Skip to content

Implement pkg/** — the plugin-author SDK - #8

Merged
scrothers merged 43 commits into
mainfrom
feat/pkg-plugin-sdk
Jul 25, 2026
Merged

Implement pkg/** — the plugin-author SDK#8
scrothers merged 43 commits into
mainfrom
feat/pkg-plugin-sdk

Conversation

@scrothers

Copy link
Copy Markdown
Member

What & why

Implements pkg/** — the plugin-author-facing Go SDK. Before this PR, every pkg/<category>/ directory contained only buf-generated proto/v1/*.pb.go stubs; a plugin author implemented raw generated gRPC server interfaces, hand-rolled the hashicorp/go-plugin handshake, hand-dialed the callback broker, and hand-wrote Describe — byte-identical across all eight services. This PR adds the hand-written SDK layer for all seven plugin-provider categories plus the cross-cutting hook-dispatch service, so a plugin author now implements a small Go interface per category and calls one Serve function.

This PR also carries kernel/expand-callback-service (commits 685390e and earlier back to 352c14a) as a merged prerequisite, not a separate PR — that branch's pkg/kernel hand-written SDK is what every category package in this PR builds on (dialing the callback channel, CountTokens, logging/tracing), and it was local/unpushed when this work started. Reviewers should expect two logically distinct pieces of work bundled here: the kernel-callback-service expansion (proto restructuring, KernelCallbackService RPCs, pkg/kernel) and this session's pkg/** SDK on top of it.

New packages

Foundation (cross-cutting, no proto of their own except pkg/plugin):

  • pkg/plugin — the shared plugin-subprocess serving layer: Serve, multi-service muxing (Config.Services), the lazily-dialed kernel-callback handle (Callback), IdentityProducerRef, and StatusError for consistent gRPC error mapping.
  • pkg/renderRenderTree/RenderNode builders + schema_version dispatch.
  • pkg/config — validating ConfigSchema/ConfigAttribute builders (enforces the object_attributes/sensitive/default_json invariants).
  • pkg/schema — the restricted JSON-Schema-subset builders for tool/model I/O schemas.
  • pkg/contentContentBlock builders (text/image/document/tool_use/tool_result/thinking).
  • pkg/kernel, pkg/telemetry — extended with the remaining KernelCallbackService RPC wrappers (RunSession, CountTokens, Emit, ReadEvents, GetSession); fixed pkg/telemetry.Bootstrap returning an unnameable internal/telemetry.Provider to out-of-tree callers.

Category SDKs (each: domain types, Provider interface, convert.go, server.go gRPC adapter, error mapping, ≥80% coverage):
pkg/model, pkg/tool, pkg/context, pkg/memory, pkg/frontend, pkg/widget, pkg/hook (the shared HookSubscriberService), pkg/slashcommand (reuses pkg/tool's Kind/RiskClass/ConcurrencySpec/Result/Error/OutputStream verbatim, per spec mandate — verified at the generated-proto level, not just the Go-domain level).

Design decision requiring a rule change

The SDK uses rich Go domain types converted to/from the generated wire message at the package boundary (e.g. tool.Call, model.Spec), rather than passing the generated proto type straight through. .claude/rules/go-layout.md previously read as a blanket "exactly one Go representation of each wire message" rule that would forbid this. Amended it to state the real boundary explicitly: pkg/<category> MAY define domain types; internal/ MUST still consume the generated types directly, unchanged — the rule's actual, original target.

End-to-end proof

internal/pluginruntime/testdata/plugin/main.go — previously a hand-rolled hashicorp/go-plugin adapter existing purely as an integration-test fixture — is rebuilt on pkg/plugin/pkg/tool/pkg/hook. TestLaunch_realSubprocess passing is this SDK's own end-to-end proof: a plugin built with nothing but the public SDK surface launches, handshakes, dispenses ToolServiceClient, answers GetSchema, and calls back into KernelCallbackService.Log successfully. The fixture also registers hook.Service alongside tool.Service on the same Config.Services, proving pkg/plugin's multi-service muxing doesn't break a real launch (it does not itself invoke DispatchHookinternal/pluginruntime.Plugin deliberately dispenses only the primary category client, not the raw connection a second client would need).

Lint findings across independently-built packages

Six of nine hand-written packages were built concurrently in separate worktrees; three (pkg/tool, pkg/context, pkg/frontend) had skipped running golangci-lint themselves and shipped package-name-stutter violations (tool.ToolError, context.ContextRequest, frontend.FrontendError, etc. — revive) that the other six independently caught and fixed in their own siblings. Fixed directly on the integration branch before merge, verifying the generated <category>v1.XxxError wire type names (which must NOT be renamed) weren't touched in the process.

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 — plus buf lint, buf format --diff --exit-code, a buf generate drift check (no diff), gosec -exclude-generated ./... (0 issues), govulncheck ./... (no vulnerabilities)
  • Observable behavior changes update the matching docs/specifications/ document in this same PR — N/A: this PR is purely additive Go SDK code over already-frozen, already-specified wire types; no protocol behavior changed
  • No hand edits under pkg/*/proto/v1/.proto changes made in api/ and regenerated with buf generate — N/A: no .proto changed in this PR
  • Doc cross-references are path + heading anchor (never section numbers); renamed headings were grepped for inbound anchors — no headings renamed
  • New internal/ packages include README.md + CLAUDE.md in the same commit — N/A: no new internal/ packages (only pkg/ SDK packages, which follow go-layout.md's doc.go convention instead)
  • No compiled artifacts outside bin/

Notes for reviewers

  • The kernel/expand-callback-service bundling (see above) is the one thing worth confirming you're comfortable with before merge — it wasn't originally scoped as part of this PR's ask, but the pkg/** SDK has a hard dependency on it.
  • pkg/slashcommand's six-type reuse is verified at the wire level in its own commit message (e152d08): the generated slashcommandv1.SlashCommandSpec.Kind field's Go type is literally toolv1.ToolKind, etc.
  • pkg/tool/stream.go's Invoke stream-contract enforcement (exactly one terminal event, exit_status cardinality, output ordering) is mirrored — not shared via a common package — in pkg/slashcommand/stream.go, since the two categories' wire response types are distinct generated messages despite identical semantics per spec.

scrothers added 30 commits July 24, 2026 15:35
Introduce .claude/rules/proto-layout.md: a fixed slot template
(service/rpc_request/rpc_response/events/types/errors) that every
proto package's files must follow, replacing the current one-file-
per-package convention. Assignment is by wire role, not name suffix.

Update proto.md's file-path bullet to point at the new rule instead
of asserting the now-superseded <category>.proto single-file shape,
and add a pointer bullet to the Copilot proto instructions.
Apply proto-layout.md's slot template to the 10 packages that declare
no service: common, config, content, log, plan, render, schema,
session, slashcommand collapse to a single types.proto; event (a flat
event/payload registry) collapses to events.proto. No declarations
move between packages and no wire types change — this is a rename
plus the cross-package import-path fixups it forces in the 8
service-bearing packages that import these leaves. Those packages'
own service.proto/rpc_request.proto/etc. split lands separately.

Regenerated pkg/*/proto/v1/ and removed the now-stale <name>.pb.go
files buf generate leaves behind on a rename.
Apply proto-layout.md's slot template to the 8 packages that declare
a service — model, frontend, memory, tool, hook, context, kernel,
widget — each collapsing from one monolithic <category>.proto into
service.proto, rpc_request.proto, rpc_response.proto, events.proto
(where the service has streamed or oneof-envelope traffic),
types.proto, and errors.proto (where the package defines one).
Assignment follows wire role, not name suffix, per the rule's
Contribute/RunSession-style examples.

No wire type changes: buf breaking with breaking.use=[PACKAGE] against
main reports zero findings, and go doc -all diffs to zero once vN
import-alias renumbering is normalized out, across all 18 proto
packages. The only Go-visible effect is the generated
File_pluggableharness_<pkg>_v1_<file>_proto reflection descriptor
variable renaming to match its new source filename, an unavoidable
consequence of the split protoc-gen-go itself performs.

Also fixes the forward-looking cross-package imports this split
forces in already-split leaf files (event/v1/events.proto,
plan/v1/types.proto) and updates five docs/specifications/ prose
references to now-renamed proto files, per CLAUDE.md's fix-forward
policy.

Regenerated pkg/*/proto/v1/ and removed the stale monolithic
<name>.pb.go/<name>_grpc.pb.go files buf generate leaves behind.
A direct-invoke slash command was previously a shortcut into a tool
call: SlashCommandSpec.tool_name named a ToolSchema operation declared
by the same provider, dispatched through ToolService.Invoke. That only
made sense for a provider that was also a tool provider, which the
spec never quite admitted (model/context/memory could declare
slash_commands too, with no tool op a tool_name could reference).

Split SlashCommandSpec into two distinct things:

- A direct-invoke command IS a tool-shaped operation now, not a
  reference to one. New pluggableharness.slashcommand.v1 category (7th
  provider category, CATEGORY_SLASHCOMMAND = 7) with its own
  SlashCommandService (GetCapabilities/Configure/Invoke/Render/
  Preview/Describe), mirroring tool.v1's shape closely. SlashCommandSpec
  gains its own input_schema and reuses tool.v1's ToolKind/RiskClass/
  ConcurrencySpec/ToolResult/ToolError/OutputStream directly rather than
  duplicating them -- same gating and streaming semantics, one source of
  truth. No more tool_name indirection.

- A prompt-expansion command never executes anything -- it's a
  template string the kernel expands into a user_message. Renamed
  PromptExpansionSpec, the now-pointless Dispatch enum is gone (each
  type implies its own mode positionally), and it moves to
  common.v1: it has zero dependency on slashcommand.v1's own
  vocabulary, and homing it in common.v1 (the existing import-nothing
  leaf) avoids a tool.v1 <-> slashcommand.v1 package import cycle that
  buf lint's PACKAGE_NO_IMPORT_CYCLE rule rejects outright once
  slashcommand.v1 imports tool.v1 for the reused vocabulary above.
  model/tool/context/memory/frontend's slash_commands fields narrow to
  this type.

Since a direct-invoke command now flows through the same plan/apply
gate as a tool call, plan.v1.PlanItem generalizes: tool_call_id ->
call_id, tool_name -> operation_name, plus a new producer_category
field (CATEGORY_TOOL or CATEGORY_SLASHCOMMAND) so a consumer knows
which category's Invoke/Preview produced a given item. kind/risk stay
typed as tool.v1.ToolKind/RiskClass -- now understood as reused
gated-operation vocabulary, not tool-exclusive. ApplyResult.ApplyItem's
oneof needs no new variants, since tool_result/tool_error are already
the same reused tool.v1 types.

frontend.v1's SlashCommandRegistry splits into direct_invoke_commands
(slashcommand.v1.SlashCommandSpec) and prompt_expansion_commands
(common.v1.PromptExpansionSpec), collision-checked jointly.

docs/specifications and .claude/rules updates land in follow-up
commits -- this commit is proto + generated Go only.
Add the three category-dispatch sites the new CATEGORY_SLASHCOMMAND
enum value needs:

- internal/pluginruntime/adapter.go: newCategoryClient gains the
  CATEGORY_SLASHCOMMAND arm returning slashcommandv1.NewSlashCommandServiceClient
  -- the one site with irreducible per-category knowledge.
- internal/statebackend/event.go: producerCategoryText gains
  CATEGORY_SLASHCOMMAND -> "slashcommand"; without this,
  encodeProducerCategory rejects a slashcommand producer outright.
- pkg/common/plugin.go: PluginKey gains an explicit slashcommand arm
  for consistency with the other six, though its string-derived
  default already handled this safely.

Updated both packages' test tables and the remaining six-category doc
comments (pluginruntime/doc.go, launch.go, statebackend/doc.go).

internal/statebackend's plan_items table (schema.go/session.go/
query.go/integrity.go) still uses tool_call_id/tool_name column names
and has no producer_category column -- deliberately out of scope here.
That's a real schema migration to a tested, spec-driven package
(state-backend.md's DDL is source of truth per its own CLAUDE.md), not
covered by the approved plan's Go-plumbing scope, and belongs in its
own follow-up once slashcommand.v1 has an actual Go implementation to
migrate for.
Add docs/specifications/slashcommand/ (README, protocol, data-types,
examples, conformance), the standard five-file template, mirroring
tool/'s structure closely -- it's the closest sibling, sharing the
same gating mechanics. Documents SlashCommandService's six RPCs, the
self-contained SlashCommandSpec (no more tool_name indirection), and
the explicit reuse of tool.v1's ToolKind/RiskClass/ConcurrencySpec/
ToolResult/ToolError/OutputStream rather than a parallel taxonomy.

Update every existing document that assumed six provider categories
or the old embedded SlashCommandSpec shape:

- architecture.md, README.md, glossary.md, conventions.md,
  agent-loop/hook-dispatch.md: six -> seven provider categories,
  including a careful anchor-rename of architecture.md's
  '## The six provider categories' heading and all inbound links to it.
- frontend/{README,frontend-protocol,widget-protocol,render-tree,
  conformance}.md: SlashCommandSpec is no longer shared vocabulary --
  frontend-protocol.md's 'Slash commands' section now points at
  slashcommand/protocol.md for the direct-invoke definition and keeps
  only PromptExpansionSpec and the two-list SlashCommandRegistry;
  widget-protocol.md's 'also being a tool provider with a slash
  command' becomes 'also being a slashcommand provider'; render-tree.md's
  ActionNode dispatch now cites SlashCommandService.Invoke.
- model/tool/context/memory protocol.md + data-types.md: the embedded
  slash_commands field narrows from slashcommand.v1.SlashCommandSpec to
  common.v1.PromptExpansionSpec; tool/protocol.md drops the now-gone
  tool_name-must-reference-own-operation constraint and notes a tool
  provider wanting direct-invoke implements SlashCommandService
  alongside ToolService in the same process (go-plugin service muxing,
  same precedent as hook.v1.HookSubscriberService).
- agent-loop/plan-apply-gate.md: the PlanItem sketch reflects the real
  proto (call_id, operation_name, producer_category); plan construction,
  snapshot rationale, and Preview flow generalize from 'tool call' to
  'gated call', citing both tool.v1 and slashcommand.v1 where a
  category-specific RPC is named.
- configuration/agent-profiles.md: direct-invoke commands now fold into
  the same agent_profile.tools allow-list tool operations use;
  slash_commands narrows to scoping prompt-expansion commands only.
- configuration/{lock-file,blocks-reference}.md, state-backend.md:
  wording-only six -> seven fixes. state-backend.md's plan_items
  columns and producers-table comment are deliberately untouched --
  the actual SQLite schema (internal/statebackend) wasn't migrated in
  this work, so the docs don't claim it was.
Fix every remaining six-category mention outside docs/specifications/:

- .claude/rules/proto.md: add pluggableharness.slashcommand.v1 to the
  explicit package-name list.
- .claude/rules/grpc.md: add a Slashcommand row to the streaming-shape
  table (Invoke, server-streaming, same shape as Tool's).
- .claude/rules/go-layout.md, .claude/rules/plugin-runtime.md: six ->
  seven provider/plugin categories.
- README.md: tagline, category table (+ Slashcommand provider row),
  'mastered all six' -> 'all seven'.
- docs/index.md: six -> seven; add the Slashcommand provider card.
- mkdocs.yml: site_description, llmstxt description + sections list,
  and the nav block all gain the new slashcommand/ five-file group.
- Three stale proto doc comments on the Describe RPC ('shared verbatim
  across all six category protocols' / 'every one of the six category
  protocols') in model/v1/service.proto, frontend/v1/service.proto,
  widget/v1/service.proto -- fixed at the source and regenerated,
  never hand-edited in the generated .pb.go.
- pkg/telemetry/doc.go: six -> seven plugin categories.

Verified: buf format/lint/build clean, buf generate produces only the
three service.proto's downstream .pb.go changes, go build/vet/gofmt/
golangci-lint/test all clean, and  passes with
zero warnings -- every anchor, nav entry, and cross-reference in the
whole slashcommand rewrite resolves correctly.
Add eight new KernelCallbackService primitives to kernel-callbacks.md:
ExportSpans/RecordMetrics/GetTelemetryConfig (observability relay),
GetConfig (resolved plugin config readback), Publish/Subscribe (event
bus), and ReadEvents/GetSession (session readers). Reshape LogRequest
to carry a batch of entries instead of one.

Add two new kernel-owned specs: event-bus.md (topic grammar, filter
grammar, delivery semantics, the reserved kernel.* namespace, and the
boundary against Emit/hook-dispatch/frontend-broadcast) and
observability.md (the span-relay model, why tracing and metrics are
deliberately asymmetric, and the GetTelemetryConfig caching contract).

Update README.md's reading order, mkdocs.yml's nav, glossary.md with
five new terms, hook-dispatch.md with an explicit event-bus-vs-hook
carve-out, and blocks-reference.md with an event_bus{} config block
and a fatal addition to settings.log_level's domain.

Update .claude/rules/grpc.md (two new streaming rows, a second named
opaque-bytes carve-out), proto.md (document the Struct carve-out four
existing protos already cite but that wasn't written down, plus the
event-bus payload as a second opaque-bytes exception), and
logging-telemetry.md (clarify that span relay is not a second
trace-context propagation mechanism).
New leaf packages, mirroring log.v1's precedent rather than importing
an external OTel schema:
- trace.v1.Span: a minimal mirror of OTel's span model (trace_id/
  span_id/parent_span_id, kind, timing, status, attributes, events,
  links, instrumentation scope) for ExportSpans' transparent relay.
- metric.v1.MetricRecord: one metric observation (name, kind, oneof
  int/double value, bounded-key attributes, time) for RecordMetrics.

kernel/v1 gains a new events.proto slot (BusEvent, StoredEvent — the
two server-streamed message types) and eight new RPCs across
rpc_request.proto/rpc_response.proto/service.proto: ExportSpans,
RecordMetrics, GetTelemetryConfig, GetConfig, Publish, Subscribe,
ReadEvents, GetSession. LogRequest's single `entry` field is replaced
with `repeated LogEntry entries` (field 2 reserved, no v* tag exists
yet so this is not a breaking change in practice).

buf lint and buf format --diff both clean; pkg/*/proto/v1/ regenerated
via buf generate. internal/log/handler.go's Log RPC still references
the removed single-entry field — that break is expected and is fixed
in the next (kernel-implementation) commit, not this one.
internal/eventbus: add SubscribeFilters (exact-or-trailing-wildcard
topic filters per event-bus.md's filter grammar); Subscribe becomes
sugar over it. Subscription now tracks a filter list instead of a
single topic; Bus gains a linear-scanned wildcard registry alongside
the existing exact-topic map, deduped so a Subscription matching via
more than one filter is still delivered exactly once per Publish.

internal/telemetry: Backend gains TraceUploader(ctx) (otlptrace.Client,
error), implemented by all five drivers (real otlptracegrpc/
otlptracehttp clients for otlpgrpc/otlphttp; hand-written discarding/
pretty-printing/recording clients for noop/stdout/fake) — the
transport otlptracegrpc/otlptracehttp trace exporters wrap internally,
now also reachable as a raw Client for ExportSpans' relay path. Add
RecordDynamicMetric plus a lazily-created, per-name instrument cache
(dynamicmetric.go) for RecordMetrics' plugin-declared observations,
with attribute keys bounded to MaxDynamicMetricAttributes and drops
counted via a new instrument. Add eight new Start* span helpers for
the kernelcallback RPCs landing next, and three new instruments
(EventBusSubscribeStreamsClosed, RelayedSpans,
RecordMetricsAttributesDropped).

internal/telemetryrelay (new): translates pluggableharness.trace.v1.Span
into OTLP ResourceSpans and uploads via an otlptrace.Client, bypassing
internal/telemetry's own TracerProvider entirely so a relayed span's
trace/span identity is never reassigned (observability.md#the-relay-model).
Groups a batch by InstrumentationScope, stamps producer identity onto
the Resource using the same attribute vocabulary internal/telemetry
already uses for directly-exported resources.

Fixes internal/log's Log RPC to accept a batch of entries (already
reshaped on the wire in the prior proto commit): a malformed entry is
skipped and warned individually rather than failing the whole call;
an empty batch or an all-malformed batch still fails the RPC. Updates
the one other stale Entry: reference (pluginruntime's integration
test fixture).

go.mod: otlptrace and proto/otlp promoted from indirect to direct
(go mod tidy), matching their new direct use in the telemetry drivers.
…fig,

GetConfig, Publish, Subscribe

Server's constructor becomes NewServer(Config) — bundling Log, Producer,
Telemetry, TelemetryRelay, Bus, BusSubscribeQueueBound, ResolvedConfig,
LogLevel, and Logger, all fixed once per plugin instance for the same
server-derived-identity reason Producer already was. Updates both
existing call sites (this package's own tests, pluginruntime's
integration fixture).

New handlers:
- ExportSpans/RecordMetrics/GetTelemetryConfig (telemetry.go): relay
  spans via internal/telemetryrelay, record plugin metrics against
  kernel-owned dynamic instruments via telemetry.RecordDynamicMetric,
  and report the operator's tracing/metrics/logs signal state.
- GetConfig (config.go): returns the plugin's resolved config Struct,
  fixed at construction; never logs its own request or return value
  (GetConfig is a second channel a sensitive value can cross).
- Publish/Subscribe (eventbus.go): Publish constructs a server-derived
  topic ("plugin.{category}.{name}.{event_type}") and republishes onto
  internal/eventbus; Subscribe is a server-streaming bridge layering a
  per-stream backpressure bound on top of eventbus's own unbounded,
  never-drop contract, closing with codes.ResourceExhausted on a slow
  consumer rather than growing without bound.

RunSession/CountTokens stay stubbed pending agent-loop/CountTokens
work. Emit/ReadEvents/GetSession stay stubbed too, but for a different,
narrower reason: internal/statebackend.Store.Open already provides a
working data-read path (confirmed directly), but nothing anywhere
tracks which session(s) a plugin instance is authorized to touch —
implementing these three without that check would be silently
insecure rather than honestly unimplemented.

Fix-forward docs: internal/kernelcallback's doc.go/README/CLAUDE.md
now describe the twelve-method service as it stands; internal/eventbus's
doc.go/README/CLAUDE.md drop the "zero integration" framing now that
kernelcallback is a real caller; internal/telemetry/CLAUDE.md corrects
its "span-funnel rejected" note now that ExportSpans/TraceUploader/
telemetryrelay are exactly that funnel, implemented.

internal/telemetry gains Provider.Config() (the Config a Provider was
built from), used by GetTelemetryConfig rather than threading a second
copy through kernelcallback.Config.
New package wrapping the dialed callback connection with the
ergonomic surface a plugin author actually wants, rather than the raw
generated client:

- Client (client.go): Dial(broker) / NewClient(conn) construct it;
  Raw() escapes to the generated client for anything not (yet)
  wrapped. LoadTelemetryConfig caches GetTelemetryConfig once;
  TracingEnabled/MetricsEnabled/LogsEnabled/LogLevel/SamplingRatio
  become cached field reads with conservative defaults before the
  first load.
- SlogHandler (slog.go): a log/slog.Handler that batches records and
  flushes via Log on a timer or at a max-batch-size threshold,
  replacing one-RPC-per-line. Shares mutex-guarded state across
  WithAttrs/WithGroup-derived handlers via a pointer-held sink, so
  deriving a handler never copies a lock. attrs.go converts slog.Attr
  (including nested groups, LogValuers, durations, times) into the
  wire LogEntry.fields Struct.
- SpanExporter (span.go): an sdktrace.SpanExporter relaying completed
  spans via ExportSpans, translating identity/timing fields verbatim
  — a plugin wires an ordinary TracerProvider and the relay transport
  is invisible.
- Publish/Subscribe (eventbus.go): Publish is a one-line call;
  Subscribe owns the stream-receive goroutine and invokes a
  caller-supplied handler, so a plugin author never touches stream
  plumbing directly.
- GetConfig (config.go): returns the plugin's resolved config Struct.
- level.go duplicates (deliberately, not by import) the TRACE/FATAL
  slog.Level boundary arithmetic kernel-callbacks.md documents — pkg/
  must not depend on internal/, so a two-constant fact is copied
  rather than crossing that boundary.

Tests use a real in-memory gRPC round trip (bufconn + a hand-written
fakeServer) rather than faking the client interface, so the actual
wire marshaling this package's translators produce is what's under
test. Coverage 88.6%; Dial itself has no direct test (a *plugin.GRPCBroker
can't be constructed outside hashicorp/go-plugin, the same confirmed
limitation internal/pluginruntime's own GRPCClient has) — NewClient,
which Dial delegates to, carries the real coverage.

Also fixes a flake in internal/kernelcallback's
TestServer_Subscribe_backpressureCloses surfaced by -shuffle: Publish
returning only means an event reached its subscription's queue, not
that internal/eventbus's own delivery goroutine has invoked the
handler yet, so the final wait needed a generous bound to absorb
scheduler contention under a fully parallel test run, not the 2s it
had.

Scope note: pkg/telemetry.Bootstrap is intentionally NOT reworked to
build on this relay by default in this change. Bootstrap currently
returns internal/telemetry.Provider, a kernel-internal type whose
StartModelCall/StartSession/etc. surface doesn't belong in a plugin
author's hands — fixing that mismatch is a separate, focused design
task. pkg/kernel.Client.NewSpanExporter()/NewSlogHandler() already
give a plugin author everything needed to wire this up in a few lines
today.
Add hand-written builders over the generated
pluggableharness.content.v1.ContentBlock oneof: Text, Image, Document
(with a WithFilename option), ToolUse, ToolResult/ToolErrorResult, and
Thinking/RedactedThinking — every variant the generated oneof defines.

Per docs/specifications/model/data-types.md's canonical message and
content-block schema, and docs/specifications/frontend/frontend-protocol.md's
UserMessage.content note (field 1's reserved bare-text field), messages
carry repeated content.v1.ContentBlock, never a plain string; this
package removes the boilerplate of constructing the nested oneof by
hand at every call site.
Add the hand-written ergonomic layer over the generated
pluggableharness.render.v1 types (pkg/render/proto/v1), per
docs/specifications/frontend/render-tree.md: one builder function
per RenderNode variant (text, code, diff, table, link, list, group,
collapsible, sub_session, action), a Tree wrapper for RenderTree,
and a VersionRegistry that dispatches a Render RPC's schema_version
to the VersionedRenderer registered for it, per the spec's Schema
versioning section requiring a plugin to branch on schema_version
rather than sniff payload shape.

Builders return the generated *renderv1.RenderNode/RenderTree types
directly rather than a second parallel Go representation, per
.claude/rules/go-layout.md's one-representation-per-wire-message
rule. List/OrderedList and Collapsible/CollapsedByDefault are split
into paired functions instead of a boolean parameter to avoid the
boolean-trap pattern; Diff takes pre-built DiffHunk values (via the
new Hunk/DiffContextLine/DiffAddLine/DiffRemoveLine helpers) since
DiffNode has no before/after string fields to derive hunks from.
Adds Object, String, Number, Integer, Boolean, Array, and Enum
builders over the generated pluggableharness.schema.v1.Schema, plus
functional Option (WithDescription/WithEnum/WithRequired), so a tool
or slashcommand provider author constructs input_schema/output_schema
values without hand-assembling proto structs.

The supported keyword set (type, properties, required, enum, items,
description) matches docs/specifications/model/data-types.md's
tool-schema section exactly; keywords the subset omits (oneOf/anyOf/
allOf, $ref, pattern, format, non-trivial additionalProperties) have
no builder because the generated Schema message has no field for
them. Object validates required names against properties and rejects
nil property values; Array rejects a nil items schema — both per the
same spec section's MUST-level field semantics.
Add a hand-written builder layer over the generated
pkg/config/proto/v1 types so a plugin author cannot easily
construct a ConfigSchema that violates the schema-to-cty bridge's
invariants (docs/specifications/configuration/blocks-reference.md#the-schema-to-cty-bridge):

- object_attributes must be set iff type is ATTR_TYPE_OBJECT
- default_json must not be set on a sensitive attribute
- default_json's JSON shape must match the attribute's declared type,
  recursively for nested object defaults

Attribute(name, typ, opts...) validates a single attribute
immediately via functional options (WithRequired, WithSensitive,
WithDescription, WithDefault, WithObjectAttributes). Schema(attrs...)
re-validates the whole tree — including hand-built struct literals
that bypass Attribute — before assembling a ConfigSchema, so an
invalid attribute cannot reach the wire regardless of how it was
built. Sentinel errors (ErrUnspecifiedType,
ErrObjectAttributesMismatch, ErrSensitiveDefault,
ErrDefaultTypeMismatch) let callers use errors.Is.
… wrappers

Wraps the 5 KernelCallbackService RPCs previously reachable only via
Client.Raw(): CountTokens/RunSession/GetSession as thin request/result
pass-throughs (tokens.go, session.go), Emit with an explicit required
sessionID parameter per kernel-callbacks.md's mandatory-session_id rule
(emit.go), and ReadEvents as a server-streaming wrapper mirroring
Subscribe's handler+Subscription shape in eventbus.go (events.go).

helpers_test.go's fakeServer gains func fields for all five RPCs,
following its existing pattern. New unit tests use the same bufconn
round-trip harness as the rest of the package; coverage for pkg/kernel
is 89.1%.
Bootstrap previously returned *internal/telemetry.Provider, an
internal/ concrete type an out-of-tree plugin author cannot name at
all (internal/ packages are importable only from within this module) —
defeating pkg/ as the third-party-consumable surface pkg/kernel/doc.go
documents.

Adds a package-local Provider interface (Shutdown, ForceFlush, Tracer)
covering what a plugin author actually calls; Bootstrap now returns
that instead. *internal/telemetry.Provider satisfies it structurally
with no change to internal/telemetry. Instruments() and Config() stay
off the interface — both return internal/-only types, and re-exporting
either is a larger metrics-API redesign than this fix's scope; a known,
documented gap rather than an oversight.

No caller under internal/ invokes pkg/telemetry.Bootstrap today
(verified via grep), so nothing else needed updating. Adds a test
confirming the returned value satisfies Provider and that
Shutdown/ForceFlush/Tracer are callable through it.
Add pkg/plugin, the serving layer every category SDK (pkg/model,
pkg/tool, pkg/context, pkg/memory, pkg/frontend, pkg/widget,
pkg/slashcommand) builds on to run a plugin subprocess. It wraps
hashicorp/go-plugin's own plugin.Serve with:

- Identity + ProducerRef: a plugin author's self-reported build
  identity, turned into the common.v1.ProducerRef every category's
  Describe RPC returns (docs/specifications/configuration/lock-file.md's
  dev_overrides note).
- Service + Config.Services: a plugin process muxes its own category
  service plus, optionally, HookSubscriberService and/or
  SlashCommandService on one subprocess connection, per
  docs/specifications/agent-loop/hook-dispatch.md,
  docs/specifications/tool/protocol.md#getschema,
  docs/specifications/frontend/widget-protocol.md#transport, and
  docs/specifications/slashcommand/data-types.md, all four of which
  independently spec-mandate this via hashicorp/go-plugin's native
  multi-service muxing.
- Callback: a lazily-dialed handle to the kernel callback channel,
  built via pkg/kernel.Dial. The dial is deferred to first use (guarded
  by sync.Once) because the kernel does not start serving the callback
  broker until it dispenses this plugin's client, which happens only
  after GRPCServer has already returned — dialing eagerly inside
  GRPCServer would race that sequencing. Serve's internal GRPCPlugin
  adapter records the broker on Callback once GRPCServer runs; the
  actual dial happens the first time a plugin author's own RPC handler
  calls Callback.Client.
- StatusError: a shared *status.Status + google.rpc.ErrorInfo builder
  for the "most specific code, category enum in structured detail"
  error shape .claude/rules/grpc.md mandates for every RPC error
  crossing the plugin boundary.

Structurally mirrors internal/pluginruntime/adapter.go (the kernel-side
half of this same handshake) and pkg/kernel (the existing callback
client wrapper this package's Callback builds on).

Deviations from the originally sketched API: Callback.Client's context
parameter is unnamed rather than named ctx — kernel.Dial has no
context-aware variant, and golangci-lint's revive unused-parameter
check flags a named-but-unused parameter, so it stays unnamed with a
doc comment explaining why, matching this repo's existing
GRPCClient(context.Context, ...) precedent in
internal/pluginruntime/testdata/plugin/main.go.

Test coverage: 93.3% of statements. Two test files
(callback_internal_test.go, serve_internal_test.go) are white-box
(package plugin) rather than black-box, needed to exercise the
unexported dial seam on Callback and the unexported grpcPlugin/
pluginSet/serveConfig pieces of Serve — both for the same reason
pkg/kernel/client.go's Dial has no direct unit test: *plugin.GRPCBroker
has no exported constructor.

go mod tidy promotes google.golang.org/genproto/googleapis/rpc from
indirect to direct now that status.go imports its errdetails package.
Hand-written SDK on top of pkg/tool/proto/v1, per
docs/specifications/tool/{README,protocol,data-types,reference-catalog,conformance}.md.

- tool.go: domain types, including the six pkg/slashcommand reuses
  verbatim (ToolKind, RiskClass, ConcurrencySpec, ToolResult, ToolError,
  OutputStream), plus ToolSchema/ToolCall/ToolEvent and the Provider,
  Renderer, Previewer, ConfigSchemaProvider, SlashCommandProvider, and
  HookPointProvider interfaces.
- errors.go: ToolError (implements error), ToolErrorCategory, GRPCCode
  mapping, and ToStatusError. process_crashed is unconstructable by
  design: NewToolError validates and rejects it regardless of how the
  category value was obtained.
- convert.go: domain<->proto conversions, plus ToolSchema invariant
  validation (kind/risk pairing, concurrency required except
  interactive).
- stream.go: cancellation-safe Stream.Send enforcing the Invoke
  contract (single terminal event, exit_status at most once, ordering
  serialized under one mutex, no synthesized success after cancel).
- capabilities.go: GetSchema response builder wiring the optional
  capability interfaces.
- server.go: Service adapter implementing toolv1.ToolServiceServer,
  including context-borne kernel-callback access for Provider methods.

Tests are table-driven stdlib testing, black-box by default with two
white-box files (convert_test.go, stream_test.go) exercising
unexported conversion/contract internals; server_test.go and
helpers_test.go do a real bufconn gRPC round trip. Coverage 95.6%.
Implements the plugin-author-facing Go SDK for the context provider
category (docs/specifications/context/README.md, protocol.md,
data-types.md, conformance.md) on top of the merged
feat/pkg-plugin-sdk foundation packages (pkg/plugin, pkg/kernel,
pkg/config, pkg/render).

- context.go: domain types (ContextCapabilities, ContextSection,
  ContextRequest, ContextContribution), the Provider/Renderer
  interfaces, CheckOwnSectionOnly, and the CountTokens helpers that
  route token counting through the kernel's CountTokens callback
  primitive rather than a provider-local heuristic.
- convert.go: domain <-> proto conversions, including the text-only
  content-block <-> string translation data-types.md#contextsection
  mandates.
- server.go: Service adapts a Provider into a real
  ContextServiceServer, wiring ContextRequest.CountTokens from the
  kernel callback client and running log-only defensive checks for
  the own-section-only and budget-fits-allocation invariants.
- errors.go: ContextError and the category -> codes.Code mapping from
  conformance.md's error taxonomy, via plugin.StatusError.
- capabilities.go: functional-options GetCapabilities builder.
- doc.go: package documentation, including the deliberate "context"
  package-name collision with the stdlib package and why the domain
  types mirror the wire message names despite the stutter.
Implements the plugin-author-facing SDK over pkg/model/proto/v1, per
docs/specifications/model/{README,protocol,data-types,conformance}.md:

- model.go: Provider (GetCapabilities/Configure/StreamCompletion, all
  MUST) plus optional TokenCounter/Renderer interfaces detected via
  type assertion, and Go-idiomatic domain types (Capabilities, Spec,
  ThinkingSpec, CachingSpec, Pricing, PricingTier, Usage).
- convert.go: domain <-> proto conversions, both directions.
- capabilities.go: NewCapabilities validates MUST-level invariants —
  ThinkingSpec.default required when mode != none, Pricing present
  with cache rates iff caching supported, and pairwise pricing-tier
  overlap detection across both the time and input-size dimensions.
- stream.go: Sink, the StreamCompletion event writer enforcing
  "exactly one terminal event" and treating cancellation as normal
  control flow, never an error.
- server.go: Service adapts Provider to modelv1.ModelServiceServer;
  Describe is implemented directly from plugin.Identity.
- errors.go: Error is the domain shape of the error taxonomy,
  mapping every category to its codes.Code and routing through
  plugin.StatusError; cancellation always short-circuits to a bare
  codes.Canceled status.

StreamCompletionRequest and its nested types are passed through as
the generated proto type rather than mirrored into a domain shape —
already the canonical wire/domain form data-types.md describes, so a
parallel copy would be purely duplicative.

98.6% statement coverage; gRPC-facing tests round-trip over bufconn
per pkg/kernel's harness pattern, including explicit coverage of the
streaming-cancellation path and capability-gated content rejection.
Implements the hand-written plugin-author SDK for the memory provider
category per docs/specifications/memory/{README,protocol,data-types,
taxonomy,conformance}.md: twelve unary RPCs (nine MUST, ApproveRecord/
RejectRecord and Render optional) adapted from a Provider interface
onto the generated memoryv1.MemoryServiceServer.

- memory.go: domain types (Type, Scope, RecordStatus, Record,
  Provenance, Capabilities, per-RPC request/result shapes) and the
  Provider/RatificationProvider/Renderer interfaces, plus CountTokens
  routing MemoryRecord.tokens through the kernel callback.
- convert.go: domain<->proto conversions, both directions, including
  the text-only-in-v1 content collapse/expand and a clamped int64->
  int32 token count conversion.
- capabilities.go: Capabilities -> wire MemoryCapabilities.
- server.go: Service adapts a Provider into MemoryServiceServer.
  Ratification support is derived once via a RatificationProvider type
  assertion rather than trusted from a provider's own Capabilities
  self-report, structurally enforcing the both-or-neither rule for
  ApproveRecord/RejectRecord. Empty-id short-circuits to not_found for
  UpdateRecord/DeleteRecord/GetRecord/ApproveRecord/RejectRecord, and
  a RecordStatusPending result from a non-ratifying provider is
  rejected defensively.
- errors.go: Error/ErrorCategory with constructors (NotFound,
  InvalidType, InvalidScope, RatificationUnsupported, BudgetExceeded,
  SourceUnavailable, Unknown) mapped to the conformance.md gRPC code
  table via plugin.StatusError.

91.3% statement coverage; go build/vet/gofmt/golangci-lint/gosec all
clean.
Implements the plugin-author-facing SDK for the widget provider
category per docs/specifications/frontend/widget-protocol.md: Provider
interface (GetCapabilities/Configure/Attach), Service adapter over
widgetv1.WidgetServiceServer, cancellation-safe UpdateSender for the
server-streaming-only Attach, and the WidgetError-mirroring Error type
mapped to gRPC status per widget-protocol.md#error-taxonomy.

Domain types (Capabilities, Update, Error) are named to avoid the
package-stutter revive would otherwise flag against the generated
WidgetCapabilities/WidgetUpdate/WidgetErrorCategory names they wrap.

96.8% statement coverage; go build/vet/gofmt/golangci-lint/gosec all
clean.
Implements the pluggableharness.hook.v1.HookSubscriberService wire
contract per docs/specifications/agent-loop/hook-dispatch.md: a
shared cross-category service, not tied to one of the seven plugin
categories, that any category plugin may additionally implement
alongside its primary service.

- doc.go: package doc citing hook-dispatch.md and the shared-service
  model.
- hook.go: Mode/Decision aliases of the generated enums, the Payload
  wrapper type, and the split Observer/Transformer/Vetoer interfaces
  (detected via type assertion in NewService, not one monolithic
  Subscriber) since a real subscription is declared per (point, mode)
  pair and most plugins only implement one or two.
- convert.go: HookPayload oneof to Payload conversions, and the
  transform-mutable-field comparison (only pre-model-call's messages
  is mutable in v1; every other point requires byte-identical).
- server.go: Service adapter implementing HookSubscriberServiceServer
  over the wrapped subscriber, validating each mode's response shape
  before it reaches the wire.
- errors.go: HOOK_ERROR_CATEGORY-to-codes.Code mapping via
  plugin.StatusError.

A veto response of HOOK_DECISION_UNSPECIFIED is rejected as a gRPC
error (InvalidArgument), never as an in-band VetoResult{DENY}: the
generated VetoResult doc comment places the fail-closed conversion
at the gRPC-status level, which is the kernel dispatch loop's job,
not this SDK's.
scrothers added 13 commits July 24, 2026 19:31
Implements the plugin-author-facing SDK over the generated
pluggableharness.frontend.v1 types, per
docs/specifications/frontend/frontend-protocol.md,
docs/specifications/frontend/render-tree.md, and
docs/specifications/frontend/conformance.md.

- frontend.go: Provider/Emitter interfaces and ClientEvent/ServerEvent
  domain types, including a PlanScope type whose Go zero value matches
  the spec-mandated PLAN_DECISION_SCOPE_ONCE default.
- convert.go: domain <-> proto conversions both directions, validating
  the session_id placement invariant on ClientEvent.
- server.go: Service adapter implementing FrontendServiceServer,
  satisfying pkg/plugin.Service.
- attach.go: the one per-connection Attach dispatch loop, demuxed by
  session_id, with two distinct error paths (in-band ServerEvent.error
  vs. a deliberate Fatal wrapper that closes the stream).
- capabilities.go: GetCapabilities response builder over pkg/config's
  ConfigSchema.
- errors.go: FrontendError with the ten-category to grpc/codes.Code
  mapping.
- fallback.go: FallbackText, a graceful plain-text renderer for any
  RenderNode, including a variant added after this package shipped.

93.3% statement coverage on the hand-written SDK (generated proto/v1
excluded from the floor per plugin-runtime.md).
Rename ToolKind/ToolResult/ToolSchema/ToolCall/ToolEvent/ToolError/
ToolErrorCategory and their enum constants and helper functions to
Kind/Result/Schema/Call/Event/Error/ErrorCategory. golangci-lint's
revive linter flags tool.ToolXxx as a package-name stutter
(.claude/rules/go-style.md), which pkg/model, pkg/memory, pkg/widget,
and pkg/hook already avoided from the start. Generated toolv1.ToolXxx
references are untouched — only the hand-written domain types moved.

pkg/slashcommand (docs/specifications/slashcommand/data-types.md)
reuses these types verbatim, so this fix lands before that package is
built rather than after.
Rename ContextCapabilities/ContextSection/ContextRequest/
ContextContribution/ContextError to Capabilities/Section/Request/
Contribution/Error, matching the pattern pkg/model/pkg/memory/
pkg/widget/pkg/hook already applied. Also fix five ErrorCategory
constant doc comments to the required 'Identifier ...' form.
Generated contextv1.ContextXxx and contentv1.ContextSection
references are untouched.
Rename the hand-written FrontendError to Error (generated
frontendv1.FrontendError is untouched). Fix FatalErr's doc-comment
form, simplify interruptEvent's unused test parameter, and remove
the dead capabilitiesFromProto function golangci-lint flagged as
unused.
Adds the hand-written Go SDK for the slashcommand plugin category
per docs/specifications/slashcommand/{README,protocol,data-types,
conformance}.md: Spec/Call/Event domain types, the Provider/Renderer/
Previewer/ConfigSchemaProvider/HookPointProvider interfaces, proto
conversions, a cancellation-safe Invoke Stream mirroring pkg/tool's
discipline, and a Service adapter implementing GetCapabilities,
Configure, Invoke, Render, Preview, and Describe.

Per data-types.md#reused-toolv1-types, Kind, RiskClass,
ConcurrencySpec, Result, Error, and OutputStream are pkg/tool's types
reused directly with no parallel redeclaration; the generated
slashcommandv1 messages reference toolv1 types on the wire for the
same fields. SlashCommandSpec carries no output_schema, matching the
spec's explicit case that a direct-invoke command is never
model-callable.
pkg/<category>'s hand-written SDK layer uses rich domain types
(tool.Call, model.Spec, ...) converted to/from the generated wire
message at the package boundary, rather than passing the generated
type through directly — the design this session's pkg/** SDK
implementation settled on for plugin-author ergonomics.

The prior text ("there is exactly one Go representation of each wire
message") was written with internal/ in mind but read as a blanket
rule against pkg/ ever doing this. Make the boundary explicit instead
of leaving code and rule text in tension: pkg/<category> MAY define
domain types; internal/ MUST still consume the generated
pkg/<category>/proto/v1 types directly, unchanged.
Rewrite testdata/plugin/main.go to use pkg/plugin.Serve,
pkg/tool.Provider/Service, and pkg/hook.Observer/Service instead of
hand-rolling the hashicorp/go-plugin adapter directly — the fixture
is now the SDK's own end-to-end acceptance test: TestLaunch_
realSubprocess passing proves pkg/plugin genuinely round-trips
through a real subprocess launch, handshake, and callback dial.

The fixture also registers hook.Service alongside tool.Service on
the same plugin.Config.Services slice, proving pkg/plugin's
multi-service muxing doesn't break a real launch. It does not itself
call DispatchHook — internal/pluginruntime.Plugin deliberately
dispenses only the primary category client, not the raw
*grpc.ClientConn a second service client would need.

Update CLAUDE.md and README.md's now-stale notes describing this
fixture as hand-rolled and the SDK as a future concern.
Copilot AI review requested due to automatic review settings July 25, 2026 00:00

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 files (300). Try reducing the number of changed files 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

None

@scrothers
scrothers merged commit f8808b9 into main Jul 25, 2026
14 of 15 checks passed
@scrothers
scrothers deleted the feat/pkg-plugin-sdk branch July 26, 2026 04:52
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.

2 participants