All notable changes to Agent Controller are tracked here. Format loosely follows Keep a Changelog; the project pre-dates this changelog so the v0.1.x history is a retrospective summary — see git tags v0.1.0 … v0.1.10 for per-slice detail.
This project adheres to Semantic Versioning for the umbrella release version; individual artifacts (CLI, runtime packages, schemas, wire protocol, runtime images) version independently. See docs/versioning.md for the multi-dimensional policy.
The three tests in runtime/src/e2e/runsession-fake.test.ts had been failing
since the pi 0.82.1 bump (1be203c), each timing out at 5s.
Root cause: pi 0.82.x moved provider dispatch from the api-registry to the
provider catalog. ModelRuntime.prepareRequest() resolves the streaming
provider with this.models.getProvider(model.provider) — it never consults the
model.api -> getApiProvider() registry that registerFauxProvider() writes
to. Instrumenting both layers showed fake-test being registered and then
never looked up, while pi resolved anthropic from the catalog instead.
Because the faux model claims provider: "anthropic" (so the adapter's
ANTHROPIC_* handling stays a no-op), the catalog handed back the real
Anthropic provider. The "hermetic" suite was making live HTTP calls, retrying
four times, and blowing the 5s budget — the timeouts were network waits, not a
deadlock.
Fix: register the faux as a catalog provider too. fauxProvider() returns
the same scripted core wrapped as a Provider; the new
resolveFakeModelRuntimeIfRequested() builds a ModelRuntime (with
modelsPath: null, so it touches no real config) and calls
registerNativeProvider() to shadow anthropic for that instance only. The
adapter passes it as createAgentSession({ modelRuntime }) — the seam pi
actually consults. Production is unchanged: with no fake installed the helper
returns undefined and the option is omitted entirely.
The suite is now genuinely hermetic — 105/105 passing, and the runtime drops from ~16s to ~1.2s because the retry waits are gone.
extensions/subagent/ carried a ~600-line vendored fork of Pi's bundled
subagent example (@earendil-works/pi-coding-agent/examples/extensions/subagent/),
which drifted from upstream on every pi bump. Pi ships examples/ in the
package's npm files, and its extension loader aliases @earendil-works/* and
typebox to its own installs, so the example can be loaded straight out of
node_modules — with its own pi-tui / pi-agent-core imports resolving, which
is why the fork had to strip them in the first place.
extensions/subagent/entrypoint.tsis now a ~150-line shim that dynamically imports upstream's example and overrides only what conflicts with agent-controller's model.extensions/subagent/agents.tsis deleted — upstream's own copy is used.- The ADL allowlist is unchanged and still enforced. Upstream defaults
agentScopeto"user"and lets the model choose it, which reaches~/.pi/agent/agents/*.md— agents no spec declared. The shim forces"project", so onlyspec.subagents[]materialized into<cwd>/.pi/agents/is reachable. It also forcesconfirmProjectAgents: false(the spec is the approval) and replaces upstream's model-visible description, which embedsgetAgentDir()and instructs the model to setagentScope: "both". - Two process-global overrides are applied for the duration of a call:
process.argv[1]→ the resolved pi CLI (upstream'sgetPiInvocationspawnsnode process.argv[1], which under agent-controller is the adapter itself, so every child would hang waiting for a CompiledSpec on stdin), andPI_CODING_AGENT_DIR→ the local agent dir holding theANTHROPIC_BASE_URLgatewaymodels.json(upstream spawns with noenvoverride, so children inherit ours). Scoping them to the call keeps the parent session's owngetAgentDir()—auth.json,settings.json, sessions — pointing at the user's real~/.pi. The overrides are reference-counted because Pi executes a batch of tool calls concurrently by default: naive per-call save/restore let a second overlapping call capture the first call's already-mutatedargv[1]as the "original", and let the first call's restore repointargv[1]at the adapter while the second was still spawning children, hanging them. First call in applies, last call out restores. runtime/src/adapter.tsgainsresolveUpstreamSubagentPath()and setsAC_UPSTREAM_SUBAGENT_PATHbefore the resource loader is built.createRequire().resolve()cannot reach the package (itsexportsmap declares only.and./hooksunder animportcondition), so this usesimport.meta.resolvewith a conventional-layout fallback.- New
runtime/src/subagent-upstream.test.ts— six canary tests that mock nothing and load the real shim through Pi's owndiscoverAndLoadExtensions.examples/is not a stable API surface, so these fail atnpm testrather than at a user's session start if pi moves or reshapes the example. The documented fallback is re-vendoring the fork. copy-vendored-extensions.mjsnow clears the destination before copying;cpSyncmerges, so a file deleted from the source (likeagents.ts) would otherwise linger indist/across incremental builds and get published.
Retires the "Subagent extension swap" item from ROADMAP.md. The catalog was
surveyed first per the catalog-first convention: there is no first-party Pi
subagent package (pi-subagents and @tintinweb/pi-subagents are both
third-party and unbadged), and the catalog entry pi-subagents ships builtin
agents (scout, reviewer, oracle, …) that would need explicit lockdown to
not widen the allowlist. Pi's own bundled example is the first-party option.
Fourth runtime adapter for Agent Controller. Drives sessions through @anthropic-ai/claude-agent-sdk (pinned to 0.3.220), making it the only adapter that needs no external CLI on PATH.
Key facts:
- New
runtime.type: local-claudeselector. Dispatched by the existingresolveRuntimeCommandpath incli/cmd/agentctl/main.go. - New package
@agent-controller/runtime-claude(runtime-claude/). Calls the SDK'squery()in-process; the SDK spawns its own bundled executable. EachSDKMessageis translated to the NDJSON wire-event stream viaevent-translator.ts. - No external CLI required. Unlike opencode (needs the
opencodeCLI) and codex (needs thecodexCLI), this adapter only needsANTHROPIC_API_KEYin the environment.Options.envis never set, soANTHROPIC_API_KEY/ANTHROPIC_BASE_URLpass through from the process env. - Anthropic-only.
model.providermust beanthropic;openaiandgoogleare rejected at compile time bycheckClaudeIncompatibilitiesand again at adapter startup with a field-naming error. Bedrock / Vertex routing is deferred. - Isolation:
settingSources: []is set unconditionally, so the SDK never loads~/.claude/settings.json, project.claude/config, orCLAUDE.mdfiles. A spec's behavior does not depend on the operator's machine. - Tools (
spec.tools[]). Pi built-ins map onto SDK tool names (bash→Bash,read→Read,edit→Edit,write→Write) and are set asOptions.tools, which is the SDK's restriction list — atools: []spec runs with zero built-in tools, matching Pi and opencode semantics. The same names go intoOptions.allowedToolsso a non-interactive run does not stall on a permission prompt. An ADL built-in with no SDK equivalent throws instead of being dropped. - Subagents (
spec.subagents[]). Parsed fromagents/<slug>.mdand registered natively via the SDKagentsoption. Declaring subagents also grants theAgentdelegation tool, without which the registration would be unreachable. Supported here and on Pi/opencode; rejected by codex. - Session resume (
--resume,agentctl chat,agentctl serve). agentctl session ids (s_<hex>) are not SDK session ids —Options.sessionIdrequires a UUID. The adapter derives a UUIDv5 from the agentctl id over a fixed namespace (implemented locally withnode:crypto; no new dependency), sets it viaOptions.sessionIdon the first turn, and passes the SDK session id captured from thesystem/initmessage toOptions.resumeon later turns. The captured id is persisted at$XDG_DATA_HOME/agent-controller/claude-sessions/<derived-uuid>/sdk-session-idbecause the adapter is a fresh process per turn. - MCP servers (
spec.mcpServers[]). All three ADL transports map onto the SDK's config union: stdio →{type:"stdio"}, streamable-http →{type:"http"}, sse →{type:"sse"}. Each declared server is auto-approved with anmcp__<server>allow rule; servers not in the spec are never granted.lifecycle: eager | lazyis accepted by the schema and ignored — the SDK has no eager/lazy control. - Skills (
spec.skills[]). SKILL.md bodies are read at startup, YAML frontmatter stripped, and inlined into the system prompt viawrapSkillBody(). Same "active by default" semantic as Pi, opencode, and codex. Native plugin-backed SDK skills are deferred. - Guardrails (
spec.guardrails).blockandwarnbehave identically to the other adapters.correctdegrades towarn— a corrective re-prompt would need a second turn, which is out of scope for this single-shot adapter (same treatment codex gives it). - No
model.request/model.responseevents. The SDK does not surface per-model-request events and synthesizing them would lose fidelity — the same gap opencode and codex have. Pi remains the only adapter emitting them. model.temperatureis ignored. The SDK has no temperature option; the field is accepted by the ADL schema and the adapter writes a note to stderr rather than failing.- Rejected at adapter startup:
spec.extensions[],spec.installs[], custom Pi-extension tools (entrypoint set,builtinunset), non-anthropicproviders. - New example:
examples/hello-claude.yaml— minimalruntime.type: local-claude,model.provider: anthropic, persona + task,tools: []. schemas/runtimebinding.v1alpha1.jsonselector.runtimeTypegainslocal-claude(andlocal-codex, which had been missing).- Harness matrix updated (
docs/architecture/harness-matrix.md) with a full claude column across all feature tables.
Prerequisites: ANTHROPIC_API_KEY environment variable set. No separate CLI install.
Third runtime adapter for Agent Controller. Drives sessions via the OpenAI codex CLI (codex exec), making it the only adapter in the project with a native sandbox.
Key facts:
- New
runtime.type: local-codexselector. Dispatched by the existingresolveRuntimeCommandpath incli/cmd/agentctl/main.go. - New package
@agent-controller/runtime-codex(runtime-codex/). Spawnscodex exec -C <cwd> -m <model> -s workspace-writewith an isolatedCODEX_HOME. Reads JSONL stdout line-by-line; translates to the NDJSON wire-event stream viaevent-translator.ts. - OpenAI-only.
model.providermust beopenai;anthropicandgoogleare rejected at adapter startup with a field-naming error. RequiresOPENAI_API_KEYin the environment; the adapter seeds it into the freshCODEX_HOMEviacodex login --with-api-key. - Native sandbox (
-s workspace-write).codex execis always spawned with theworkspace-writesandbox flag, restricting file-system writes to the current working directory. This is the only adapter in the project with a native sandbox; Pi and opencode have no equivalent enforcement layer. - Session resume (
--resume).resolveCodexHomekeys a stableCODEX_HOMEoff the agentctl session id. Thethread_idcaptured during a run is persisted to$CODEX_HOME/.agentctl-thread-id; the nextcodex execinvocation reads it via--thread-idto resume the conversation. Ephemeral homes (no--resume) are wiped after each run. - MCP servers (
spec.mcpServers[]). stdio and streamable-http transports are written as[mcp_servers.<name>]blocks in$CODEX_HOME/config.toml. SSE transport is rejected at adapter startup (not in the codex TOML schema). - Skills (
spec.skills[]). SKILL.md bodies are read at startup, YAML frontmatter stripped, and inlined into the--instructionsprompt viawrapSkillBody(). Same "active by default" semantic as Pi and opencode. - Guardrails (
spec.guardrails).blockandwarnbehave identically to the other adapters.correctdegrades towarnin v1 — a corrective re-prompt would require a newcodex exec resumeinvocation which is out of scope for this single-shot adapter. - Output schema (
spec.outputSchema). Supported via the shared CLI--output-file+spec.outputSchemapath (same as Pi and opencode). - Rejected at adapter startup:
spec.extensions[],spec.installs[],spec.subagents[], custom Pi-extension tools (entrypoint set,builtinunset), non-openaiproviders. - New example:
examples/hello-codex.yaml— minimalruntime.type: local-codex,model.provider: openai, persona + task,tools: []. - Harness matrix updated (
docs/architecture/harness-matrix.md) with a full codex column across all feature tables.
Prerequisites: codex CLI on PATH (install via npm install -g @openai/codex or the official release); OPENAI_API_KEY environment variable set.
agentctl serve — expose ADL agents over HTTP/SSE. v0.8 turns the Agent Controller into a long-lived network service. Same Agent definitions, same SessionStore from v0.6, now accessible via HTTP so external clients (schedulers, UIs, integration tests) can open sessions and stream turns without spawning a new process per request.
The headline surface, end to end:
agentctl serve <spec.yaml>— starts a single-process HTTP/SSE server on--port(default 8080). Accepts--in-memoryfor tests,--max-concurrent-turns,--max-sessions,--session-ttl, and--shutdown-gracefor production tuning.- Session REST API —
POST /v1/sessions(create; optional{"inputs":{...}}body),GET /v1/sessions(list),GET /v1/sessions/{id}(get),DELETE /v1/sessions/{id}(delete). - Turn streaming —
POST /v1/sessions/{id}/turnswith{"input":"..."}returns atext/event-streamSSE response; each frame isevent: <wire-type>\ndata: <json>\n\n; stream ends onsession.ended. - Error codes — 409 (session busy), 429 (turn cap or session cap), 503 (draining).
- Health probes —
GET /healthz(liveness, always 200),GET /readyz(readiness, 503 during drain). - Graceful shutdown — SIGTERM triggers drain; in-flight turns complete up to
--shutdown-grace;/readyzreturns 503 immediately so load-balancers stop routing new traffic.
Coordinated artifact bumps:
agentctlCLI → v0.8.0 (version embedded from the release tag at build time).@agent-controller/runtimeand@agent-controller/runtime-opencodeunchanged this cycle (no adapter changes required for the serve surface).- ADL schema unchanged (
agent-controller.dev/v1alpha1).
Deferred to v0.8.x: metrics endpoint (OTel meter registry), auth middleware (OIDC / API-key hooks). TLS is expected to be terminated at a proxy.
agentctl serve <spec.yaml>command (cli/cmd/agentctl/serve.go) — HTTP/SSE server wrapping any ADL agent spec.- Session manager (
cli/internal/serve/) —Managertype owns the session lifecycle, turn dispatch, concurrency limiter, and graceful-drain logic. - SSE writer (
cli/internal/serve/sse.go) —WriteSSEformats wire events as SSE frames. - REST handlers (
cli/internal/serve/handlers.go) — multiplexer over the session REST API and health probes. examples/serve-client.sh— copy-paste curl example: create session → POST turn → stream SSE (no jq required).e2e/serve.sh— hermetic E2E test (no API key): builds CLI, starts server with--in-memory, asserts/healthz, session create, and session get; live tier (gated onAGENT_CONTROLLER_RUN_LIVE=1) also POSTs a turn and asserts the SSE stream.
Make agentctl run a first-class scheduler task (Option-B pivot). v0.7 ships NO new workflow YAML and NO in-process orchestrator; instead it turns agentctl run into a clean, composable per-step task primitive that external schedulers (Maestro / Airflow / Temporal) drive, and gives multi-step agent DAGs the three things they need: parameterized input, captured output, and durable shared memory.
The headline surface, end to end:
- Parameterize a run —
--input k=v+${inputs.foo}interpolation (7.1), with--input KEY=@<path>file values and--input-file <json>(7.4). - Capture a result —
--output-file <path>+ optionalspec.outputSchemaJSON validation (7.2);--skip-if-output-existsfor scheduler idempotency (7.4). - Remember across steps —
--workspace <dir>injects a harness-agnostic MCP memory server (workspace_remember/recall/note_append/list_outputs), working on both the Pi and opencode adapters (7.5). - Copy-paste scheduler examples for Maestro (OSS
kubernetesstep), Airflow, and Temporal (7.3). - Exit-code contract and shell/expansion-injection safety documented throughout.
Coordinated artifact bumps:
agentctlCLI → v0.7.0 (version embedded from the release tag at build time).@agent-controller/runtime0.6.0 → 0.7.0 (Pi adapter: atomic.pi/mcp.jsonwrite for the workspace MCP server).@agent-controller/runtime-opencode0.6.0 → 0.7.0 (no code change this cycle; bumped to satisfy the release's lockstep version-validation).- ADL schema unchanged (
agent-controller.dev/v1alpha1) — all v0.7 fields (outputSchema) were additive. - Runtime image not bumped (released on its own
runtime-image/v*tag; the K8soutputSchemaround-trip + a v0.7-bundled image remain a tracked follow-up — v0.7's surface is host-side CLI, and the K8s backend is opportunistic per the roadmap).
Known follow-ups (Pi adapter, to make --workspace first-class on Pi; opencode already handles all cases): preserve declared built-in tools alongside MCP tools, and per-run MCP config isolation so one cwd can serve many workspaces.
Fifth slice of v0.7 (Option-B pivot). Completes the DAG story: 7.2/7.4 pass a step's output to the next step as input; 7.5 adds durable, structured memory an agent can read and write across runs — "save memory somewhere and have the next agent reuse it".
agentctl run --workspace <dir>— points the run at a shared workspace directory and auto-injects an MCP server exposing four memory tools to the agent:workspace_remember(key, value)/workspace_recall(key?)— a SQLite key/value store (recall with no key lists everything).workspace_note_append(note)— appends a timestamped line tonotes.mdin the workspace dir (a file, so it's also a normal handoff artifact: it shows inlist_outputsand a downstream step can read it with--input note=@<dir>/notes.md).workspace_list_outputs()— lists the regular files in the workspace dir (the--output-fileartifacts from prior steps +notes.md); the internal SQLite files are excluded.- Runs that point
--workspaceat the same dir share the same memory.
- Harness-agnostic by design: the memory tools are exposed over MCP, not as harness-specific native tools.
agentctlitself is the MCP server —--workspaceinjects anmcpServersentry that runsagentctl __workspace-mcp --workspace <absdir>over stdio (command = the absolute path to the running executable, so it resolves regardless of the adapter subprocess's PATH). Both the Pi adapter and the opencode adapter consumespec.mcpServers, so the same workspace works on both with zero adapter-specific code. (Per the pi.dev/packages check: existing Pi memory extensions —pi-hermes-memory,gentle-engram— are Pi-only and would break harness-agnosticism, so a native MCP server keyed to a--workspacedir was the right fit.) - SQLite store (
cli/internal/workspace) mirrors the slice-6.2 session-store patterns:modernc.org/sqlite(pure Go, no CGO → still cross-compiles), WAL +busy_timeoutfor safe concurrent DAG steps, forced WAL/SHM creation +0600perms because remembered values can be secrets, and rejection offile:/?-DSN paths that would mis-target the chmod hardening. - Kubernetes target rejected:
--workspaceerrors with a clear message when the binding targetskubernetes— the workspace SQLite store is host-local and a Pod can't reach it. Cross-step state under K8s uses a shared volume + the file-handoff flags instead. - New dependency:
github.com/modelcontextprotocol/go-sdk(the official MCP Go SDK) for the stdio server. Chosen over hand-rolling the JSON-RPC/MCP protocol so the server is guaranteed spec-compliant against both adapters' MCP clients; chosen overmark3labs/mcp-gofor being the official, org-maintained implementation. --workspaceon Pi: per-cwd config constraint (codex passes 2-3 of slice 7.5): the Pi adapter keys its MCP config off a single<cwd>/.pi/mcp.json.--workspaceinjects a memory server whose args carry the workspace path, so reusing the same workspace from a cwd is idempotent (identical content → no-op), but a different workspace from the same cwd would conflict on that one file. We keep the adapter's existing safe behavior — it fails loudly (Cannot write MCP config: … already exists with different contents) rather than silently overwriting, because overwriting a shared per-cwd file would let two overlapping runs race and cross-load each other's MCP servers (cross-contaminating workspaces). Guidance: run each scheduler step from its own working directory (containers/task dirs already do this), or useruntime.type: local-opencode, whose per-run XDG config dir has no such constraint and handles--workspacecleanly in all cases. Per-run config isolation on Pi (so one cwd can serve many workspaces) is a tracked follow-up; it relocates all adapter cwd-relative state and warrants its own slice.--workspacevalue validated before absolutizing (codex pass 3 P3): a--workspace file:/…value would otherwise havefilepath.Absprefix the cwd beforeworkspace.Open'sfile:/?guard ran, creating a nested<cwd>/file:/…dir instead of failing. The raw flag value is now rejected up front.list_outputshides in-flight output temp files (codex pass 4 P2): when--output-filetargets a path inside the workspace, slice 7.2's atomic writer leaves a.agentctl-output-*.tmpfile until the final rename.list_outputsnow treats that prefix as internal (alongside the SQLite DB files) so a concurrent run or a crash can't surface a partial/temp file as a durable step artifact.- Atomic Pi MCP config creation (codex pass 5 P2):
writeMcpJsonnow writes.pi/mcp.jsonwith the exclusive-create flag (wx) and reconciles onEEXIST, instead of a check-then-write. This closes the first-creation TOCTOU where two concurrent runs from the same cwd (neither finding an existing file) could both write and clobber each other — so the "fails loudly, never silently cross-loads" guarantee now holds even when the file doesn't exist yet, not only when it already does. - Reject explicit empty
--workspace ""(codex pass 5 P2): a wrapper expanding an unset path (--workspace "$WS"withWSunset) marks the flag provided but empty; the old!= ""guard treated it as omitted and ran with no durable memory.runnow usescmd.Flags().Changed("workspace")and errors on an empty value (same fix as slice 7.4's--input-file). note_appendre-asserts0600on an existing journal (codex pass 6 P2):OpenFile's mode only applies when creating the file, so a pre-existingnotes.mdwith looser perms kept them.AppendNotenowchmods the journal to0600before appending — notes can be sensitive, matching the DB/output hardening.- Honest message on concurrent Pi config creation (codex pass 6 P2): the exclusive-create loser reads
.pi/mcp.jsonto reconcile, but the winner may be mid-write (the window betweenO_CREATandwrite), so an empty read would otherwise misreport "different contents". An empty existing file is now surfaced as a concurrent-same-cwd error pointing at the per-run-dir / opencode guidance. (Full concurrent-same-cwd correctness on Pi remains the per-run config-isolation follow-up.) - Known limitation — Pi built-in tools (codex pass 1 of slice 7.5): the Pi adapter switches to
noTools: "builtin"whenever ANY MCP server is present (it can't keep declared built-ins AND allow unknown MCP tool names at once — Pi'snoToolsis"all" | "builtin", not a per-tool list). So adding--workspaceto a Pi spec that declares built-in tools (bash/read/edit/write) would suppress them.agentctl runnow prints a clear warning naming the affected tools when--workspaceis combined with a Pi runtime + declared built-ins, and points atruntime.type: local-opencode(whose per-tool permission model is unaffected — built-ins and the workspace tools coexist) or the file-handoff flags. The proper fix (preserve declared built-ins alongside MCP tools on Pi) is an adapter change tracked as a follow-up; it affects everymcpServers+ built-ins spec, not just--workspace. - Tests: 11 store cases (remember/recall round-trip, missing key, upsert, empty-value-vs-not-found, empty-key rejection, sorted recall-all, note journal, persist-across-reopen, list-outputs excludes the DB + sorts, idempotent close, path rejection, 0600 perms — the perms case skipped on Windows like the session store) + 11 command/server cases (in-memory client↔server: lists 4 tools, remember→recall, recall-all, missing-key-not-an-error, note+list-outputs, empty-note tool error; injection appends/collision-rejects/preserves-existing; the declared-builtins detector;
--workspace+ kubernetes target rejected end-to-end). Full module suite green; cross-compiles forGOOS=windows.
Fourth slice of v0.7 under the Option-B pivot: makes agentctl run compose in a multi-step DAG where one step's output becomes the next step's input, and lets a scheduler safely retry a partially-failed DAG.
agentctl run --input KEY=@<path>— the value form@<path>reads the input value from a file (capped at 1 MiB) instead of the literal text. This is the file-handoff path: a previous step writes its result with--output-file, the next step consumes it with--input text=@/shared/prev-result.json. The file's exact bytes become the value (no trimming) so an--output-file→--inputround-trip is lossless. There is deliberately no escape for a literal leading@(consistent with slice 7.1's no-escapes-yet stance) — pass such values through--input-file, whose JSON values are always literal.agentctl run --input-file <path>— merges a JSON object of inputs (multi-input handoff: one upstream step emits a{key: value}object the downstream step consumes in one shot). Scalar values only — strings pass through, numbers keep their exact textual form (json.Number, so large ids / decimals survive losslessly), bools become"true"/"false"; arrays, objects, and null are rejected (they can't interpolate into the text ofspec.task). Keys must match the same[A-Za-z_][A-Za-z0-9_]*shape as--input. A key supplied via BOTH--inputand--input-fileis a hard error (cross-channel duplicate), mirroring slice 7.1's repeated-key rule. Trailing content after the JSON object is rejected (same defense slice 7.2 applies to captured output).agentctl run --skip-if-output-exists— if--output-filealready points at an existing regular file, skip the run and exit 0. Because slice 7.2's output write is atomic (tmp+rename) and only happens on a clean run with a non-empty message, a present file means a prior run succeeded — so a scheduler retrying a DAG re-runs only the steps that didn't finish, without re-spending tokens on the ones that did. Requires--output-file; an existing directory at the path is a configuration error. The check runs after cheap/deterministic config validation but before reading input files, interpolating, or initializing tracing/backends, so the skip path stays cheap and side-effect-free.- 1 MiB cap on both file-reading paths (
maxInputFileBytes), enforced via anio.LimitReaderof cap+1 so an oversized file is detected without reading it fully into memory. A handoff larger than this is a smell — pass a reference (path/URI) rather than inlining the payload into the prompt. - Tests: 27 cases —
@<path>read / exact-byte preservation / empty-path / missing-file / over-cap / at-cap-boundary / leading-@-only;--input-filebasic / scalar coercion / large-int precision / cross-channel duplicate / intra-file duplicate / invalid key / non-object / null / nested-non-scalar / trailing-content / over-cap / empty-object no-op; the interpolation-intent gate; an empty---input-file-still-validates flow; an end-to-end file+JSON→interpolation flow; andoutputAlreadyExistsexists/missing/directory/FIFO.
Codex pass 1 (slice 7.4) hardened three edge cases:
--input-fileis interpolation intent (P2): the original gatelen(inputs) > 0meant an explicit--input-file {}(empty object) skipped interpolation entirely, so a${inputs.foo}reference reached the model literally instead of failing for the missing input. The gate now keys on caller intent via ashouldInterpolateInputs(inputFlags, inputFile)helper (a supplied flag, not a non-empty map) — which still keeps the in-Pod K8s child (no flags) off the interpolation path.- Intra-file duplicate keys rejected (P2): decoding straight into a map silently kept the last value for
{"topic":"a","topic":"b"}, reintroducing the last-wins ambiguity the slice rejects everywhere else. A newdecodeInputObjectwalks the object with a JSON token stream and errors on a repeated key (and still rejects trailing content). - Non-regular output paths rejected (P3):
--skip-if-output-existspointed at a FIFO/socket/device passed the old!IsDir()check and skipped the run, though such a path can't be the regular file a prior run wrote.outputAlreadyExistsnow requiresMode().IsRegular()and surfaces a configuration error otherwise.
Codex pass 2 caught two special-file hazards on the same surfaces:
- Input FIFO could hang the task (P2):
--input KEY=@<fifo>/--input-file <fifo>opened the path withos.Open, which blocks indefinitely on a read-side FIFO open (waiting for a writer) — theLimitReadernever gets a chance.readCappedFilenowos.Stats before opening and rejects non-regular files, so a special-file handoff fails fast instead of wedging the scheduler. (A symlink to a regular file is still accepted — only the resolved type matters for reading.) - Symlinked output path falsely skipped (P2):
outputAlreadyExistsusedos.Stat, which follows symlinks, so an--output-filesymlink to any regular file counted as a completed prior run — butwriteOutputFilereplaces the symlink itself, so it is not evidence this step succeeded. Switched toos.Lstatso a symlink at the output path is a configuration error.
Codex pass 3 (P2): the FIFO tests used syscall.Mkfifo, which is undefined on Windows, so the whole test package failed to COMPILE under GOOS=windows before t.Skipf could run (the release builds cross-platform binaries). Moved the FIFO tests into a //go:build unix file; the Windows test binary compiles again and Unix still exercises them.
Codex pass 4 caught two more --input-file edge cases:
- Error message leaked file contents (P2): when
--input-filepointed at a valid JSON scalar (e.g. a string holding a prior step's secret output) instead of an object, the error printed the decoded value in full — up to the 1 MiB cap — and scheduler stderr is commonly logged.decodeInputObjectnow reports the token KIND via adescribeJSONTokenhelper, never the value. - Explicitly-empty
--input-file ""silently ignored (P2): a wrapper expanding an unset path (--input-file "$PARAMS_JSON"with the var unset) passes an empty string; theif inputFile != ""guard then skipped both the file read and the interpolation-intent path, so a${inputs.foo}task reached the model literally. RunE now distinguishes flag presence withcmd.Flags().Changed("input-file")and errors on an empty value.
Codex pass 5 (P2): the remaining content-leak path — a --input-file that starts with a valid object but has trailing bytes (an object followed by a prior step's secret) — still echoed up to 80 bytes of that trailing content in the error. The trailing-content error now reports only the byte count, never the bytes, consistent with the pass-4 non-object fix.
Deferred from the original 7.4 plan: store-aware --resume for run (active-session refusal via the SQLite session store). run --resume already works at the adapter level (it sets spec.SessionID and the Pi adapter continues that session); adding session-store lookup + expired/active/cross-agent guards is a session-lifecycle concern, not file-handoff, and fits more naturally alongside the memory/workspace work in slice 7.5. Keeping 7.4 scoped to the file-handoff I/O primitives keeps it cohesive.
Third slice of v0.7 under the Option-B pivot: working examples showing operators how to call agentctl run as a per-step task primitive from external schedulers, exercising the --input / ${inputs.foo} / --output-file / outputSchema surface from slices 7.1 and 7.2.
examples/schedulers/text-classifier.yaml— shared agent spec used by all three scheduler examples. Single-purpose: classify a snippet of text's sentiment with a confidence score. Demonstrates${inputs.text}interpolation inspec.taskand a typedoutputSchemafor{label, confidence}.examples/schedulers/maestro/workflow.yaml— Maestro workflow that runsagentctlas akubernetesstep (OSS Maestro's containerized step type) using theagent-runtime-baseimage, with a downstream step that reads the typed JSON result. Passes--inputvia anargsSTRING_ARRAY so there is no shell layer between Maestro and agentctl (injection-safe by construction), and keys the shared-volume result path on a workflow-levelparams.nextUniqueId()value referenced from both steps.examples/schedulers/airflow/classify_dag.py— Apache Airflow DAG usingBashOperatorto invokeagentctl runandPythonOperatorto read the result file and push to XCom. Passes BOTH scheduler-provided values — the input text and the{{ dag_run.run_id }}-keyed result path — via the operator'senv=dict (read inside the script as"$TEXT_INPUT"/"$RESULT_PATH") rather than splicing them intobash_command; env vars are opaque to bash's parser, so neither command-substitution nor a stray quote in a custom run id can reach the shell. The DAG run id keys the shared-volume result path so concurrent runs don't clobber each other.examples/schedulers/temporal/classifier_workflow.go— Temporal Workflow (deterministic) + Activity (side-effect) pair. The Activity shells out toagentctl runwith retry policy + timeouts; the Workflow stays deterministic so Temporal can replay it cleanly. Tagged//go:build ignoreso the file documents the pattern without being picked up by any Go toolchain.
All three examples lean on spec.outputSchema — the scheduler-side reader can json.Unmarshal directly without re-validating because agentctl already enforced the shape before writing the file.
Codex passes 1-9 hardened the examples. Each finding caught a different production-failure mode. The recurring lesson: never trust a scheduler-provided value (input text OR run identifiers) as-is — keep it out of any shell AND out of any other expansion layer (bash, Airflow Jinja, kubelet $(VAR)), derive a safe bounded basename when it becomes a filesystem path, and make cross-step file handoffs work under non-root / root-squashed volumes.
- Airflow path rendering (pass 1 P2): module-level Jinja-templated strings are NOT rendered by Airflow's Jinja layer — only operator template fields are. Resolution: pass the templated path via the operator's
templates_dict=parameter, which IS rendered, and read the substituted value from the callable'stemplates_dictkwarg. - Airflow shell-safe input (pass 1 P2): scheduler-provided text reaches bash via the operator's
env=dict and is read inside the script with"$TEXT_INPUT". Env vars are opaque to bash's parser, so values containing$(...)or backticks reach agentctl as literal strings instead of being command-substituted. - Maestro shell-safe input (pass 2 P1, pass 3 P2 — superseded by pass 5): early drafts used a SHELL step and worried about Maestro's
${...}template substitution happening before bash sees the script. Passes 2-3 hardened that SHELL approach (env-var mapping, unbraced reads). Pass 5 then found OSS Maestro has no SHELL step at all, so the whole concern was designed out — see the pass-5 finding below. - Multi-worker file handoff (pass 2 P2): result files don't survive multi-worker schedulers (Airflow Celery/Kubernetes, multi-node Maestro). Resolution: both examples use a documented shared-volume path with prominent comments naming the operator's responsibility to mount that volume (PVC / NFS / EFS) on every worker pool / step pod. Airflow keys the path on
{{ dag_run.run_id }}; Maestro keys it on a workflow-levelparams.nextUniqueId()value. - Airflow worker-env preservation (pass 2 P2): setting
env=onBashOperatorwithoutappend_env=TrueREPLACES the worker environment, droppingPATH/ANTHROPIC_API_KEY/ etc. Resolution:append_env=Trueso the custom env merges on top of the inherited worker env. - Temporal per-attempt output path (pass 3 P2): the original used
WorkflowExecution.RunIDalone to key the result file, which collides across retries and parallelRunAgentctlactivities in the same workflow. Resolution: includeActivityIDandAttemptin the path so each attempt writes its own file. - Output contract must live in the prompt, not just
outputSchema(pass 4 P2): the runtime sends onlyspec.taskto the model —spec.outputSchemais enforced AFTER generation, never shown to the model. The originaltext-classifier.yamltask said "match the outputSchema below", but the model never sees that schema, so it could emit different keys and fail every scheduler example at validation time. Resolution: spell the JSON contract out insidespec.task(exact field names, allowed values, a structural example) so the model is told the shape;outputSchemathen enforces it server-side. The takeaway foroutputSchemausers generally: treat the schema as a validation gate, and still describe the desired shape in the task prompt. - Maestro example targeted a non-existent DSL (pass 5): the original Maestro example used
type: SHELLsteps and a${maestro.instance_uuid}parameter — neither exists in OSS Maestro. Verified against the OSS repo + wiki: Maestro's step types areNoOp/Foreach/Subworkflow/kubernetes; there is no inline-shell step (containerized work runs as akubernetesstep), and${workflow_instance_id}is explicitly NOT globally unique (it restarts from 1 per workflow). Resolution: rewrote the example as a realkubernetesstep that runs theagent-runtime-baseimage (which already bundles agentctl), passing--inputthrough anargsSTRING_ARRAY — this removes the shell layer entirely, so the pass 2-3 SHELL-injection concern is designed out rather than mitigated. The unique result key now comes from a workflow-levelparams.nextUniqueId()SEL expression referenced from both steps (computing it per-step would yield different values and break the write→read handoff). Comments call out the operator-supplied pieces the OSS k8s-step sample doesn't cover (shared volume,ANTHROPIC_API_KEYsecret, spec ConfigMap, and an image bundling agentctl >= v0.7.2). - Maestro image tag conflated version dimensions (pass 6 P2): the rewritten Maestro example pinned
agent-runtime-base:0.7.0, but runtime-image tags version INDEPENDENTLY of the agentctl CLI / ADL schema (per the versioning policy in ROADMAP.md — the current published image line is:0.1.x). A:0.7.0tag both doesn't exist and falsely implies the image tracks the CLI version. Resolution: the image value is now an explicit, deliberately un-pullable placeholder (:<tag-with-agentctl-0.7.2-or-newer>) so an unedited copy fails loudly, plus a header note that the tag must be chosen by the agentctl version the image bundles, not by matchingv0.7.x. - Temporal SIGKILL orphaned the agent on cancellation (pass 6 P2):
exec.CommandContext's default cancel behavior isProcess.Kill()(SIGKILL), which bypasses agentctl's SIGTERM/SIGINT cleanup. On Temporal Activity cancellation / timeout (and the subsequent retry), the agentctl process and the runtime subprocess + in-flight model request it owns could keep running, orphaned. Resolution: setcmd.Cancelto send SIGTERM andcmd.WaitDelayto escalate to SIGKILL after a grace period, and added a heartbeat goroutine (withHeartbeatTimeouton the Activity options) so Temporal actually delivers the cancellation that triggers the graceful path — otherwise ctx isn't canceled untilStartToCloseTimeout. - Airflow run-id in the shell path (pass 7 P2): the example carefully routed
params.textthroughenv=but still rendered{{ dag_run.run_id }}directly into a single-quoted--output-fileargument inbash_command. Airflow deployments can permit custom DagRun IDs, and a run id containing a single quote would break out of the shell word — reintroducing the very injection class the example avoids for the text. Resolution: the result path now also travels viaenv=(RESULT_PATH, Jinja-rendered) and is read as"$RESULT_PATH"; only operator-controlled constants (SHARED_DIR,AGENT_SPEC) remain spliced into the command body. - Airflow run-id as a raw filename (pass 8 P2): even out of the shell, using the raw
dag_run.run_idas a filesystem path component is unsafe — a custom run id containing/or..can traverse out ofSHARED_DIR, and a long id plus the suffix can exceed the 255-byte filename limit and fail the write before the reader runs. Resolution: a_safe_keyhelper hashes the run id to 16 hex chars (registered as auser_defined_macrosJinja callable, so the writer and reader tasks render the same key) and that hash — not the raw id — keys the result file. - Maestro kubelet
$(VAR)expansion (pass 9 P1): the rewritten Maestro example claimed anargsSTRING_ARRAY was "injection-safe by construction" because no shell is involved. True for bash, but kubelet ALSO expands$(VAR)references in containercommand/args(and dependent expansion in env values) against the pod's env before exec. WithANTHROPIC_API_KEYmounted into the pod, an attacker-controlledtextof$(ANTHROPIC_API_KEY)would be rewritten to the secret before agentctl ran — secret exfiltration with no shell. Resolution: corrected the example's security narrative to describe BOTH expansion layers, and documented the safe options (pass untrusted input as a mounted file via the planned--input KEY=@<path>form; escape$→$$; don't co-locate secrets with untrusted-arg expansion). The inlinetext=${text}is annotated as safe only for trusted/static input. - Maestro cross-pod file permissions (pass 9 P2): the downstream reader used
busybox(root or an arbitrary uid), but agentctl writes the result0600/ its parent0700as the non-root uid baked into agent-runtime-base, so on non-root-enforced or root-squashed clusters the reader couldn't read the handoff file. Resolution: the reader now uses the SAME agent-runtime-base image (same uid as the writer), plus a FILE OWNERSHIP note recommending a sharedsecurityContext(matchingrunAsUser+ anfsGroupthat owns the volume) on both step pods.
Second slice of v0.7 under the Option-B pivot: gives schedulers a clean way to capture an agent's result so it can flow into the next workflow step.
agentctl run --output-file <path>— writes the agent's last assistant message to<path>on successful exit. Failed or cancelled runs leave the path untouched, so a scheduler reading the file sees the result of the last successful run (or nothing). Atomic write (write tmp + rename); parent dirs are createdmkdir -p-style (schedulers often point at a fresh-workspace path). File perms are0600since the output may contain sensitive task results.spec.outputSchema(new optional field) — a JSON Schema document. When set AND--output-fileis passed, the CLI extracts JSON from the agent's last assistant message (stripping a single```jsonfenced block if present), parses it, validates against the schema, and writes the re-marshaled validated JSON to<path>. Failure modes — non-JSON output, schema compilation failure, validation failure — all fail the run with a clear stderr error and exit 1. Without--output-filethe field has no runtime effect; validation is gated on a consumer being present.- Last-message capture: the CLI accumulates the LAST
EventMessage{role:assistant}(not concatenate). Single-turnagentctl runproduces exactly one; rare multi-turn runs surface the final reply (chat usesagentctl chat, notrun). - Empty-final hard error: if
--output-filewas requested but no assistant message arrived, the run fails. Silent zero-byte files become diagnosis puzzles later. - K8s round-trip intentionally NOT included (codex pass 5 P2): the default in-Pod image (
agent-runtime-base:0.1.5) embeds an older ADL schema that pre-datesoutputSchema. Writing the field into the in-Pod YAML would make every default K8s run that usesoutputSchemafail in-Pod validation BEFORE the agent runs. The host-side capture path is unaffected — the host CLI keeps the schema in its own CompiledSpec and validates from the wire stream the Pod streams back. The K8s round-trip will return in a future slice that bumps the default runtime image and pins image-version → schema-field compatibility. - Empty schema preserved (codex pass 1 P2):
outputSchema: {}is valid JSON Schema (accepts any JSON value) and is the natural way to require JSON syntax with no further constraints. The compiler and K8s marshaller both branch on key-presence / non-nil — notlen > 0— so an empty-schema run still flips the CLI into JSON-parse mode and rejects non-JSON output instead of silently downgrading to raw-text capture. - Numeric precision preserved (codex pass 1 P2): the JSON validator now uses
Decoder.UseNumber()so integers above 2^53 (Snowflake-style IDs, etc.) and high-precision decimals survive the parse → validate → re-marshal round trip losslessly. Without this,json.Unmarshal(_, any)rounds every number tofloat64and the re-marshal writes the rounded value, silently corrupting scheduler payloads. - Trailing-content rejection (codex pass 1 P2 + pass 2 P2): the JSON parse path now rejects anything after the decoded value. Pass 1 used
dec.More(), but pass 2 caught thatMore()is array/object-iteration-only and returns false on closing delimiters like]or}— so{"a":1}]would slip through. The check now inspects bytes pastdec.InputOffset()and only allows whitespace, catching stray delimiters, additional values, and prose tails. OutputSchemais*map[string]any(codex pass 3 P2):omitemptyon a baremap[string]anywould drop a zero-length map at JSON marshal time. That meantagentctl compile(which marshals CompiledSpec to JSON) silently lost theoutputSchema: {}distinction the rest of the slice carefully preserves. Pointer-to-map +omitemptydrops only nil pointers, so empty-map values survive end-to-end.- Pre-run schema compilation (codex pass 3 P2): a new
prepareOutputCapturehelper compiles the JSON Schema BEFOREbe.Submit. Without this, a malformedspec.outputSchemawould only surface AFTER the agent ran — wasting tokens and possibly executing tools before failing on a deterministic config error.finalizeOutputnow takes the pre-compiled*jsonschema.Schemadirectly. - Boolean JSON Schema shorthand intentionally not supported (codex pass 4 P2 — deferred): JSON Schema draft 2020-12 allows
outputSchema: true(accept any) andoutputSchema: false(reject all). 7.2 restrictsoutputSchemato object form because (a)trueis already expressible asoutputSchema: {}, (b)falserejects all output and has no real use, (c) supporting both would widen the field's type from*map[string]anytoanyacross the compiler, K8s marshaller, and pre-compile path for negligible practical value. The ADL schema rejects boolean shorthand at validate time with a clear error; a regression test pins this so a future widening is an explicit, observable change.
First slice of v0.7 under the Option-B pivot (see ROADMAP.md): agentctl is the agent runtime; external schedulers (Maestro, Airflow, Temporal) are the orchestrators. v0.7 ships ZERO new workflow YAML format and ZERO in-process orchestrator; instead it makes agentctl run a clean scheduler-callable task primitive.
agentctl run --input KEY=VALUE— repeatable flag. The scheduler calls agentctl once per task, passing parameters via this CLI surface. Keys must match[A-Za-z_][A-Za-z0-9_]*(so they're addressable from the interpolation syntax). Duplicate--input KEY=flags are rejected (last-wins would be silently buggy from a shell wrapper). Empty values are accepted (KEY=is a real input).${inputs.<key>}interpolation inspec.task. Pre-7.1 the task was a literal string; 7.1 lets operators write ONE Agent YAML withtask: "Research \"${inputs.topic}\""and dispatch with different--input topic=Xper scheduler task instance. Interpolation runs AFTER--taskoverride so the override itself can reference${inputs.X}.- Error semantics: a referenced input key that has no
--inputis a HARD error (interpolation fails the run with all missing keys listed in one message; iteration-style "fix one at a time" is the wrong UX). An--inputkey not referenced anywhere is a stderr warning (operator might intend it as reserved for a future field). - No escaping yet: no syntax for a literal
${inputs.X}in the task. If an operator needs that, they break the string up. Adding escape rules now would lock us into a syntax we may regret. - Malformed placeholder rejection: any text matching
${inputs.that ISN'T a well-formed${inputs.<valid-key>}(e.g.${inputs.topic-name}with a dash,${inputs.topicwithout closing brace,${inputs.}empty key) is a HARD error in the TEMPLATE. Codex pass 1 P2 caught the silent-pass-through bug; codex pass 2 P2 caught the follow-up: the check originally ran on the rendered OUTPUT, which incorrectly rejected legitimate input VALUES that happened to contain literal${inputs.foo}text (e.g. a code snippet a scheduler passes via--input snippet='${inputs.foo}'). Input values are opaque single-pass payloads; the malformed check now runs on the template only. - Post-interpolation empty-task check: codex pass 2 P2 caught that a template like
"${inputs.prompt}"with--input prompt=would render to empty and reach the backend, bypassing the schema's existingspec.taskminLength: 1invariant (which runs before interpolation). The CLI now re-enforces non-empty (afterTrimSpace) after interpolation and points the operator at the offending empty input. - Interpolation gate is caller-intent, not content sniff: codex pass 3 P2 caught the Kubernetes recursion bug. The K8s backend marshals the host-resolved spec (task already interpolated) into a Secret and runs
agentctl runin-Pod without forwarding--inputflags. If the host operator passed an opaque value like--input snippet='${inputs.foo}', the in-Pod child would see literal${inputs.foo}text inspec.taskand — under the oldstrings.Contains(spec.Task, "${inputs.")gate — re-trigger interpolation, then fail withunknown inputs: foo. The gate is nowlen(inputs) > 0only. Cost: a host operator who writes${inputs.X}in YAML and forgets--input X=no longer gets a pre-flight error; the literal text reaches the model and the operator learns from the confused response. Acceptable trade — the K8s correctness bug is worse than losing the soft pre-check. - Only
spec.taskis interpolated. Other CompiledSpec fields (persona, tools, model) change agent identity and should live in distinct Agent YAMLs. - Tests: 15 cases covering flag parsing (basic, empty value, equals-in-value, missing-equals, empty key, invalid key, duplicate key) + interpolation (basic, repeated reference, missing key as error, all-missing-keys-reported-once, unused-keys returned, no-reference no-op, no-collision with
$HOME/${OTHER_VAR}syntax).
- v0.7 direction pivot recorded in ROADMAP.md. Previous v0.7 plan (in-process
AgentWorkflowengine with parallel/conditional/foreach step shapes) is dropped. Reasoning: Maestro already does orchestration well; a custom workflow engine would duplicate it with no upside, and the durability problem (workflows outliving a single process) is exactly what Maestro/Temporal solve. New v0.7 plan: enhanceagentctl runas a scheduler-callable task primitive.
The v0.6.0 release rolls slices 6.1 through 6.6 — the full long-running agents track. agentctl chat <spec.yaml> is now a first-class interactive surface; sessions persist across process restarts via SQLite by default; the operator can retire idle sessions with agentctl sessions sweep; and the slice-5.x OTel tracing chain extends naturally to a chat-root → per-turn → adapter-session span tree.
Coordinated artifact bumps:
agentctlv0.6.0 (CLI)@agent-controller/runtime0.6.0 (Pi adapter)@agent-controller/runtime-opencode0.6.0 (opencode adapter)ghcr.io/ccdevelopforfun/agent-runtime-base:0.1.5(runtime image)
End-to-end acceptance test covering the v0.6 chat surface:
TestV06AcceptanceMultiTurnChatRoundtrip— opens a real chat-root span, runs three turns against a fake backend through the SQLite store, asserts that allchat.turnspans share the chat-root trace id and parent under the chat-root span id, that the session'sLastActiveAtadvances per turn, and that the exit transition toStatusPausedlands in the store.TestV06AcceptanceResumeAcrossReopen— closes the SQLite store, reopens against the same file, resumes the session by id, asserts the resume snapshot carries the priorpausedstatus and the resumed session isactiveagain.TestV06AcceptanceSweepThenResumeRejected— seeds a stale Active session, runsMarkExpired, asserts the session is nowexpiredAND thatopenOrResumeChatSessionreturnserrSessionExpiredwith the snapshot'sStatus=expiredsonewChatCmdemits the right wire event.
No new CLI surface in 6.6 itself; it's the release-readiness gate for slices 6.1–6.5.
Closes the v0.6 tracing story. Slice 6.3 wired a single OTel root span around the WHOLE agentctl chat REPL — every turn's adapter spans shared the same chat-root parent. Slice 6.5 inserts a chat.turn span per turn so the trace tree gets the natural three-level shape:
agentctl.run (chat root, whole REPL)
├── chat.turn 1
│ └── agent.session (adapter, parented via slice 5.2 TRACEPARENT)
│ ├── gen_ai.chat (slice 5.3 — adapter-emitted LLM span)
│ └── gen_ai.tool.call
├── chat.turn 2
│ └── agent.session
│ └── ...
└── chat.turn N
chat.turnspan opened inrunChatTurnas a child of the caller's ctx, via the sharedobservability.Tracer()(instrumentation scopegithub.com/CCDevelopForFun/agent-controller/cli— same scope asagentctl.runand adapter spans, so saved dashboard queries that filter by scope see ALL agentctl spans uniformly). Codex pass 1 P2 caught the original separate-scope choice that would have silently dropped turn spans from existing operator queries. The adapter dispatch (be.Resolve/be.Submit) uses the turn span's ctx, so the slice-5.2 TRACEPARENT env injection parents adapter spans under THIS turn — not the chat-root.- Attributes:
chat.turn.index(1-based monotonic counter per chat),chat.turn.prompt.bytes(size of the user input),agent_controller.session.id(the durable session id). Observability tools can navigate "turn 1 → turn 2 → …" without timestamp arithmetic. - Status: named-return
turnErrlets a deferredspan.End()observe the error and setStatus=Error+RecordErrorwhen the turn ends witherror/cancelled. Matches the chat-root pattern from slice 6.3 codex pass 4. - No-op when tracing is off: the OTel SDK's no-op tracer returns a no-op span; no allocation, no propagation, no perf cost.
Tests:
TestRunChatTurnEmitsChatTurnSpanWithIndex— usestracetest.InMemoryExporterto capture spans and asserts the attribute contract.TestRunChatTurnTurnSpanMarkedErrorOnTurnFailure— verifiesStatus=Errorwhen the adapter emitssession.ended { reason: "error" }.
What slice 6.5 does NOT do:
- No turn-counter persistence across
--resume. Each chat invocation starts atturn.index = 1because the chat-root span is also fresh. Resuming gets a NEW chat-root (with its own trace id); the resumed turns still nest correctly within their own chat-root. This is the simpler / correct semantic for "one trace per chat invocation". - No adapter-side awareness of turn boundaries. Pi/opencode just see the adapter session continue across turns; the turn-boundary structure lives at the host trace tree.
Closes the v0.6 chat lifecycle. The slice 6.3 REPL already tracked session state; slice 6.4 makes the transitions observable on the wire and adds a sweep operation for retiring idle sessions.
- Three new wire-event types (
cli/internal/wire/events.go+ TS mirrors in both adapter packages):session.resumed— emitted byagentctl chat --resume <id>immediately after a stored session is rehydrated. Payload carriessessionId,agentName,createdAt,previousLastActiveAt, andpreviousStatusso observability dashboards can compute time-since-last-touch and distinguish a resume-of-ended from a continuation-of-paused.session.paused— emitted at chat exit when the user steps away without an explicit/exit(EOF, SIGTERM, idle SIGINT). The session record'sStatusbecomespaused.session.expired— emitted byagentctl chat --resume <id>when the targeted session was already swept toStatusExpired. The chat then bails with a clear error.
Store.MarkExpired(ctx, cutoff)— new bulk operation on the Store interface. Transitions everyStatusActivesession whoseLastActiveAtis strictly beforecutofftoStatusExpired. Returns the IDs (sorted, for determinism). OnlyActivesessions are touched — already-Paused/-Endedsessions are left intact so the lifecycle history is preserved. BothMemoryStoreandSQLiteStoreimplement it; SQLite uses a transactional SELECT + UPDATE so concurrentUpdatecalls can't race it into inconsistency. Idempotent.agentctl sessions sweep --ttl <duration>— operator command for retiring idle sessions. Safe to run from cron / systemd timers.--ttl 0sis rejected (would expire everything). Defaults to 24h. Prints the list of expired ids to stdout; nothing to expire printsno sessions to expire (cutoff <RFC3339>).- Chat exit-reason tracking:
agentctl chatnow distinguishes the four exit paths and stores the right terminal status:/exit→StatusEnded+session.endedwire event- EOF (Ctrl-D), SIGTERM, idle Ctrl-C →
StatusPaused+session.pausedwire event - Pre-slice-6.4 all exits set
ended. Resume-fitness logic in slice 6.6 will prefer paused sessions when suggesting one to continue.
- Store-enforced terminal-Expired invariant:
Store.Updatenow returns the newsessions.ErrSessionExpiredsentinel when the existing row'sStatusisStatusExpiredAND the incoming update would transition it elsewhere. Enforced atomically — Memory via mutex-held read-then-write, SQLite via aWHERE id=? AND (status != 'expired' OR new = 'expired')clause. Codex pass 1 P2 (the original Get-then-Update guard) was still racy: sweep could fire between the Get and the Update commit. Codex pass 2 P2 closed the race architecturally — the guard lives at the data layer. Chat's per-turn LastActiveAt bump and exit-status write both callUpdate; onErrSessionExpiredthe chat emitssession.expiredon the wire, prints to stderr, and ends cleanly. - Pre-turn expiration check: codex pass 3 P2 caught that the post-turn
Updateonly catches expiration AFTER the model dispatch — letting an expired session run one extra prompt-and-tokens. Added a pre-turn touch viaUpdate; onErrSessionExpiredthe chat bails BEFORE submitting to the adapter. Best-effort (still need the post-turn check for the in-flight case) but closes the common "user idled past TTL then sent one more prompt" path. - Sweep-race during resume touch: codex pass 3 P2 caught that
openOrResumeChatSession's Get-then-Update could race with sweep — the Update would returnErrSessionExpiredbut the caller wrapped it as a generic touch error, losing thesession.expiredwire signal. Now the resume path detectsErrSessionExpiredand propagates via the sameerrSessionExpiredsentinelnewChatCmdalready handles — so the wire event lands either way. MarkExpiredrace-free viaUPDATE ... RETURNING: codex pass 4 P2 caught that the SELECT + UPDATE inside a deferred transaction couldn't promote a stale read snapshot under concurrent chat writes —agentctl sessions sweepcould fail with SQLITE_BUSY. Rewrote as a singleUPDATE ... RETURNING id(SQLite 3.35+, included in modernc.org/sqlite v1.52). No transaction needed; atomic at the row level.- Expiry check before cross-agent check on resume: codex pass 4 P3 caught that a swept session with a drifted YAML would return the cross-agent error first, never surfacing
errSessionExpiredtonewChatCmd→ nosession.expiredwire event. The expired session isn't resumable under either spec; the TTL lifecycle event takes precedence. --ttlhelp text clarified: Go'stime.ParseDurationdoesn't support thed(day) unit — multi-day TTLs must be expressed in hours (168hnot7d). Codex pass 1 P3.- Tests: shared Store-contract harness gains a
MarkExpiredsubtest (both Memory + SQLite pin identical semantics); chat tests updated for the newopenOrResumeChatSession3-return signature;sessions_sweep_test.gocovers happy path, no-matches message, zero-TTL rejection, and the sweep-overwrite-by-chat-exit race guard.
What slice 6.4 does NOT do:
- No automatic background sweep — operators schedule
sessions sweepthemselves. - Sweep doesn't emit
session.expiredon the wire (no active wire-stream consumer to emit on). The wire event is emitted bychat --resumewhen it hits a swept session. agentctl sessions show <id>/sessions rm <id>still pending — likely slice 6.6.
First user-facing surface for v0.6. The slice 6.1 Store interface + slice 6.2 SQLite impl get a CLI driver: agentctl chat <spec.yaml> opens an interactive multi-turn chat that keeps a single session alive across many prompts. Pi's own session loader maintains the conversation context across turns; our SessionStore records the higher-level metadata (id, agent, lifecycle status, last-active timestamp, original spec snapshot, opaque adapter state).
newChatCmdincli/cmd/agentctl/chat.go. Behavior:- Parse + compile the spec, open the
SessionStore(SQLite default;--in-memoryfor ephemeral chats), create or--resume <id>an existingSession. Cross-agent reuse — resuming a session whose stored agent name differs from the current spec — is rejected with a clear error: the model has been seeing the prior agent's persona and swapping mid-stream produces confused state. - One OTel root span wraps the whole REPL (slice 6.5 will refactor to per-turn child spans); host TRACEPARENT propagation from slice 5.2 + Pi adapter spans from slice 5.3 still apply per turn.
- REPL loop reads stdin one line at a time, scanner buffer raised to 1 MiB to match the host wire stdout cap (long pasted prompts don't truncate mid-line).
/exitand EOF (Ctrl-D) end the chat cleanly; Ctrl-C (SIGINT) cancels the current turn only viabackend.Stopand leaves the REPL alive. SIGTERM is handled at the REPL level — a process manager's shutdown actually terminatesagentctl chatrather than getting absorbed as a turn-cancel. Codex pass 1 P2 caught the original handler that listened to both; codex pass 2 P2 caught that the SIGTERM-driven cancel went unnoticed while the main goroutine was blocked insidescanner.Scan()— fixed by reading stdin via a goroutine + selecting on the channel + the REPL context so an idle prompt also unwinds on SIGTERM.- Raw prompt text is dispatched unmodified (intentional leading whitespace — code blocks, indented snippets — survives). Trimming applies only to the empty-line +
/exitdispatch decision. Codex pass 1 P3. - Turn failure detection: a terminal
session.ended { reason: "error" }OR{ reason: "cancelled" }produces a turn error even when no separateerrorwire event is emitted. Codex pass 1 P2 caught the original gap. - Session lifecycle on early errors: any setup failure between session creation and the REPL's normal exit (OTel init, runtime resolution, staleness check, stdin scanner error) now marks the session record
StatusFailedvia a deferred cleanup that observes the named-returnrunErr. Without this, the session would stayactiveforever andsessions lswould show a ghost record for a chat that never actually ran. Codex pass 3 P2 caught the gap. - Ctrl-C at idle prompt — a top-level SIGINT handler catches the signal so a Ctrl-C while waiting at the prompt cancels the REPL cleanly (deferred session cleanup runs) instead of taking Go's default signal path that skips defers. An atomic
inTurnflag tells the handler to ignore signals delivered DURING a turn — those are the per-turn handler's responsibility (cancel the turn, keep the REPL alive). Codex pass 4 P2. - Scanner error vs EOF race: the stdin-reader goroutine no longer closes
inputChwhenscanner.Err()is non-nil. Closing bothinputChANDscanErrChwould let the select pick the closed channel and treat a real scanner failure (e.g. a line over the 1 MiB cap) as a clean EOF nondeterministically. Now the two terminal signals are mutually exclusive. Codex pass 4 P2. - Chat root span marked Error on failure: deferred
span.End()now observesrunErrand callsSetStatus(Error, ...)+RecordErrorbefore ending — matches theruncommand's pattern (slice 5.1 codex pass 5). Without this, traced chat sessions that fail during setup would show UNSET status in BrainTrust / MLflow / Langfuse. Codex pass 4 P2. - Stored-spec wins on
--resume: the freshly-parsed YAML is still validated against the schema but the storedSession.Specdrives the rest of the chat. The on-disk YAML is just a lookup credential. Any drift (model / persona / tools / runtime change) prints a[resume] spec on disk differs from the session's stored snapshotwarning to stderr but does NOT silently apply mid-conversation — that would have changed tools/persona under the model without it knowing, the same "confused state" failure mode the cross-agent check guards against. Codex pass 5 P2. - Single-channel signal dispatch (codex passes 5 / 6 / 7 of slice 6.3 — three iterations on the same fan-out hazard):
- Pass 5: a Ctrl-C delivered to BOTH the per-turn handler AND the idle channel's buffer would fire as an idle interrupt after the turn returned and end the REPL.
- Pass 6: the pass-5 fix (
signal.Stop+ re-Notifyaround each turn) opened a window with NO SIGINT handler — a Ctrl-C in that window fell through to Go's default and killed the process. - Pass 7: the pass-6 fix (always-armed idle channel +
inTurnflag check) had an inherent race — the dispatcher goroutine could take a signal, be descheduled BEFORE the flag check, and by the time it resumed the turn had ended → flag=false → REPL ends, even though the user pressed Ctrl-C mid-turn. - Final fix: SINGLE signal channel, no fan-out. One dispatcher goroutine routes every SIGINT/SIGTERM. SIGTERM always ends the REPL; SIGINT is forwarded to
turnCancelChif a turn is active (the REPL loop readsturnActivewritten by it as the only writer), or ends the REPL otherwise.runChatTurnlistens onturnCancelChto cancel its own ctx + callbe.Stop(h). Each signal is delivered exactly once to exactly one consumer — no race possible.
- Runtime-type rejection moved post-resume:
local-opencodeis rejected based on the EFFECTIVE spec (the stored one after--resume, the freshly-parsed one otherwise). Without this, a user with a working Pi chat would fail to resume just because their YAML drifted to opencode. Codex pass 6 P2. - Cleanup-defer registered before runtime check: codex pass 8 P2 caught that the runtime-type rejection returned BEFORE the deferred
StatusFailedcleanup was installed, so an opencode-rejection left the session record stuckactive. Re-ordered to install the cleanup defer first. - SIGINT grace window: codex pass 8 P2 caught that even the single-channel design (pass 7) had an unresolvable race — a signal queued during a turn could be dispatched AFTER the REPL clears
turnActive, getting routed as REPL-end. The fix is fundamental-limit-aware: a 50ms grace window after each turn end where SIGINT still routes to the (just-ended) turn channel rather than ending the REPL. Kernel signal delivery is asynchronous; no pure flag-based design can perfectly synchronize with user-space state. The bias is intentional — a stray cancel routed to a finished turn is a benign no-op; an unintended REPL-end is catastrophic to user intent. 50ms is ~50x typical signal latency, well below the time a user spends reading the prompt before deciding to send a deliberate idle-Ctrl-C. - Each turn: clone the spec, set
Task = userInputandSessionID = &sess.IDon the LOCAL copy (the caller's spec stays untouched), dispatch throughLocalBackend.Submit, drain events, updateSession.LastActiveAtin the store. - On exit: mark
Session.Status = endedin the store (failure here is informational, not fatal — the chat already happened).
- Parse + compile the spec, open the
- opencode rejected (
runtime.type: local-opencode) with a clear message — opencode's--resumestory isn't there yet (slice 3.4 noted this forrun; chat inherits the same gap). Pi adapter only for MVP. - Tests in
chat_test.go(11 cases):--in-memoryvs default SQLite store selection (the SQLite path tests persistence across reopen against a per-test$XDG_DATA_HOME); session create/resume; resume refresh ofStatus+LastActiveAtwhile preservingCreatedAt; cross-agent reuse rejection; missing-resume-id error; per-turnTask+SessionIDinjection via a fake backend; the per-turn LOCAL-copy invariant (caller's spec.Task / SessionID don't drift across turns); error-event-as-turn-error semantics; clean-completion semantics; Submit-failure propagation. The TTY-driven REPL loop itself is exercised end-to-end in slice 6.6's acceptance test against a real adapter.
What slice 6.3 does NOT do:
- K8s
--bindingchat (each turn spawning a fresh Pod is wasteful and slow) — deferred to slice 6.6. - Lifecycle wire events
session.resumed/session.paused/session.expired— slice 6.4. - Per-turn OTel child spans under a chat-root span — slice 6.5.
agentctl sessions show <id>/agentctl sessions rm <id>— likely 6.4 with the lifecycle event work.
File-backed Store impl. The default session store for v0.6; in-memory (slice 6.1) is now the fallback for tests and ephemeral REPLs.
SQLiteStoreincli/internal/sessions/sqlite.go. Wraps a single SQLite connection (writes are SQLite-serialized anyway) with WAL mode +synchronous=NORMAL+ a 5-second busy timeout. Schema lives inline asschemaSQL+pragmaSQL; both are idempotent soNewSQLiteStoreis safe to call repeatedly on the same file.schema_versioncolumn reserved for future migrations.modernc.org/sqlitepicked overmattn/go-sqlite3: pure Go (transpiled from C), no CGO, keeps cross-platform binary distribution simple. Negligible perf cost for our workload (single-digit writes per chat session).DefaultSQLiteStorePath()resolves the conventional location:$XDG_DATA_HOME/agent-controller/sessions.dbwhen XDG is set, otherwise$HOME/.local/share/agent-controller/sessions.db.NewSQLiteStoremkdir's the parent at mode0700if missing — session metadata is per-user.- Shared contract test harness in
contract_test.go. BothMemoryStoreandSQLiteStorerun through the same 11 subtests so both impls are pinned to identical behavior. New impls (Postgres / Redis in v0.8) just plug in via astoreFactoryclosure. - SQLite-specific tests: in-memory (
:memory:) AND file-backed runs of the full contract, persists-across-reopen, creates-parent-directory, rejects-empty-path, idempotent Close, concurrent Close + ops race coverage (codex pass 1), XDG / HOME path resolution. - Concurrent-Close safety:
sync.Oncearounddb.Close()+ the*sql.DBhandle is NOT nil'd, so a racingClose + Create/Get/...from another goroutine fails cleanly withsql.ErrConnDoneinstead of panicking on a nil-pointer dereference. Codex pass 1 P2 caught the original unsynchronized read/write ofs.db. - Tight file permissions:
NewSQLiteStorechmods the DB /-wal/-shmfiles to0o600after they're created (every open re-enforces). Without this, a typical0o022umask leaves the files0o644— readable by other local users — andspec_jsoncan hold MCP env vars / headers that are real secrets (API keys, OAuth tokens). Codex pass 2 P2. - Narrow-ownership directory chmod: parent dir is chmodded to
0o700ONLY whenNewSQLiteStoreitself created it (detected via a pre-MkdirAll Stat). Pre-existing parent dirs — including the CWD when a caller passes a bare filename like./sessions.db— are left alone. Codex pass 3 P2 caught the unconditional chmod that would have silently stripped group/other perms from caller-owned directories. busy_timeoutapplied before WAL setup: split out as the first pragma Exec, BEFOREjournal_mode=WAL. The WAL switch briefly takes an EXCLUSIVE lock; without an installed busy handler, a concurrent SQLITE_BUSY at that instant fails open immediately even though the 5s retry was intended to cover it. Codex pass 4 P2.- SQLite
file:URIs explicitly rejected: a previous doc-comment claimed they were accepted, butfilepath.Dirmangles them during the parent-dir prep + permission hardening (file:/tmp/sessions.db?cache=shared→ bogusfile:/tmpdirectory creation, and the real DB file misses the chmod). NowNewSQLiteStorereturns a clear error for anyfile:-prefixed path. Codex pass 4 P2. - Forced WAL/SHM creation at open: a
BEGIN IMMEDIATE; COMMIT;runs after the schema Exec so the WAL + SHM files always exist by the time the chmod loop runs. Without this, reopening an existing DB (where the idempotent schema is a no-op) leaves WAL/SHM uncreated at chmod time; the nextCreate/Updatethen creates them with the process umask (0o644), leakingspec_jsonsecrets. SQLite preserves file perms through subsequent WAL checkpoints, so a once-at-open chmod sticks. Codex pass 5 P2. - DSN query strings also rejected: paths containing
?(e.g.sessions.db?_pragma=busy_timeout(5000)) are modernc DSN query strings — the driver strips them at open, but our chmod uses the literal path and hardens nonexistent files while the REAL DB/WAL/SHM stay at the process umask. Same class of bug as thefile:URI rejection; same fix. Codex pass 6 P2.
What slice 6.2 does NOT do:
- No
agentctl chatCLI surface yet — slice 6.3 wires the REPL into either store via a config knob. - No expiry / TTL sweep — slice 6.4.
- No
sessions ls/show/rmsubcommands — likely slice 6.3 alongside chat, or 6.6 if scope gets tight.
First slice of v0.6 (long-running agents). Lays the durable-session foundation that slice 6.3 (REPL), 6.4 (lifecycle events), and 6.5 (cross-turn tracing) all build on. No user-facing surface yet.
- New package
cli/internal/sessionswith theStoreinterface, theSessionvalue type, theSessionStatusenum (active/ended/paused/expired/failed), andListFilter. Five statuses defined upfront so slice 6.4 (paused/expired) doesn't have to widen an in-use enum. - Session field mutability split pinned by the doc-comment and enforced by every Store impl:
- Immutable after
Create:ID,AgentName,RuntimeType,CreatedAt,Spec,TraceContext— session identity can't drift through Update. - Mutable on
Update:Status,LastActiveAt,AdapterState(an opaque map the adapter uses to find its on-disk state).
- Immutable after
MemoryStore— ephemeral in-memory implementation, sync.RWMutex-guarded. Suitable for tests + REPL fallback when SQLite (slice 6.2) is unavailable. Returns deep copies (not interior pointers) so callers can't mutate stored sessions through returned values —CompiledSpec's nested slices and maps (Tools[].Config,MCPServers[].Env, etc.) are JSON-roundtripped to preserve isolation; the spec is already designed to be JSON-serializable as the wire-format payload to adapters, so the roundtrip is lossless and consistent with existing usage. Codex pass 1 caught the shallow-copy bug. List sorts byLastActiveAtdesc withIDtiebreak for determinism. Close is idempotent.- 13 tests covering: create-then-get roundtrip, duplicate-ID rejection, empty-ID rejection, NotFound semantics on Get/Update, the immutable-on-Update contract, idempotent Delete, List filter + order + limit, Close drops data + errors after, shallow copy-isolation on Create and Get, deep copy-isolation through nested spec slices/maps (codex pass 1), deep copy-isolation through nested AdapterState maps/slices (codex pass 2), context cancellation.
What slice 6.1 does NOT do:
- No SQLite impl yet — slice 6.2.
- No
agentctl chat/sessions ls/ etc. CLI surface — slice 6.3. - No lifecycle wire events (
session.resumed,session.paused,session.expired) — slice 6.4. - No span linkage across resumed turns — slice 6.5.
The v0.5.1 release rolls slices 5.2 through 5.6 — every piece of the v0.5 tracing track beyond the host-side OTel root span that shipped in 0.5.0 (slice 5.1). Together these slices deliver a fully stitched OTel trace from the host agentctl.run span down through Kubernetes Pod env, the runtime adapter (Pi or opencode), and per-LLM / per-tool spans inside the adapter — emit-able to any OTLP-HTTP collector.
Coordinated artifact bumps:
agentctlv0.5.1 (CLI)@agent-controller/runtime0.5.1 (Pi adapter)@agent-controller/runtime-opencode0.5.1 (opencode adapter)ghcr.io/ccdevelopforfun/agent-runtime-base:0.1.4(runtime image carrying the full chain)
The v0.5 tracing track's acceptance gate. Stands up an httptest.Server that emulates an OTLP-HTTP collector (BrainTrust, MLflow, Langfuse, Jaeger, otel-collector — all implement the same wire surface), runs the host's InitTracerProvider + StartRootSpan path against it, decodes the received protobuf payload, and asserts:
- Wire format: requests hit
/v1/traceswithContent-Type: application/x-protobuf(transparently handles gzip-compressed bodies in case the SDK enables compression in a future release) - Span attributes:
gen_ai.system,gen_ai.request.model,gen_ai.operation.name,agent_controller.agent.name,agent_controller.runtime.typeall present and match the values passed viaRunAttributes - Resource attributes:
service.name = "agentctl"andservice.versionmatch the build version (operators filter dashboards byservice.name; drift here breaks saved views silently)
This is the test the v0.5 tracing track was waiting on — it proves that what we emit is what any generic OTLP collector will ingest, without needing a real one in CI. New file: cli/internal/observability/otlp_roundtrip_test.go. Promotes go.opentelemetry.io/proto/otlp and google.golang.org/protobuf from indirect to direct deps (needed for the protobuf decode in the fake collector handler).
Pure test addition. Slice 5.2 already shipped both halves of the K8s end-to-end propagation (host-side KubernetesBackend.Submit injecting TRACEPARENT into the Pod container env; in-Pod agentctl extracting via ExtractTraceContextFromEnv after InitTracerProvider); slice 5.5 closes the verification gap with three integration-level tests in cli/internal/backend/kubernetes_test.go:
TestKubernetesBackendSubmitPropagatesActiveTraceparentToPodEnv— Submits with an active OTel span in ctx, then inspects the resulting Pod's container envVars and assertsTRACEPARENTis present in W3C format AND its trace-id segment matches the host span's trace id. Also confirmsValue-not-ValueFrom(so asecretKeyscollision can't accidentally hijack the trace header).TestKubernetesBackendSubmitOmitsTraceparentWithoutActiveSpan— pins the no-op contract: when the host has no active OTel span, the Pod's env carries neitherTRACEPARENTnorTRACESTATE. Prevents a regression that would deliver a stale/empty parent to the in-Pod agentctl and leave it producing a detached root span.TestKubernetesBackendSubmitTraceEnvCoexistsWithSecretRefEnv— Verifies thatsecretRef-injected env vars (ANTHROPIC_API_KEYviaValueFrom: SecretKeyRef) and trace-injected env vars (TRACEPARENTvia plainValue) coexist on the same container without one clobbering the other. Defense against a future refactor that might re-route trace injection through a SecretKeyRef.
Together with the existing trace_propagation_test.go (helper-level) and env_propagation_test.go (extraction-level) suites, these tests pin every link of the K8s trace chain at the unit-test layer without needing a real cluster.
Mirror of slice 5.3 for the opencode adapter. Smaller delta than the Pi adapter because opencode owns its own LLM/tool span emission via the Vercel AI SDK's experimental_telemetry; the adapter only needs to wrap the run in an agent.session parent span and flip opencode's config flag.
spec.observability.tracing: trueflipsexperimental.openTelemetry: truein the opencode config the SDK passes to the spawnedopencodechild. The child's AI-SDK telemetry then emits its own LLM + tool spans via whatever OTel SDK opencode initializes from env.runtime-opencode/src/observability.ts(new): same two-condition gate as the Pi adapter (spec.observability.tracingANDOTEL_EXPORTER_OTLP_ENDPOINT/_TRACES_ENDPOINTset). When live, opens anagent.sessionspan withgen_ai.*attributes nested under the host TRACEPARENT (slice 5.2 env injection).process.env.TRACEPARENTrewrite: after the adapter opens itsagent.sessionspan, it overwritesprocess.env.TRACEPARENTwith the new span's id so the opencode child — which the SDK spawns viacross-spawnwith{ ...process.env }— picks up the deeper parent. Without this rewrite, opencode's spans would skip theagent.sessionlevel and parent offagentctl.rundirectly. StaleTRACESTATEfrom the host is also cleared on the same path (mirrors the cleanup the Go injectTraceparent does, slice 5.2 codex pass 2).- Wire-envelope stamping: when tracing is active, every wire event picks up
apiVersion: v1alpha1+ the activetraceparent. Implemented via a module-levelactiveTraceparentholder rather than a function param rewrite — opencode adapter has one session per process so the holder is unambiguous. - Shutdown uses the same 5s three-layered timeout pattern as the Pi adapter (OTLP exporter
timeoutMillis, BSPexportTimeoutMillis, outerPromise.racewatchdog). Setup-failure and cancellation paths both flush correctly:- Setup throws (e.g.
createOpencodefails, missingopencodeCLI):initAdapterTracinglives inmain()rather thanrunOpencode, so the outer catch inmain()runstracing.end("error", realExceptionMessage)with the actual error — the span records the real cause, not a placeholder. Terminal wire events emitted from main's catch still carryapiVersion+traceparentbecauseactiveTraceparentisn't cleared until after the emit. Codex pass 1 P2. - SIGINT / SIGTERM: the signal handler now defers
process.exit(130)through an async IIFE so the OTel BSP gets its full 5s shutdown budget before the process terminates. A re-entry guard prevents a second signal during the flush from re-running the handler body. The cancelled-session wire event goes throughemit()rather than a rawstdout.write, picking up the new envelope fields. Codex pass 1 P2.
- Setup throws (e.g.
- Race-free terminal-event ownership: codex pass 2 caught two race conditions exposed by the new async tracing flushes — (a) on cancellation, the SSE main loop saw the abort-induced stream close and was about to append a duplicate
error+session.ended(error)after the handler'scancelledterminator; (b) on success/error exit, the post-emittracing.end()5s flush left the signal listeners installed, so Ctrl-C during that flush would emit a secondcancelledterminator and flip the exit code to 130 on an already-completed run. Fixed by (a) acancelledByUserflag the main loop checks before its terminal-emit block, and (b) removing signal listeners before awaiting the post-emittracing.end(). - Reliable stdout drain on cancellation: codex pass 3 caught that the
writableNeedDraincheck from pass 1 leaves a hole — for small writes (which the cancelled terminator usually is) the flag stays false even though bytes are still queued, soprocess.exit(130)could fire before the line actually shipped. Restored the write-callback pattern (emptyprocess.stdout.write("", cb)resolved only when the OS has consumed everything queued ahead of it) so the cancelled wire event is guaranteed to land before exit. - Cancellation-fetch-failure suppression: codex pass 4 caught a third cancellation signature — SDK requests like
session.create()orpromptAsync()whose underlying connection is severed byabortController.abort()reject withTypeError: fetch failed, NOTAbortError. The old string-only check (err.name === "AbortError" || err.message.includes("aborted")) missed those, so main's catch was about to emit duplicateerror+session.ended(error)after the handler'scancelledterminator. Fixed by hoistingcancelledByUserGlobalto module scope (set synchronously by the signal handler) and checking it first in main's catch — string matches remain as defense-in-depth. - Server-close ordering across all three exit paths: codex pass 5 caught that the new async
tracing.end()5s flush left the opencode child running during the wait. On cancellation that meant the child kept doing tool work for up to 5s after Ctrl-C (visible to the user as ignored cancellation); on success/error with handlers already removed, an unhandled SIGINT during the flush would take Node's default-exit path, skipfinally, and orphan the child. Fixed by callingserver?.close()BEFOREtracing.end()in all three exit paths. AsessionTerminatedflag now also drops any wire event that arrives via emit() after a terminator was already written — closes the residual race where an in-flight SSE event lost to the cancelled terminator by microseconds.
- Adapter
agent.session→ opencode child link is best-effort: the adapter writesprocess.env.TRACEPARENTso the opencode child inherits the deeper parent context, but opencode's AI-SDK telemetry only nests under it if opencode itself extractsTRACEPARENTduring its OTel init. We have no way to enforce that from outside the binary. Until verified against a real opencode build (slice 5.6 acceptance), opencode-emitted LLM/tool spans may surface as detached roots in your collector — the adapter'sagent.sessionspan will still be present and correctly nested underagentctl.run. Codex pass 4 flagged this; the env injection stays for future-compat and zero-cost upside.
What slice 5.4 does NOT do:
- The captureContent path captures
spec.taskon the adapter'sagent.sessionspan only (matching the Pi pattern). Per-LLM-call prompt/completion capture inside opencode is opencode's responsibility via its own AI-SDK telemetry semconv attrs — we don't reach into the child to control that. - No end-to-end BrainTrust / MLflow / Langfuse verification yet — slice 5.6.
Pi-adapter side of the v0.5 tracing story. The host emits a root span (slice 5.1) and threads TRACEPARENT into the adapter env (slice 5.2); slice 5.3 makes the Pi adapter consume that parent and emit its own child spans for sessions, LLM calls, and tool calls.
runtime/src/observability.ts(new): self-contained OTel SDK init. Returns a no-op tracer unless BOTHspec.observability.tracing: trueANDOTEL_EXPORTER_OTLP_ENDPOINTis set — matches the host-side two-condition gate. When live, the module:- Sets up
NodeTracerProvider+BatchSpanProcessor+OTLPTraceExporter(HTTP, JSON). - Extracts the host TRACEPARENT via the W3C propagator and opens an
agent.sessionspan under it. - Exposes a callback surface (
AdapterTracing) the adapter calls from its Pi event subscriber.
- Sets up
runtime/src/adapter.tswires the Pi event stream into the tracer:turn_start/turn_end→gen_ai.chatspan. (Pi'sbefore_provider_request/after_provider_responseare extension-runner hooks and never reach a session subscriber — codex pass 1 of slice 5.3 caught the original mis-wiring against the wrong event channel.) Token usage +stopReasonget pulled fromturn_end.message.usage/stopReason; an error/aborted stopReason marks the LLM span as ERROR.tool_execution_start/tool_execution_end→gen_ai.tool.call <name>span nested under the active LLM span (so tools parent off the LLM that requested them, matching the OTel GenAI semconv guidance).- Defensive cleanup if Pi ever leaves a span open (LLM superseded without an end event, tools open at session.dispose) — set to ERROR and closed at session shutdown.
session.disposeis followed by an awaitedtracing.end()so the BatchSpanProcessor drains before the adapter subprocess exits. 5s timeout mirrors the host flush budget. The watchdog timer isclearTimeout'd after the shutdown wins the race so it cannot keep the Node event loop alive for the full 5 seconds on normal exits (codex pass 1 P2).
OTEL_EXPORTER_OTLP_TRACES_ENDPOINTsupport: the env-resolver honors the trace-specific variable in addition to the genericOTEL_EXPORTER_OTLP_ENDPOINT, with the trace-specific one winning when both are set — matches the standard OTel SDK precedence and the host-sideisEnabled()check incli/internal/observability/otel.go. Codex pass 1 P2 caught the silent no-op when only the trace-specific var was configured.- Setup-failure flush path:
runSessionnow wraps its post-tracing-init body in an outer try/catch that emits anerror+session.endedterminator and callstracing.end("error", ...)on any escape (e.g.createAgentSessionorbindExtensionsthrowing). Without this the early setup throws left theagent.sessionspan open and dropped the failure from telemetry. The catch only re-throws when the terminator emit itself failed — otherwise returns normally, soindex.tsdoesn't append a duplicate error after the terminalsession.ended. Codex pass 2 P2 + pass 3 P2. - Prompt capture: when
captureContentis on,spec.taskis stamped asgen_ai.prompton theagent.sessionspan at init time, so specs opting into content capture get the full prompt → completion → tool args → tool result chain that the schema docs promise. Codex pass 2 P2. @opentelemetry/sdk-trace-baseis now an explicit runtime dep (runtime/package.json). Previously theBatchSpanProcessorimport resolved only via hoisting in flatnode_modules; under pnpm / Yarn PnP / a future npm layout that doesn't hoist, the import would have failed at adapter startup. Codex pass 2 P2.- Three-layered shutdown timeout: the OTLP exporter's
timeoutMillis, theBatchSpanProcessor'sexportTimeoutMillis, AND the outerPromise.racewatchdog are all set to a shared 5s budget. Without the SDK-level timeouts, a slow or blackholed collector at process exit could keep the adapter alive 30s past intent (the BSP's default export-timeout) —Promise.racestops awaiting but does not cancel pending I/O. Codex pass 4 P2. - Content-cap correctness:
truncate()now reserves space for the…[truncated]marker (13 UTF-8 bytes) before slicing, so the returned attribute's byte length is always<= MAX_CONTENT_BYTESeven on the truncation path. Previously the marker was appended after slicing to the full cap, overflowing it. Codex pass 4 P2. - Session id consistency on
--resume: the adapter'sagent.sessionspan (and the resourceagent_controller.session.idattribute) preferspec.sessionIdover the ephemeral wire-id when set, so resumed runs index host + adapter spans under the same session id. The wire-eventsessionIdstays ephemeral (wire semantics predate--resumeand changing them would break existing consumers). Codex pass 5 P2. - Model attrs on every
gen_ai.*span:gen_ai.systemandgen_ai.request.modelare stamped directly on everygen_ai.chatspan (andgen_ai.systemon everygen_ai.tool.callspan). OTel attributes don't inherit through the span tree, so backends that filter LLM/tool spans by provider or model would have seen blanks without this. Codex pass 5 P2. spec.observability.captureContent(new ADL field): when true, the adapter attachesgen_ai.completion,gen_ai.tool.call.arguments, andgen_ai.tool.call.resultas span attributes. Off by default for privacy. Content payloads are JSON-stringified and capped at 64 KiB per attribute (matches the host stdout-scanner buffer cap incli/internal/backend/local.go) — large tool outputs get a…[truncated]marker rather than blowing up the OTLP batch.- Wire-envelope stamping: when tracing is active on the adapter side,
apiVersion: "agent-controller.dev/events/v1alpha1"(the slice 5.2 reservation) and the activetraceparentget stamped onto every outgoing wire event via a thinwrapEmitWithTracingwrapper. When tracing is off the wrapper is the identity — no per-event overhead and legacy v0.x consumers keep seeing the legacy envelope shape. - Mirrors:
cli/internal/adl/types.go::Observability,cli/internal/adl/compiler.goextract path, both copies ofschemas/adl.v1alpha1.json, andruntime/src/types.ts+runtime-opencode/src/types.tsall gain thecaptureContentfield.
What slice 5.3 does NOT do yet:
- The opencode adapter still ignores tracing — slice 5.4 flips its
experimental.openTelemetry: trueflag and threads TRACEPARENT the same way. - BrainTrust / Databricks MLflow / Langfuse end-to-end acceptance is slice 5.6.
- The
audit.event/artifact.createdevent types reserved in slice 5.2 still have no emitters.
Prerequisite for adapter-side payload capture in slices 5.3 / 5.4.
- Wire-event envelope now carries two optional fields:
apiVersion:agent-controller.dev/events/v1alpha1once an adapter starts emitting the new shape (5.3+). Absent on legacy v0.4.x and earlier — consumers should treat absence as the implicit v0 namespace.traceparent: W3C TraceContext header value, so adapter-side spans can be stitched as children of the hostagentctl.runspan emitted in slice 5.1.
- Reserved event types (constants only — emission lands in slices 5.3 / 5.4 / 5.5):
tool.started,tool.completed,tool.failed— splits the legacytool.call/tool.resultpair into three discrete events with richer span boundaries (maps onto OTelgen_ai.tool.call.*semconv)artifact.created— emitted when an agent run produces a durable artifactaudit.event— structured audit log entries for the v0.5+ governance track
- Host-side TRACEPARENT injection.
LocalBackend.SubmitaddsTRACEPARENT(andTRACESTATEwhen present) to the runtime adapter subprocess env via the OTel propagator.KubernetesBackend.Submitdoes the same into the Pod's agent-container env. Tracing-off is a no-op (the SDK's default propagator writes nothing) so this is unconditional —injectTraceparentincli/internal/backend/trace_propagation.go. The helper strips any pre-existingTRACEPARENT/TRACESTATEfrom the caller env before appending the freshly-extracted pair, so a child process can never receive a fresh trace id paired with a stale vendor tracestate inherited from a wrapper script (codex pass 2 of slice 5.2 caught the pairing bug). - Entrypoint TRACEPARENT extraction. Symmetric to the injection:
agentctlreadsTRACEPARENT(+TRACESTATE) fromos.Environimmediately afterInitTracerProvider, so when it runs inside a Pod launched by an outer agentctl, its root span nests under the hostagentctl.runspan instead of starting a detached trace.observability.ExtractTraceContextFromEnvis a no-op when env is unset or malformed — codex pass 1 of slice 5.2 caught the missing extraction half. - TS mirrors. Both
runtime/src/types.tsandruntime-opencode/src/types.tsaddapiVersion+traceparenttoWireEvent, the new event-type literals, and exportEventsAPIVersionV1alpha1so adapter code in 5.3 / 5.4 can stamp the namespace consistently.
What slice 5.2 does NOT change yet:
- Existing
tool.call/tool.resultevents keep their current shape and are still emitted by both adapters — no break. - No adapter actually reads
TRACEPARENTyet. That wiring lives in slices 5.3 (Pi via@raindrop-ai/pi-agent) and 5.4 (opencode viaexperimental.openTelemetry).
Coordinated bumps for the v0.5.0 release: CLI version v0.5.0, npm adapters @agent-controller/runtime@0.5.0 and @agent-controller/runtime-opencode@0.5.0 (version-bump only — adapters themselves unchanged this release), runtime image runtime-image/v0.1.3 (carries the new agentctl + schema that recognizes spec.observability). Go floor bumped to 1.25 for the OTel SDK v1.44+ requirement; runtime-image Dockerfile builder bumped to golang:1.25-bookworm in lockstep.
- OpenTelemetry root span around
agentctl run. Newinternal/observability/package wires the OTel Go SDK + OTLP/HTTP exporter. Eachagentctl runopens anagentctl.runspan tagged with OTel GenAI semconv attributes (gen_ai.system,gen_ai.request.model,gen_ai.operation.name) plus agent-controller-specific attributes (agent_controller.agent.name,agent_controller.runtime.type,agent_controller.backend.type,agent_controller.binding.name,agent_controller.session.id). Thebackend.typeattribute separates Kubernetes-backend submissions from local ones (whereruntime.typewould otherwise be misleading — it names the in-Pod adapter, not the orchestration plane). Span status flips to Error for any non-zero exit (including reason=cancelled). The span opens immediately after spec parse, so preflight failures (--resume/local-opencodemismatch, invalid--binding, K8s client setup, staleness check) all get traced. RunE uses a named return so the defer captures the actual returned error even when error paths shadow the outererrvariable. Adapter-side and per-tool spans land in slices 5.3 / 5.4. spec.observability.tracingADL field (boolean, default false). The spec must opt in AND the operator must pointOTEL_EXPORTER_OTLP_ENDPOINTat a collector — either missing → no-op provider (zero cost). Lets a sensitive spec disable tracing even when the host has the env exporter configured. The compiler passes the field through into CompiledSpec; the KubernetesBackend round-trip preserves it into the in-Pod Agent YAML.main.versionstring variable wired into the ldflags-injection that release.yml already does, surfaced asservice.versionon every span so traces correlate to a specific CLI build.examples/tracing-demo.yamlwith the env-var commands for a local OTel collector and for BrainTrust.
Roadmap pivot 2026-06-10 — see ROADMAP.md for the full rationale. After shipping the v0.4.0 KubernetesBackend skeleton (slice 4.3), the next four minor releases focus on observability + agent shapes rather than further K8s backend polish:
- v0.5.0 — Tracing (OpenTelemetry). GenAI semconv, OTLP exporter, three-layer trace propagation (CLI → adapter → tool/MCP), one observability-platform smoke (MLflow or BrainTrust). Includes the event-protocol v1alpha1 bump (
tool.{started,completed,failed},artifact.created,audit.event). - v0.6.0 — Long-running agents (REPL + durable sessions).
agentctl chat; pluggable session store (SQLite default, in-memory for tests); foundation for the v0.8 server. - v0.7.0 — Declarative orchestration. New
AgentWorkflowADL kind for multi-agent pipelines.agentctl workflow run. Composes with v0.5 tracing and v0.6 sessions. - v0.8.0 — Long-running agents, part 2.
agentctl servewith HTTP/SSE endpoints; multi-tenant primitives; workflow server endpoint.
Deferred to opportunistic background (no committed release): slice 4.4 (advanced K8s binding fields), slice 4.5 (sandboxing enforcement), slice 4.7 (Docker local-container backend). They ship when there's a clear user-signal pull.
Slice 4.3 lands the K8s skeleton. Coordinated bumps for the v0.4.0 release: CLI version v0.4.0, npm adapters @agent-controller/runtime@0.4.0 and @agent-controller/runtime-opencode@0.4.0 (version-bump only — no functional change in the adapters themselves; the release workflow gates require matching versions), runtime image runtime-image/v0.1.2 (carries the new agentctl with --ndjson-stdout).
-
runtime-image/— multi-stage Dockerfile + GHCR publish workflow foragent-runtime-base. Image bundlesagentctl(built fromcli/in-tree),@agent-controller/runtime,@agent-controller/runtime-opencode, and theopencode-aiCLI; non-root user; tini PID-1;linux/amd64+linux/arm64. Versions independently of the umbrella tag —runtime-image/v0.1.0(first publish),v0.1.1(opencode-postinstall fix + adapter 0.3.3),v0.1.2(agentctl with--ndjson-stdout+ adapters at 0.4.0; required byKubernetesBackend). -
KubernetesBackend(slice 4.3): newBackendimplementation that submits an agent run as a Pod fromagent-runtime-base. AddsRuntimeBinding.spec.target.type: kubernetesplustarget.kubernetes.{namespace, image, secretRef.{name, keys}}(the schema's conditionalrequiredrule rejectskubernetestargets that omitsecretRefatagentctl validatetime, not at runtime). The CLI dispatches to this backend automatically when the active Binding's target.type iskubernetes. Submit creates a Secret holding the rendered Agent YAML (Secret rather than ConfigMap becausespec.mcpServers[].envandheaderscan carry API tokens), creates a Pod that mounts it at/workspace/spec.yamlviasubPath(so/workspacestays writable), reads adapter credentials from the user-supplied Secret named insecretRef.name, passes--ndjson-stdoutto the in-Pod agentctl so its stdout is parseable wire NDJSON, setsAGENT_CONTROLLER_RUNTIMEto the opencode adapter whenspec.runtime.typeislocal-opencode, and streams the in-Pod agentctl's NDJSON stdout as wire events (always specifyingContainer: "agent"so sidecar-injected Pods still stream). Stop deletes the Pod and spec Secret; cleanup also fires automatically when the log stream ends. User Ctrl-C surfaces assession.ended reason=cancelled(matches LocalBackend's exit-130 convention) rather thanreason=error.NewKubernetesBackendtriesrest.InClusterConfig()before kubeconfig discovery when no explicit path/context override is set, soagentctlruns from inside a Pod with only a ServiceAccount token. Default image isghcr.io/ccdevelopforfun/agent-runtime-base:0.1.2(the version that carries the matching agentctl).Out of scope for 4.3 and deferred to later 4.x slices: SecurityContext / NetworkPolicy enforcement (4.5), explicit kubeconfig path / context / serviceAccount fields (4.4), Job vs Pod toggle, registry-backed specs in the Pod, custom Pi-extension tools / extensions / skills / subagents / installs /
--resume(all rejected byvalidateCompiledSpecForK8s). Seeexamples/bindings/kubernetes-kind.yamlfor a working example against a localkindcluster. -
agentctl run --ndjson-stdoutflag: swaps stdout from human-formatted[type] {...}lines to raw NDJSON (one wire event per line). The KubernetesBackend passes this flag to the in-Pod agentctl so the host can decodekubectl logsdirectly withwire.Decode. -
Shared matcher (
cli/internal/backend/matcher.go): selector + capability-matching logic factored out oflocal.gosoLocalBackend.ResolveandKubernetesBackend.Resolveapply identical policy. Behavior unchanged vs v0.3.3.
0.3.3 — 2026-06-08
Adapter fix so ANTHROPIC_BASE_URL works with both adapters using the same value (e.g. an internal Anthropic-compatible AI gateway).
- opencode adapter URL composition. v0.3.2 honored
ANTHROPIC_BASE_URLonly when it already contained/v1(Vercel@ai-sdk/anthropicappends/messages). Pi-style URLs (no/v1) made opencode request${base}/messagesand gateways returned 404. The adapter now normalizes the URL atrunOpencode()startup: if the value doesn't end in/vN,/v1is appended in-place before any opencode subprocess inherits the env. Standardapi.anthropic.com/v1is preserved as-is. runtime-image/v0.1.1— Dockerfile now explicitly runsopencode-ai'spostinstall.mjsafternpm install -g --ignore-scripts. Without this the image shipped a stub/usr/local/bin/opencodethat exited with "postinstall script was not run" on first invocation.
- New export
normalizeAnthropicBaseUrlForOpencodeinruntime-opencode/src/index.ts— exposed for unit tests that pin the normalization rules (7 cases: undefined, empty, Pi-style with/without trailing slash, already-/v1, hypothetical/v2, sub-path). @agent-controller/runtimev0.3.3 — version bump only (release workflow's version-match gate; no functional change in the Pi adapter).
0.3.2 — 2026-06-05
Bug-fix patch: npm-installed @agent-controller/runtime now supports spec.subagents (previously broken in v0.3.1).
- Vendored subagent extension was missing from the npm tarball. v0.3.1's
runtime/src/adapter.tsresolved the subagent extension via../../extensions/subagent/entrypoint.tsrelative toruntime/dist/adapter.js. The path was valid only in the source-tree layout (whereextensions/is a sibling ofruntime/); the npm package shipped justdist/so the file didn't exist afternpm install. ADL specs that declaredspec.subagentswould fail at Pi load with "extension not found." Caught by codex pass 3 of slice 4.2.
-
runtime/scripts/copy-vendored-extensions.mjs— new build step that copiesextensions/subagent/intoruntime/dist/extensions/subagent/. Runs as part ofnpm run build, so the file is part of the published tarball. -
runtime/src/adapter.ts— subagent-extension path resolution now probes two candidate locations:<adapter.js>/extensions/subagent/entrypoint.ts(in-package; used after npm install)<adapter.js>/../../extensions/subagent/entrypoint.ts(source tree; used when running from a clone)
Falls back to the in-package path if neither file exists (Pi then surfaces a clear file-not-found at session start, matching pre-v0.3.2 behavior).
-
@agent-controller/runtime-opencodeversion bumped to 0.3.2 to match the umbrella tag's version-gate (no functional change in the opencode adapter itself).
0.3.1 — 2026-06-05
Self-contained install. Closes the "downloaded binary works standalone" gap called out as a v0.3.0 known limitation.
- npm-published runtime adapters —
@agent-controller/runtime(Pi) and@agent-controller/runtime-opencode(opencode). Each ships its builtdist/, README, and LICENSE — no source, no test scaffolding (exceptruntime/dist/testing/fake-provider.*which is part of the public API for hermetic downstream tests). Tarball sizes 27.7 kB / 36.9 kB. publish-npmworkflow job (.github/workflows/release.yml) — runs in parallel with the GitHub Release job, builds each adapter, assertspackage.jsonversion matches the release tag (catches forgot-to-bump cases), and publishes withnpm publish --access public. Gated onNPM_TOKENrepo secret — skipped gracefully when the secret is unset (forks, binary-only releases). Pre-release tags (with a hyphen) skip the publish for now.- New install path documented:
npm install -g @agent-controller/runtime+AGENT_CONTROLLER_RUNTIME="$(npm root -g)/@agent-controller/runtime/dist/index.js" agentctl run .... Surfaced inREADME.mdQuick Start and in the release-body template.
runtime/package.json+runtime-opencode/package.json— droppedprivate: true; addedpublishConfig.access: public,files: ["dist", "README.md", "LICENSE"],repository.{type, url, directory},homepage,bugs,license: MIT,description,keywords,engines.node: ">=22"; bumpedversionto0.3.1.runtime/README.md+runtime-opencode/README.md— rewritten as npm landing pages: install/use sections lead, all repo links converted to absolute GitHub URLs (relative../docs/links don't resolve on npmjs.com), cross-package references use npmjs.com URLs.- LICENSE — copied into each package directory (npm includes it in the tarball; npm's convention is per-package).
- Release-body template in
.github/workflows/release.yml— removed the "What's NOT in this release: adapters not bundled" section; added an npm-install section pointing at the new packages. Source-clone + build is now documented as a fallback path (forks, offline use) rather than the recommended one.
(Filled in at slice commit time.)
0.3.0 — 2026-06-04
The Runtime Abstraction release. ADL now declares not just what the agent needs (spec.runtime.type + spec.runtime.requirements) but the new RuntimeBinding resource kind separately declares what each execution target provides. The Backend.Resolve() step matches the two before running. This is the K8s-style separation between PodSpec intent and runtime/scheduler enforcement that the architecture memo committed to — see ROADMAP.md.
Mostly non-breaking for v0.2.x specs: Bindings, requirements, and the matcher are all opt-in — specs that do not use them continue to compile and run identically. Two new compile-time rejections do break the v0.2 status quo for specs that relied on them:
- Pi built-ins with
spec.tools[].config(silent no-op pre-v0.1.11 — now a compile error pointing at@gotgenes/pi-permission-system). runtime.type: local-opencodecombined withspec.extensions[],spec.installs[], or custom Pi-extension tools (formerly failed at adapter startup — now failed at compile).
Upgrade path for both: see the "Added — compile-time adapter compatibility checks" and "Added — spec.tools[].config rejection" sections below.
schemas/runtimebinding.v1alpha1.json— new top-level kindRuntimeBinding.metadata.name+spec.selector.{runtimeType, capabilities}+spec.target.{type, runtimeCommand, strict}. Lives in the sameagent-controller.dev/v1alpha1namespace as Agents.agentctl validatedispatches bykind:— bothAgentandRuntimeBindingdocuments validate against the right schema. Unknown kinds produce a clear error naming the supported set.agentctl run --binding <path>— loads + validates a RuntimeBinding YAML and passes it to the resolver. Without--binding, runs flow throughBackend.Resolve(spec, nil)— exact v0.2.x default behavior.spec.target.runtimeCommand— overrides the cwd-relative runtime-adapter lookup per-Binding (equivalent toAGENT_CONTROLLER_RUNTIMEbut scoped). When set, the cwd-relative staleness check is skipped.- Example bindings:
examples/bindings/local-default.yaml(warn-but-proceed) andexamples/bindings/local-strict.yaml(target.strict).
spec.runtime.requirements— additive free-form boolean map on Agent specs. Reserved well-known keys:streaming,sandbox,gpu,restrictedNetwork,ephemeralFilesystem. Arbitrary keys accepted so capability bundles can advertise their own (e.g.spark,notebookContext).Backend.Resolve()+ResolvedRunSpec— new two-phase Backend interface. Resolve comparesspec.runtime.requirementsagainst the Binding'sselector.capabilities; Submit takes the resolved shape.- Selector check —
binding.spec.selector.runtimeTypemust equalspec.runtime.type. Mismatch is a hard error regardless of strict mode (wiring bug, not capability gap). - Warn-but-proceed default policy — unmet
requirement: trueentries emit one wirewarningevent each (kind: unmet_runtime_requirement, sorted alphabetically for stable output). The run proceeds. Default per ROADMAP "Recorded design decisions". spec.target.strict: true— opt-in per-Binding: promotes unmet requirements from warn-but-proceed to a hard error before anysession.startedevent.
- The compiler now rejects three opencode-incompatible spec shapes at
agentctl compiletime (previously they failed at adapter startup):- non-empty
spec.extensions[] - non-empty
spec.installs[] - custom Pi-extension tools in
spec.tools[](withentrypointset,builtinfalse)
- non-empty
- All three problems are surfaced together in one error. The runtime adapter retains the same checks as defense-in-depth.
agentctl runalso rejects--resume <id>+runtime.type: local-opencodeupfront (the session id is applied to the spec afterparseValidateCompile, so the check lives in the CLI rather than the compiler).- Closes Phase 2 follow-up #6 from the harness matrix.
- The Pi adapter previously silently accepted
configonbash/read/edit/writeand dropped it at runtime (built-ins don't readAGENT_CONTROLLER_EXT_CONFIG). The compiler now rejects this at compile time with a clear pointer at the supported allowlist path:@gotgenes/pi-permission-systemas aspec.extensions[].source: npm:entry. Seeexamples/bash-allowlist.yaml.
docs/features.md— verify-each-feature-locally walkthrough.go install github.com/CCDevelopForFun/agent-controller/cli/cmd/agentctl@v0.3.0works via thecli/v0.3.0Go-submodule tag (mirroring the umbrella tag).- CHANGELOG.md — this file.
- GitHub repo metadata — description, homepage, topics (
agent,ai-agents,adl,llm,anthropic,opencode,pi,control-plane,declarative,agentic).
Durable entries in ROADMAP.md for two upcoming features so future contributors don't re-litigate:
- Tracing track (v0.5+): three-layer OpenTelemetry design — CLI-side wire-event converter (adapter-agnostic), Pi-side via
@raindrop-ai/pi-agent, opencode-side viaexperimental.openTelemetry: true. Shared trace ID via OTel propagation. - Sandboxing track: enforcement lands on the Kubernetes target in v0.4 (Pods provide the primitives — SecurityContext, NetworkPolicy, emptyDir; honest about vanilla NetworkPolicy's lack of FQDN filtering);
LocalBackendfallback viasandbox-exec/unshare+seccomp ships in v0.5+.
- ADL schema (
schemas/adl.v1alpha1.json):spec.runtime.requirementsadded (additive). Field descriptions updated to describe v0.3.3 matcher behavior honestly. - Backend interface (
cli/internal/backend/):Submit(ctx, spec)→Resolve(ctx, spec, binding) + Submit(ctx, run). Internal-only change (no external implementers existed). agentctl validatenow dispatches bykind:. Documents withkind: RuntimeBindingvalidate against the new schema.
agentctl validate(schema-only) does NOT run the compile-time adapter-compatibility checks — onlyagentctl compiledoes. CI gates that stop atvalidatewill still pass specs with incompatible fields.requirementsenforcement is best-effort today: the matcher emits warnings or errors based on what the Binding advertises, but no Backend actually enforces capabilities likesandbox: trueorrestrictedNetwork: trueend-to-end. v0.4 Kubernetes target ships real enforcement via Pod primitives.- Opencode adapter feature gaps remain. Where they're caught is now precise:
spec.extensions[],spec.installs[], and custom Pi-extension tools combined withruntime.type: local-opencode— rejected byagentctl compile(v0.3.4).--resume <id>withruntime.type: local-opencode— rejected byagentctl run(the resume id is applied after compile, so this check lives in the CLI).- Built-in tools with
spec.tools[].config— rejected globally byagentctl compilefor both adapters (v0.1.11 carryover, not opencode-specific). Pi previously accepted these and dropped them silently at runtime; opencode never supported them.
- GitHub Release artifacts still ship
agentctlonly; runtime adapters require source clone +npm build. npm-publishing the adapters is on the v0.4 roadmap.
0.2.0 — 2026-06-03
First multi-adapter release. ADL specs now run on either the legacy Pi adapter or the new opencode adapter (built on @opencode-ai/sdk) by setting spec.runtime.type. The NDJSON wire protocol is the unified contract.
- opencode adapter (
runtime-opencode/) — full session dispatch viacreateOpencode()+session.promptAsync+ SSE event translation. Skills inlined into the system prompt, subagents via opencode's nativecfg.agentmap withmode: "subagent", MCP servers via opencode's nativecfg.mcp. Opencode's own native agents (plan,build,general,explore,scout) are explicitly disabled so thetasktool can't bypass the ADL allowlist. - ADL
runtime.typeselector:local,local-pi,local-opencode(schema enum). The legacylocalvalue remains a backwards-compatible alias forlocal-pi. - Harness capability matrix (
docs/architecture/harness-matrix.md) — every ADL surface mapped to ✅/⚠/❌ per adapter. Phase 2 acceptance gate: zero🚧cells in the opencode column. - Dual-adapter E2E harness:
ADAPTER=pi|opencode ./e2e/run.sh. - Release-hardening docs:
ROADMAP.md,SECURITY.md,CONTRIBUTING.md,docs/versioning.md, per-subdirectory READMEs (cli/,runtime/,runtime-opencode/,schemas/).docs/features.md(verify-each-feature-locally walkthrough) was added post-tag — see Unreleased. - Automated cross-platform release workflow (
.github/workflows/release.yml) — buildsagentctlbinaries for{darwin,linux}-{amd64,arm64}+windows-amd64, runs the full Go + Pi + opencode test suite, publishes a GitHub Release withschemas.zipandchecksums.txt. Prerelease tags (containing-) are marked as such; stable tags getmake_latest: true. - (Post-tag)
go install github.com/CCDevelopForFun/agent-controller/cli/cmd/agentctl@v0.2.0resolves via thecli/v0.2.0Go-submodule tag (mirrors the umbrellav0.2.0tag). See Unreleased.
- Pi adapter (
runtime/): cumulative bug fixes and the addition of the slice 1.1 hermetic E2E harness (runtime/src/e2e/runsession-fake.test.ts). No breaking changes to existing v0.1.x specs. - ADL schema (
schemas/adl.v1alpha1.json):spec.runtime.typeenum extended withlocal-piandlocal-opencode(additive —localstill accepted). - CLI dispatch (
cli/cmd/agentctl/main.go::resolveRuntimeCommand): routes toruntime-opencode/dist/index.jswhenruntime.type: local-opencode.
Tracked in docs/architecture/harness-matrix.md as v0.3+ follow-ups:
- GitHub Release binaries ship
agentctlonly — the runtime adapters (runtime/,runtime-opencode/) still require a source clone +npm install+npm run build. npm-publishing the adapters is on the v0.3 roadmap. - Opencode hermetic E2E pending (needs an Anthropic-API-compatible mock server reachable via
ANTHROPIC_BASE_URL). - Opencode
--resume <id>is rejected at startup; resume support deferred. - Opencode-unsupported-field rejections happen at adapter startup, not at
agentctl compile/validatetime. - Pi adapter still uses plain
{}objects forspec.mcpServers[].name-keyed maps (no prototype-pollution hardening); the opencode adapter has it. - Opencode adapter + custom-provider configurations (e.g. a corporate LLM proxy with provider-prefixed models) — our
XDG_CONFIG_HOMEisolation hides the user's provider blocks, so specs that need a non-default provider currently can't reach the right model. Design fix is part of v0.3 RuntimeBinding work.
This release went through approximately 70 codex review passes across slices 1.1 → 2.7. Pass details live in the per-slice commit messages.
0.1.10 — 2026-06-01 (retrospective)
The last single-adapter release. Hardened the Pi adapter with the three-mode hallucination guardrail (block / warn / correct), the dist-staleness check, and the connection-flow documentation.
The v0.1.x cumulative v0.1 tag bundles the original MVP plus session resume (v0.1.1), Skills (v0.1.2 / v0.1.7), Subagents (v0.1.3), MCP servers (v0.1.5), auto-install (v0.1.6), and Pi built-in tools (v0.1.9). See git log v0.1.0..v0.1.10 for details.