Skip to content

Frontend state surfaces, and vendor-observable model data - #17

Merged
scrothers merged 14 commits into
mainfrom
protocol/frontend-state-surfaces
Jul 28, 2026
Merged

Frontend state surfaces, and vendor-observable model data#17
scrothers merged 14 commits into
mainfrom
protocol/frontend-state-surfaces

Conversation

@scrothers

Copy link
Copy Markdown
Member

What & why

Two related things land together: the frontend state-surface protocol revision (replacing the Attach/region model) and a model-protocol extension that gives vendor-observable data a typed home. Along the way the repo drops everything that isn't the kernel — the terminal shell, the reference model provider, and the standalone example — since each now lives in its own repo and is consumed through pkg/ exactly as a third-party plugin would be.

Frontend state surfaces

FrontendService.Attach and WidgetService.Attach were backwards. Both were declared Attach(stream ClientEvent) returns (stream ServerEvent) and served by the plugin, so under go-plugin the plugin could only Recv() client events and Send() server events — the reverse of what frontend-protocol.md described. Both specs had been written as if the kernel were the gRPC server. Attach is deleted; kernel→plugin traffic rides Subscribe on the callback channel, where the plugin genuinely is the client.

The Region/PlacedContent vocabulary went with it. Region names like REGION_HOTKEY_HINTS and SIDEBAR are TUI furniture, and frontend/conformance.md already admitted the set had never been validated against a second frontend. State surfaces replace it: the kernel owns session state, metadata, and deltas; a frontend reads them and decides its own layout.

Model protocol extension

Research notes 27 and 28 cataloged data OpenAI/Codex and xAI/Grok publish that model/v1 had nowhere to put. The cost was concrete — plugin-provider-xai already parsed cost_in_usd_ticks, system_fingerprint, service_tier, and num_sources_used and then only slogged them.

  • Usagevendor_cost (exact decimal string + vendor unit), vendor_total_tokens, components[], reasoning_already_counted
  • RateLimitSnapshotlimit_id, limit_name, window_role, used_percent, window_seconds, CREDITS kind
  • StreamMetadata (new event) — actual_model, fingerprint, tier, live ceilings, catalog etag, early rate limits
  • GetAccount (new optional RPC) + AuthDescriptor on Capabilities
  • ModelSpecCatalogMetadata (display name, aliases, family), context/verbosity/tier fields; Pricing image+audio rates and source_unit
  • GenerationParams — service tier, verbosity, response format, cache key, store, parallel-tools override
  • ThinkingDelta channels, Stop.model_affirmed, SafetyNotice, ImageBlock.detail
  • SessionState surfaces quotas, account, vendor cost, actual model

Vendor cost is recorded, never authoritative. cost_usd stays derived from ModelSpec.pricing at event time so replay stays reproducible; the vendor figure sits beside it for reconciliation. Amounts are decimal strings, not doubles — these reconcile against invoices.

All additive: new fields, new oneof members, new enum values, one new RPC. No v* tag exists yet, so buf breaking isn't armed.

Kernel bring-up ordering

A frontend calls CreateSession from inside its own Configure, but Configure ran while the host slot was still empty, so kernelcallback answered Unimplemented. Supervisor.Start splits into Prepare + Configure(want): Prepare launches, describes, and registers everything — which is also what reveals a dev override's category — then the kernel configures non-frontends, builds catalog/hooks/runner/host, and configures frontends last.

That exposed the real cause: callbackSlot was missing sixteen RPC forwards. It forwards each explicitly and embeds the generated Unimplemented server as a compile-time guard, but the state-surface RPCs went in without forwards, so they answered the generated stub. The existing guard test enumerated RPCs by hand and couldn't catch it; the new one diffs slot.go against the generated ServiceDesc.

Checklist

  • Local gate passes: go mod tidy is a no-op, go build ./..., go vet ./..., gofmt -l -s . prints nothing, go test -race -covermode=atomic ./..., golangci-lint run
  • Observable behavior changes update the matching docs/specifications/ document in this same PR
  • No hand edits under pkg/*/proto/v1/.proto changes made in api/ and regenerated with buf generate
  • Doc cross-references are path + heading anchor (never section numbers); renamed headings were grepped for inbound anchors
  • New internal/ packages include README.md + CLAUDE.md in the same commit
  • No compiled artifacts outside bin/ (verified: no non-source paths and no blob >1MB in the range)

Notes for reviewers

Verified against the live xAI API, not just tests. A grok-4 request comes back served by grok-4.3 — exactly the silent model substitution that previously vanished into a log line, and which also meant cost_usd cited pricing for a model that never ran. Hello World runs end to end through kernel → provider → API.

Deferred, deliberately:

  • cost_ledger migration. Vendor cost and actual_model reach SessionState (the live surface) but are not yet persisted, so the ledger still records the requested model id. The actual_model chain is unit-tested through streamaccum → modelcall → turn → session, but never observed end to end, because in -prompt mode nothing displays or persists it.
  • Hosted tool item kinds (27 P2). Needs a ContentBlock kind plus stream events, and is the one item genuinely under-specified by two vendors. A guess would be worse than the gap.
  • Capabilities refresh. catalog_etag and catalog_fetched_at ship so staleness is detectable; nothing acts on them yet.

A protocol gap worth close reading. pluginhost fetches GetCapabilities at bring-up step 5, before Configure at step 8 — it must, since the ConfigSchema needed to decode the provider block arrives with that advertisement. So a provider whose roster depends on Configure-time state advertises one roster and enforces another, and nothing re-fetches to reconcile. Hit live: the kernel offered grok-4-3 while the provider rejected it as unknown, because its live catalog spells that model grok-4.3. Worked around in .dev/ with fetch_models = false; the real fix is a capabilities-refresh path.

Behavior change: -prompt mode no longer configures frontends. A frontend's Configure is what seizes the terminal and starts a session of its own, and a non-interactive run has neither a terminal to give away nor anything for a second session to do. They're still prepared, so they stay registered and catalog-visible.

Pre-existing flake, unrelated to this branch: TestServer_Subscribe_backpressureCloses failed once under -shuffle=on then passed 3/3 on rerun. It's in internal/kernelcallback, which this branch doesn't touch.

scrothers added 13 commits July 27, 2026 17:32
pluggableharness.tool.v1 has always modeled a plugin as exposing N
operations: GetSchemaResponse.tools is repeated, ToolSchema.name is
unique within a provider's namespace, and ToolCall.tool_name names which
one to run. The kernel matches -- providercatalog's unit is one
provider's one operation. Only the Go SDK didn't: Provider bundled every
operation into a single Invoke switching on Call.ToolName, with Previewer
at plugin level needing a second switch on the same value, and nothing
tying a declared schema to the code behind it.

Introduce Tool as the unit an author implements, owning its own Schema
and Invoke, with Previewer optional per tool. Provider keeps only what is
genuinely plugin-wide: the tool set and Configure. Service owns the
name-keyed dispatch, so no implementation switches on ToolName and an
unknown name is rejected as invalid_arguments before reaching any tool.

Tools() is static -- no context, no error -- because pluginhost brings a
plugin up as Describe, GetSchema, decode config, then Configure, so the
tool set is already fixed before a provider knows its configuration.
Schema() drops its context for the same reason protocol.md forbids a
network call there, and NewService resolves every tool in one pass at
construction, turning a duplicate name or invalid schema into a startup
error and letting GetSchema serve a cached advertisement.

Renderer stays plugin-level: RenderRequest carries only the opaque
payload and its schema version, with no tool name to dispatch on.

No proto, specification, or kernel change -- the wire contract already
described this shape.
Docs will live outside this repository, so the in-tree example module
goes with them. Removing the directory means removing everything that
pointed at it: the Dependabot gomod entry for the second module, the
CODEOWNERS rule, the CI step that built and tested it, and the modeltest
integration test that compiled it as its subject.

Two verification roles disappear with it, both worth restoring later by
other means. The removed CI step was the only check that pkg/ is
genuinely consumable from outside the main module -- the depguard rule on
internal/anthropic simulates that isolation but cannot catch an
unexported type leaking through an exported signature. And
TestRunBinary_againstTheExampleProvider was the only test exercising a
plugin's own main() wiring: handshake config, Serve, identity stamping.
modeltest.RunBinary now has no in-repo caller; CheckBinary keeps its
launch-failure test.

No required status check changes: the deleted CI step lived inside the
existing "Build & vet" job, so every job name the protect-main ruleset
matches on is unchanged.
Retire inverted Frontend/Widget Attach streams and Region placement.
Kernel-push and frontend control move to KernelCallbackService: session
lifecycle, SubmitInput (returns turn_id), GetSessionState, metadata
publish/list/retract, StreamDeltas, and plan/interactive resolution.

Wire: new metadata.v1, SessionState, kernel RPCs; frontend/widget reduce
to GetCapabilities/Configure/Describe; PlanDecisionScope moves to plan.v1.

SDK and kernel: pkg/frontend and pkg/widget drop Attach; pkg/metadata
builders; pkg/kernel frontend helpers; internal/metadata store and
kernelcallback handlers (metadata fully wired; agent-loop RPCs honest
Unimplemented). Specs and grpc.md rewritten fix-forward for the four
surfaces. TUI region becomes local layout chrome only.
Wire every remaining frontend surface RPC to the agent loop:

- session.Handle Open/Submit/Interrupt/Close for multi-turn chat
  (stays RUNNING after each response until Close)
- pending PlanBridge/InteractiveBridge with CLI autoallow fallback
- frontendHost implements CreateSession, SubmitInput, ListSessions,
  ResolvePlanDecision/Interactive, TriggerAction, InvokeSlashCommand
- HostSlot late-binds the host into every plugin callback server
- Metadata store + DeltaHub on supervisor; text deltas from modelcall
- SessionState assembly (cwd, vcs, model, tokens) + kernel.state publish
- cmd/tui default is plugin.Serve; -demo keeps offline scripted layout

All unit tests pass.
Three were real defects rather than style.

internal/kernel/frontendhost.go seeded a session's first turn from a
goroutine using context.Background() and discarded both return values:
uncancellable, silently swallowing a failed initial prompt, and severing
trace parentage on every session opened with one -- exactly what
logging-telemetry.md forbids. Now context.WithoutCancel, with the error
logged.

cmd/tui/plugin.go assigned its shell goroutine's CancelFunc and never
called it anywhere, so the TTY loop and its Subscribe/StreamDeltas
streams had no shutdown path. Adds Close, called once plugin.Serve
returns, and derives the run context with WithoutCancel so the shell
keeps Configure's trace values while detaching from its cancellation.

pkg/kernel/frontend.go returned nil for any StreamDeltas error that
coincided with a done context, discarding real failures. Now matches the
pattern pkg/tool/server.go already uses: EOF and cancellation are normal
control flow, everything else propagates.

The rest: probeVCS shelled out to git three times with exec.Command and
no context, so a SessionState snapshot could outlive its request on a
large working tree -- now CommandContext, with one justified nosec since
the command is constant and only the -C path varies. Plus an unchecked
Close, three errorlint comparisons, an empty if, a dead keying helper, a
grpcCode switch whose arms all returned Internal, and a stray
var _ = time.Time{} pinning an unused import.

pkg/frontend/doc.go needed rewording rather than gofmt -w: gofmt turns
its wrapped Metadata bullet into a second list item, which changes what
the doc says.

Not addressed, and pre-existing on this branch: the integration test
TestRun_completesOneSessionEndToEnd fails with "the auto-allow deviation
did not produce its WARN". Verified against HEAD without these changes.
internal/tui and cmd/tui are gone, along with
docs/first-party/frontends/tui.md. The reference shell is
pluggableharness/plugin-frontend-tui, versioned and released on its own
cadence like any other plugin, and keeping a second copy here only
guaranteed the two would drift.

The cut is clean: nothing outside internal/tui and cmd/tui ever imported
internal/tui, and goreleaser never built the shell. What remained were
references -- CODEOWNERS, the mkdocs nav, and two paragraphs of CLAUDE.md
describing a third binary that no longer exists.

docs/first-party/frontends/ keeps its index, now pointing at the external
repository: a frontend implementation is a plugin, so its design
documentation ships with it. Two claims in that index were also corrected
while it was open, both left stale by the state-surfaces revision rather
than by this removal -- it described frontends as painting into "the
region vocabulary" and emitting ClientEvents, and the protocol now has
neither a placement vocabulary nor a frontend-emitted event type.

Also deleted an untracked 25MB `tui` binary someone had compiled to the
repo root, against the bin/-only rule.

Not touched: `mkdocs build --strict` fails with 12 broken anchors into
frontend-protocol.md and render-tree.md. Verified pre-existing by
building at HEAD with these changes stashed -- same 12, none involving
tui.md. That is the state-surfaces revision renaming spec headings
without grepping for inbound anchors, and it wants its own fix.
`go build ./cmd/agent` from the repo root lands at /agent, which was
already ignored. `cd cmd/agent && go build` lands beside the main
package, which was not -- and that is the shape that put a 21 MB
providerconform binary into acd6a5f, later purged onto
backup/pre-binary-purge.

Also drops two now-stale entries: /tui and
examples/*/agent-example-provider, for trees that no longer exist.
a32c249 deleted internal/tui and cmd/tui but left their dependency
footprint in go.mod and go.sum -- the whole Charm stack (bubbletea,
lipgloss, ansi, ultraviolet, colorprofile, termios, cancelreader,
uniseg, terminfo and friends), none of which anything else imports.

CI's tidy check would have failed on that commit; this is the no-op it
should have been. Verified independent of the Anthropic removal landing
next: `go mod tidy` strips the same 51 lines with that change stashed.
cmd/anthropic and internal/anthropic are gone. Nothing outside those two
directories ever imported internal/anthropic, so the code cut is clean;
what remained was build and lint wiring.

Removed with it: the anthropic GoReleaser build (the kernel is now the
only binary this repo ships), the anthropic-plugin-isolation depguard
rule in .golangci.yml -- which scoped to a path that no longer exists,
and was depguard's only rule, so the linter goes too -- and the two
binary shapes in .gitignore.

Deliberately kept:

  - docs/first-party/providers/anthropic.md. That is vendor research,
    one of four capability reports alongside OpenAI, Google and xAI, and
    it documents Anthropic's API rather than our adapter. It is exactly
    as useful to someone writing that adapter in another repository.
  - pkg/sse. It now has no non-test consumer in this tree, which is worth
    knowing, but go-layout.md's test for belonging there is whether a
    third-party plugin author would otherwise copy it out of a provider
    -- and that is still yes.
  - Every "anthropic" in spec examples, config fixtures and comments.
    Those are illustrative provider names, not references to this code.

README.md needed no change: its agent.hcl example already points at
github.com/pluggableharness/provider-anthropic, so the documented story
was always that this ships out of tree.
Omitting -prompt now selects a second mode: the kernel brings every
provider up, installs the frontend host, and waits while a frontend
plugin creates and drives sessions over the callback channel, exiting
when the frontend's subprocess does or on Ctrl-C. With -prompt the old
one-shot behavior is unchanged. runSession is split so both modes share
one runner and one host installation.

Waiting needs a signal, and deleting Attach removed the one that used to
exist -- a stream whose closure said the operator was gone. go-plugin
offers no completion channel either, so Plugin.Exited and Live.Exited
expose the subprocess state read-only (observing that a plugin is gone is
not the same capability as being able to kill one) and hosted mode polls
them every 250ms. Nothing latency-sensitive rides this; every real signal
travels the callback channel.

Hosted mode with no frontend loaded is ErrNoFrontend rather than a wait
that never ends.

.dev/ holds committed scaffolding for running against locally built
plugins: a global config carrying dev_overrides, a project agent.hcl, and
a README. No code was needed to make the global config repo-local --
internal/xdg reads XDG_CONFIG_HOME, so pointing it at .dev/ is enough.

Driven end to end against the real TUI binary under a pty. dev_overrides
resolves, the subprocess launches, Configure runs, the TTY opens, and the
plugin calls back into the kernel -- which is where it stops, on a
genuine ordering bug described in the next commit message... except there
is no next commit, so: the frontend calls CreateSession from inside its
Configure handler, and Configure runs during bringUp, while hostSlot is
still empty because run() installs the host only after bringUp returns.
kernelcallback answers Unimplemented. Not fixed here because the fix is a
bring-up ordering decision, not a patch.
A frontend calls CreateSession from inside its own Configure handler, but
Configure ran during bringUp while hostSlot was still empty, so
kernelcallback answered Unimplemented and the TUI could never start a
session.

Split Supervisor.Start into Prepare and Configure(want). Prepare launches,
describes, and registers every provider — which is also what reveals a dev
override's category, since its lock file has none. The kernel then
configures everything except frontends, builds the catalog, hook chains,
runner, and host, and configures frontends last. Start remains
Prepare+Configure(nil), so existing callers and the integration tier are
unchanged.

That exposed the real cause of the Unimplemented: callbackSlot forwards
each RPC explicitly and embeds the generated Unimplemented server as a
compile-time guard, but the frontend state-surface work added CreateSession
and fifteen others without forwards, so they answered the generated stub
instead of reaching internal/kernelcallback. Add the missing sixteen.

TestCallbackSlot_forwardsEveryRPC could not catch that — it enumerates RPCs
by hand. The new guard reads slot.go and diffs it against the generated
ServiceDesc, so a future RPC without a forward fails with its own name.
Reflection cannot do this job: Go names a promoted wrapper after the outer
type, making a promoted method indistinguishable from a declared one.

Also skip the frontend Configure pass entirely under -prompt. A frontend's
Configure is what seizes the terminal and starts a session of its own, and
a non-interactive run has neither a terminal to give away nor anything for
a second session to do. Frontends are still prepared, so they stay
registered and catalog-visible; only the step with side effects is
withheld.

.dev/ points at the locally built frontend and xAI provider binaries.
Research notes 27 and 28 cataloged data OpenAI/Codex and xAI/Grok publish
on the wire that model/v1 had no typed place for. The cost is concrete:
plugin-provider-xai already parses cost_in_usd_ticks, system_fingerprint,
service_tier, and num_sources_used and then only slogs them, because there
was nowhere to put them.

Usage gains vendor_cost, vendor_total_tokens, components, and
reasoning_already_counted. Vendor cost is recorded, never authoritative —
cost_usd stays derived from ModelSpec.pricing at event time so replay stays
reproducible, and the vendor figure sits beside it for reconciliation.
Amounts are decimal strings, not doubles: these reconcile against invoices.

RateLimitSnapshot gains limit_id, limit_name, window_role, used_percent,
and window_seconds, plus a CREDITS kind. This retires the mapping note 27
calls a semantic lie, where a percentage-only vendor faked limit=100 and
remaining=100-percent to fit the absolute fields.

A new StreamMetadata event carries actual_model, system_fingerprint,
service_tier, live context/output ceilings, catalog etag, and early rate
limits. actual_model is the load-bearing one: a vendor silently remapping
grok-4 to grok-4.3 is how an operator experiences "it got worse" with
nothing to point at, and it also means cost_usd cites pricing for a model
that never ran. Neither StreamMetadata nor StreamStart is a block boundary;
splitting a text run on a late header would corrupt the message.

GetAccount is a new optional RPC for live pool and entitlement state, which
no per-completion message can report before the first completion runs. It
returns quotas as RateLimitSnapshot rather than a parallel shape, and is
deliberately not persisted — it reads external state no replay could
reproduce.

ModelSpec gains CatalogMetadata plus context, verbosity, tier, and backend
fields; aliases now resolve to the canonical handle instead of being
published as duplicate specs, so one model stops looking like three.
Pricing gains image and audio rates and source_unit, so an adapter's
tick-to-USD conversion is auditable rather than private.

SessionState surfaces quotas, account, vendor_cost, and actual_model, so
none of this repeats the StreamStart problem: defined on the wire, dropped
by the kernel, invisible to every frontend.

All additive — new fields, new oneof members, new enum values, one new RPC.
No v* tag exists yet, so buf breaking is not armed.
Completes the 27/28 extension set.

GenerationParams gains service_tier, verbosity, response_format,
prompt_cache_key, store, a per-request parallel_tool_calls override, and
reasoning_summary. prompt_cache_key is typed rather than left to
provider_options for the reason data-types.md already states: cache hits
feed cost_usd, and a field the kernel acts on cannot ride a pass-through.
StreamCompletionRequest gains sticky_turn_token for vendors that keep turn
state server-side, which the kernel must know about because a continuation
changes what messages it sends.

ThinkingDelta gains channel and part_index. A channel switch closes the
open block: a vendor-written summary and the raw reasoning it summarizes
are different text, and concatenating them on adjacency would produce one
block that reads as neither. Providers setting no channel coalesce exactly
as before.

SafetyNotice reports buffering, moderation, and verification-required.
Buffering is the useful one — it turns an unexplained stall into an
explained one. Like StreamMetadata it is not a block boundary. A kernel
that does not recognize a kind ignores it rather than failing the turn.

Stop gains model_affirmed, separating "the model said it was done" from
"nothing further arrived". ImageBlock gains a detail enum, which is a cost
control: vendors bill high-detail image input at a multiple of low.

Specs updated to match: data-types.md documents vendor cost as
reported-never-authoritative, the percentage-vs-absolute rate-limit rule,
stream metadata, and the not-a-block-boundary rule; protocol.md documents
GetAccount and its tolerated absence.

plugin-provider-xai now emits what it previously only slogged —
cost_in_usd_ticks as vendor_cost in its native unit, num_sources_used as a
usage component, and actual_model/system_fingerprint/service_tier as
StreamMetadata. Verified live: a grok-4 request comes back served by
grok-4.3, which is exactly the substitution that used to vanish into a log
line.

Also pins fetch_models=false in .dev. pluginhost fetches GetCapabilities at
step 5, before Configure at step 8 — it must, since the ConfigSchema
arrives with that advertisement — so a provider whose roster depends on
Configure-time state advertises one roster and enforces another, and
nothing re-fetches to reconcile them.
Copilot AI review requested due to automatic review settings July 28, 2026 16:45
@scrothers
scrothers requested review from a team as code owners July 28, 2026 16:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

  • examples/provider/go.mod
  • go.mod

Comment thread internal/session/handle.go Fixed
Two CI failures the local gate missed.

gosec: probeVCS carried only //nolint:gosec, which golangci-lint honors and
the standalone gosec the security workflow runs does not — it reads #nosec.
Both are now present, and the #nosec sits on the flagged line itself
because on a preceding line it does not bind to a node inside that closure.
gosec is a required check, so this blocked merge outright.

Docs: `mkdocs build --strict` aborted on fifteen warnings. Three were newly
introduced here — two markdown links into .claude/rules/, which is not part
of the site, and one link to a data-types.md#capabilities anchor that does
not exist. The house convention for rule references is plain backticks, as
provider_options already used; these now match, including one older
instance in frontend/README.md that had the same defect.

The remaining twelve were anchors this branch's own protocol revision broke
by renaming the headings they pointed at, so they are regressions against
main rather than pre-existing debt:

  render-tree.md#schema-versioning
    -> #schema-versioning-for-opaque-emit-payloads   (6 files)
  frontend-protocol.md#resume-and-re-open-semantics
    -> #session-lifecycle                            (2 files)
  frontend-protocol.md#backfill--the-replay-path-not-a-new-subsystem
    -> #transcript
  frontend-protocol.md#plan_decisioncorrected_input
    -> #plan-and-interactive-resolution

Verified with a real `mkdocs build --strict` locally, not by grepping:
0 warnings, exit 0.
@scrothers
scrothers merged commit 1984dcf into main Jul 28, 2026
15 checks passed
@scrothers
scrothers deleted the protocol/frontend-state-surfaces branch July 28, 2026 16:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants