Frontend state surfaces, and vendor-observable model data - #17
Merged
Conversation
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.
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned Files
|
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.
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 throughpkg/exactly as a third-party plugin would be.Frontend state surfaces
FrontendService.AttachandWidgetService.Attachwere backwards. Both were declaredAttach(stream ClientEvent) returns (stream ServerEvent)and served by the plugin, so under go-plugin the plugin could onlyRecv()client events andSend()server events — the reverse of whatfrontend-protocol.mddescribed. Both specs had been written as if the kernel were the gRPC server.Attachis deleted; kernel→plugin traffic ridesSubscribeon the callback channel, where the plugin genuinely is the client.The
Region/PlacedContentvocabulary went with it. Region names likeREGION_HOTKEY_HINTSandSIDEBARare TUI furniture, andfrontend/conformance.mdalready 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/v1had nowhere to put. The cost was concrete —plugin-provider-xaialready parsedcost_in_usd_ticks,system_fingerprint,service_tier, andnum_sources_usedand then onlyslogged them.Usage—vendor_cost(exact decimal string + vendor unit),vendor_total_tokens,components[],reasoning_already_countedRateLimitSnapshot—limit_id,limit_name,window_role,used_percent,window_seconds,CREDITSkindStreamMetadata(new event) —actual_model, fingerprint, tier, live ceilings, catalog etag, early rate limitsGetAccount(new optional RPC) +AuthDescriptoronCapabilitiesModelSpec—CatalogMetadata(display name, aliases, family), context/verbosity/tier fields;Pricingimage+audio rates andsource_unitGenerationParams— service tier, verbosity, response format, cache key, store, parallel-tools overrideThinkingDeltachannels,Stop.model_affirmed,SafetyNotice,ImageBlock.detailSessionStatesurfaces quotas, account, vendor cost, actual modelVendor cost is recorded, never authoritative.
cost_usdstays derived fromModelSpec.pricingat 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, sobuf breakingisn't armed.Kernel bring-up ordering
A frontend calls
CreateSessionfrom inside its ownConfigure, butConfigureran while the host slot was still empty, sokernelcallbackansweredUnimplemented.Supervisor.Startsplits intoPrepare+Configure(want):Preparelaunches, 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:
callbackSlotwas missing sixteen RPC forwards. It forwards each explicitly and embeds the generatedUnimplementedserver 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 diffsslot.goagainst the generatedServiceDesc.Checklist
go mod tidyis a no-op,go build ./...,go vet ./...,gofmt -l -s .prints nothing,go test -race -covermode=atomic ./...,golangci-lint rundocs/specifications/document in this same PRpkg/*/proto/v1/—.protochanges made inapi/and regenerated withbuf generateinternal/packages includeREADME.md+CLAUDE.mdin the same commitbin/(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-4request comes back served bygrok-4.3— exactly the silent model substitution that previously vanished into a log line, and which also meantcost_usdcited pricing for a model that never ran.Hello Worldruns end to end through kernel → provider → API.Deferred, deliberately:
cost_ledgermigration. Vendor cost andactual_modelreachSessionState(the live surface) but are not yet persisted, so the ledger still records the requested model id. Theactual_modelchain is unit-tested throughstreamaccum → modelcall → turn → session, but never observed end to end, because in-promptmode nothing displays or persists it.ContentBlockkind plus stream events, and is the one item genuinely under-specified by two vendors. A guess would be worse than the gap.catalog_etagandcatalog_fetched_atship so staleness is detectable; nothing acts on them yet.A protocol gap worth close reading.
pluginhostfetchesGetCapabilitiesat bring-up step 5, beforeConfigureat step 8 — it must, since theConfigSchemaneeded 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 offeredgrok-4-3while the provider rejected it as unknown, because its live catalog spells that modelgrok-4.3. Worked around in.dev/withfetch_models = false; the real fix is a capabilities-refresh path.Behavior change:
-promptmode no longer configures frontends. A frontend'sConfigureis 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_backpressureClosesfailed once under-shuffle=onthen passed 3/3 on rerun. It's ininternal/kernelcallback, which this branch doesn't touch.