Skip to content

feat!: replace the sub-harness system with an Agent Client Protocol client - #89

Merged
mattapperson merged 13 commits into
mainfrom
mattapperson/acp
Aug 14, 2026
Merged

feat!: replace the sub-harness system with an Agent Client Protocol client#89
mattapperson merged 13 commits into
mainfrom
mattapperson/acp

Conversation

@mattapperson

Copy link
Copy Markdown
Owner

Replaces the sub-harness system (5 packages, one vendor SDK per agent) with a client for the Agent Client Protocol. Noetic is the Client; the coding agent is the Agent.

Workspace goes 15 packages → 11, and ~23 sentrux boundary rules → 3.

Why

The old system was a Noetic-invented contract wrapping a different SDK per agent, normalised into a 9-variant stream union. Three consequences:

  • Adding an agent was a core change — the kind was a closed enum baked into Step.kind, the interpreter, and the published JSON Schema.
  • Lossy by construction — permissions, plans, session modes, slash commands, MCP passthrough, multimodal prompts, and terminals had no representation.
  • The agent ran outside Noetic's adapters — nothing passed through ctx.fs/ctx.shell, so there was no interception point at all.

ACP covers all of it, and because the agent asks the client to read files and run terminals, the client becomes the place a boundary can exist.

What's here

One step, open agent set. step.acpAgent({ agent }) replaces four builders; agent is a free-form adapter, so a new agent needs a registry entry, not a framework change.

Three ways to drive an agent — a step, a tool a model can call (acpAgentTool), or a human answering permission prompts over channels (askUserForPermission). Plus listAcpSessions/cancelAcpSession so a UI can watch and steer live sub-agents.

Filesystem confinement, on by default. fs/* is confined to the session cwd, rejecting outside paths, .. traversal, name-prefix siblings, and relative paths. additionalDirectories widens; allowAnyPath opts out.

An audit trail. Every fs/* and terminal/* call an agent makes is emitted as acp_client_activity, allowed and refused — an observed record, not the agent's account of itself.

Explicit session lifetime. session.keepAlive: 'step' | 'run' | 'harness' replaces the inferred onComplete. A reuse key without a scope is an error, not a silent lifetime extension.

Verification

  • 2,090 tests, including 124 in packages/acp that drive the real JSON-RPC wire in both directions via an in-process AgentSideConnection.
  • Live-verified against a real spawned @zed-industries/claude-code-acp over stdio (ACP_LIVE_AGENT=1 bun examples/acp-e2e.ts).
  • Lint, all package typechecks, 372 doc snippets, docs build, check:exports, gen:schema drift gate, sentrux — all clean.

Reviewed adversarially, and it found real things

Three review agents audited this for correctness, protocol conformance, and overclaiming. Everything below was found and fixed in this PR:

  • A dead agent hung the step forever. The protocol library breaks its read loop on end-of-stream without rejecting pending responses, and the connection never read its abort signal.
  • A false sandboxing claim. I had written that routing through Noetic's adapters gave "the same sandboxing and audit as a first-party step". It didn't — an agent read outside the workspace and ran arbitrary commands, and permissions: { default: 'deny' } did not stop it, because fs/* and terminal/* are client methods the agent calls directly, not tool calls it asks about. Fixed by making it true (confinement + audit) and rewriting the claim to say exactly what holds.
  • Session-store defects — anything kept without a reuse key leaked permanently; one run's completion closed a concurrent run's agent mid-turn; parallel steps sharing a key opened two connections and orphaned one.
  • Silent turn corruption — two concurrent prompts on one session fanned notifications into the wrong accumulator.
  • A spec violation — a denied tool answered cancelled, which tells a conforming agent the whole turn was cancelled, killing the step over one refusal.
  • Terminal truncation destroyed output — one over-limit chunk left nothing; and terminal/kill reported a clean exit because the killed status was inferred from a rejection the real adapter never produces.
  • A dead capability gateassertPromptContentSupported had four tests and zero call sites while the docs promised the guarantee.

Several doc claims were corrected rather than deleted: the dependency-graph claim (runtime import graph is clean, the npm tree is not), the ./stdio browser claim, and both headline examples, which used harness.execute() and printed undefined.

Breaking changes

step.claudeCode/codex/opencode/pi and their JSON node kinds are gone — use step.acpAgent({ agent }) and { "kind": "acp-agent", "agent": "..." }. The five @noetic-tools/sub-harness* packages are replaced by @noetic-tools/acp. SubHarness* types → Acp*; HydrationContext.subHarnessesacpAgents. Session teardown is keepAlive rather than onComplete. Agents can no longer read outside the session cwd by default.

A migration guide maps every renamed option and calls out the two changes that aren't renames.

Follow-ups not in this PR

  • The five @noetic-tools/sub-harness* npm packages still need npm deprecate — the local npm session is unauthenticated.
  • opencode and pi ship without presets; their ACP support is unconfirmed. customAcpAgent({ command, args }) reaches them.
  • Remaining lower-severity review findings: terminal lifecycle during teardown, and a stale check-docs count claim.

@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
noetic-web Ready Ready Preview Aug 14, 2026 2:00pm

Request Review

@mattapperson

Copy link
Copy Markdown
Owner Author

Review findings closed out

All 13 correctness findings and all 11 claim findings from the adversarial review are now fixed in this branch. Final pass (031efbbf):

  • Terminal teardown (F12). The connection released terminals before closing the transport and the registry had no disposed guard, so an agent could keep calling terminal/create throughout teardown and orphan a child process — the exact thing closing a connection should prevent. Transport closes first; the registry refuses work once disposed.
  • Connection-scoped settings. A step joining an existing session with a different cwd or mcpServers had them silently dropped, while a mismatched clientCapabilities threw. All three raise now — a step that named different MCP servers was getting none of them with no diagnostic.
  • Event ordering. finalize synthesized tool-call items before closing the assistant message, so output item 0 finished after items 1..n.
  • Negative limit on fs/read_text_file became a negative slice bound, quietly meaning "all but the last N lines".

The one claim nobody had verified

The docs promised ACP output reaches getTextStream() / getReasoningStream() / getItemStream(). Neither reviewer could confirm it end to end. Driven through a real harness:

getTextStream()      : "the answer"
getReasoningStream() : "thinking hard"
getItemStream()      : [message, function_call, ...]

It holds, and there's now a test so it keeps holding.

Verification

2,097 tests (130 in packages/acp), lint, all package typechecks, 372 doc snippets, docs build, check:exports, sentrux, and the live claude-code-acp run over stdio — all green.

Every fix in this branch has a regression test that fails against the pre-fix code; I checked each by reverting the fix and re-running. The liveness suite hangs outright without its fix, which is the original symptom.

Still outstanding

The five @noetic-tools/sub-harness* npm packages need npm deprecate — the local npm session is unauthenticated, so that's a manual step:

npm deprecate "@noetic-tools/sub-harness@*" "Deprecated: replaced by @noetic-tools/acp. See https://noetic.tools/docs/framework/migrating-from-sub-harnesses"

…and the same for -claude-code, -codex, -opencode, -pi.

…ocol client

Coding-agent steps are now built on the Agent Client Protocol (ACP), a
JSON-RPC 2.0 standard in which Noetic is the Client and the coding agent is
the Agent. This replaces the five per-vendor sub-harness packages with one
`@noetic-tools/acp` package and one generic `step.acpAgent`.

The old system was a Noetic-invented contract wrapping a different vendor SDK
per agent, normalising everything into a nine-variant stream union. Adding an
agent meant editing core and the published JSON Schema, because `SubHarnessKind`
was a closed enum baked into both. Permissions, plans, session modes, slash
commands, MCP passthrough, multimodal prompts, and terminals had no
representation and landed in `raw` or were dropped. And a coding agent read
files and ran commands through its own SDK, so none of it passed through
`ctx.fs` / `ctx.shell`.

ACP inverts the control flow: the agent asks the client to read files, write
files, and run terminals, and asks permission before running a tool. Serving
those requests means a sub-agent's file and shell access now flows through
Noetic's own adapters — the same sandboxing, virtual filesystem, and audit path
as a first-party step.

- `packages/types/src/types/acp.ts` holds the contract (`AcpAgent` →
  `AcpAgentConnection` → `AcpSession`, plus `AcpTransport`, `AcpClientHost`,
  and the permission types), re-exporting the protocol types verbatim from
  `@zed-industries/agent-client-protocol` so the wire surface cannot drift.
- `@noetic-tools/acp` owns the protocol client, the client-side `fs/*`,
  `terminal/*`, and permission handlers, the agent presets, and the transports.
  Its main entry is runtime-neutral; the Node stdio transport is behind
  `./stdio`. Core never imports it — one sentrux boundary each way replaces the
  eighteen the sub-harness packages needed.
- Permissions resolve in three tiers — step policy, then steering (as a veto,
  since `beforeToolCall` also returns allow when no hook exists), then an async
  handler — falling back to a default of deny.
- The JSON node is `{ kind: "acp-agent", agent: "<registry key>" }`, an open
  registry: adding an agent touches neither core nor the published schema.

The 74 tests in `packages/acp` drive the real wire protocol in both directions
through an in-process `AgentSideConnection`, covering capability negotiation,
all eight session-update variants, permission round-trips, the `fs/*` and
`terminal/*` callbacks, cancellation, and every stop reason. The previous
adapters were only ever exercised against fake runners that touched no
protocol. No real agent binary is spawned in CI; `examples/acp-e2e.ts` does
that behind `ACP_LIVE_AGENT=1`.

BREAKING CHANGE: `step.claudeCode`, `step.codex`, `step.opencode`, and
`step.pi` are removed, along with the `claude-code` / `codex` / `opencode` /
`pi` step kinds and JSON workflow node kinds. Use `step.acpAgent({ agent })`
and the `acp-agent` node kind instead. The packages `@noetic-tools/sub-harness`
and `@noetic-tools/sub-harness-{claude-code,codex,opencode,pi}` are removed in
favour of `@noetic-tools/acp`. The `SubHarness*` types are replaced by the
`Acp*` contract, and `HydrationContext.subHarnesses` by
`HydrationContext.acpAgents` (keyed by a free-form `agentId` string rather than
a `SubHarnessKind`). Session teardown is `'close' | 'keep'` instead of
`'stop' | 'detach' | 'destroy'`, and the `UNKNOWN_SUB_HARNESS_REFERENCE`
hydration error is now `UNKNOWN_ACP_AGENT_REFERENCE`.

Signed-off-by: Matt Apperson <me@mattapperson.com>
An `AcpAgentConnection` owns a live agent — usually a child process whose
stdio keeps the host's event loop alive. `executeAcpAgent` only closed it on a
failed `session/prompt`; a failure in `session/new`, `session/load`,
`session/set_mode`, or `session/set_model` propagated straight out and left the
agent running. The step did not merely leak: the process never exited.

Found by running `examples/acp-e2e.ts` against a real agent, where
`session/new` failed and the example hung indefinitely instead of reporting the
error.

Both paths now tear a freshly opened connection down before rethrowing. A
reused connection is still left intact for a later step to retry against.

The example's live path now also clears the `CLAUDECODE` marker: Claude Code
refuses to launch nested inside another Claude Code session, and spawning it as
an ACP subprocess is exactly the supported usage.

Signed-off-by: Matt Apperson <me@mattapperson.com>
…ession is reused

Sharing one ACP session across several steps — the documented
"investigate read-only, then fix with edits" pattern — was broken three ways.
All three were silent.

1. Per-step configuration was ignored. The client host carries the permission
   policy, steering hook, async handler, and event sink, but it was built once
   when the connection opened and never rebound. Every step after the first was
   answered with the opening step's policy. `openAcpConnection` compounded this
   by spreading the host into a new object, so even a rebind could not reach the
   client. The host is now passed by reference the whole way down, the client
   reads it at call time instead of snapshotting, and the runtime rebinds it
   before every turn.

2. Output was misattributed. Because the update sink was part of that frozen
   host, a reused session streamed its notifications into the first step's
   already-finalized event bridge, under the first step's id.

3. Kept sessions were never disposed. Nothing emptied the harness's session
   store, so `onComplete: 'keep'` meant "keep forever" — with the stdio
   transport that leaves a child process running whose stdio keeps the event
   loop alive, so the host never exits. Reuse is now scoped to a root run: the
   harness closes whatever it still holds when the run finishes.

Two connection-level settings cannot vary per step, because ACP negotiates
client capabilities once during `initialize` and a connection speaks to one
agent. Requesting different ones from a step joining an existing session now
raises `ACP_SESSION_CAPABILITY_CONFLICT` / `ACP_SESSION_AGENT_CONFLICT` instead
of quietly handing back a session that is not what the step asked for.

Regression tests cover all of it at both levels: the interpreter's rebinding
and run-scoped disposal in core, and the client's live read of the host in
`packages/acp` — the latter is what the spread-copy defeated, and it fails
against the previous code.

BREAKING CHANGE: `AcpClientHost.permissions`, `steerPermission`,
`onPermissionRequest`, and `onSessionUpdate` are no longer `readonly` — the
runtime rebinds them per turn, and any implementation that copies the host
rather than holding it by reference will silently freeze per-step
configuration. `AcpLiveSession` gains required `host` and `agentId` fields.
`session.onComplete: 'keep'` is now scoped to the root run rather than the
lifetime of the harness.

Signed-off-by: Matt Apperson <me@mattapperson.com>
…one outlive its run

An ACP connection owns a live agent — usually a child process whose stdio keeps
the event loop alive — so how long one is kept should be something a step says,
not something the runtime infers. `session.onComplete` inferred it twice over: a
fresh session defaulted to `'close'` and a reused one to `'keep'`, so naming a
reuse key silently extended the lifetime and closing it early meant opting back
out.

`session.keepAlive` replaces it and states the scope directly:

  'step' (default)  closed when the step finishes — nothing is maintained
  'run'             kept for the rest of the root run, then collected
  'harness'         kept until the caller runs `harness.closeAcpSessions()`

`reuse` now requires `'run'` or `'harness'`. A connection closed at the end of
its step has nothing left to share, so a reuse key without a scope raises
`ACP_REUSE_WITHOUT_KEEPALIVE` rather than quietly upgrading the lifetime on the
step's behalf — the step asked for two things and named one.

`'harness'` is new: it makes the long-lived case reachable at all. A harness
serving a conversation can keep one warm coding agent across many
`execute()` calls, which the previous run-scoped collection made impossible.
Ownership transfers with it — nothing closes a `'harness'` session for you, and
`closeAcpSessions()` (now public and idempotent) is how you give it back.

Verified end to end for all three scopes: the default opens a connection per
step and holds none; `'run'` shares one across steps in a run and is collected
after it; `'harness'` survives runs and is released only on disposal.

Signed-off-by: Matt Apperson <me@mattapperson.com>
… a step

`step.acpAgent` puts a coding agent where the step-tree author decided it goes.
Three surfaces were missing for anyone who wants the decision made elsewhere.

**A tool the model can call.** `acpAgentTool()` wraps an agent as a `Tool`, so a
`callModel` step can delegate by calling it — `{ prompt }` in, `{ text }` out,
named `delegate_to_<agentId>` by default. It runs the same `step.acpAgent`
through `harness.run` on the calling tool's context, so the delegated turn lands
in the same item log, usage totals, and event stream as any other step rather
than becoming a side channel. Giving it a `session.reuse` key lets the model
hold a conversation with the agent across calls instead of starting cold.

**Permission requests routed to a person.** A declarative policy can only answer
what it was told in advance. `askUserForPermission()` publishes the request on
an external channel and parks for an answer, using the request/decision shape
`@noetic-tools/chat-sdk` already established for tool approvals: a queue for
requests (each belongs to one reviewer) and a topic for decisions (broadcast,
filtered by `requestId`). The prompt carries what a reviewer needs — agent,
step, thread, tool title and kind, raw input, and the agent's own options, which
a UI should present rather than inventing its own. An unanswered prompt denies
on timeout: waiting must never become approval.

Because reaching a person means reaching a channel, `onPermissionRequest` now
takes `(request, ctx, info)`. The runtime binds the context and the asking
agent/step before handing the handler to the protocol client, which keeps
`Context` out of the client entirely.

**A live-session surface.** `listAcpSessions()`, `getAcpSession()`, and
`cancelAcpSession()` let a UI show running sub-agents — with their mode and
slash commands — and interrupt one. Deliberately read-and-interrupt only: turns
are driven by steps, so a turn started here would bypass the item log, usage
accounting, and the event bridge. Follow-ups go through a step sharing the
`session.reuse` key. The raw session map is now marked internal.

Writing the end-to-end proof caught a real bug in the permission handler: it
published the request and only then parked on the decision topic, but topic
delivery reaches only subscribers parked at send time — so a reviewer who
answered immediately answered into the void, and the agent waited out the full
timeout and was denied something a human had allowed. It now parks first. The
regression test uses a topic-faithful stub that drops undelivered sends, which
is what the more forgiving stub could not catch.

Signed-off-by: Matt Apperson <me@mattapperson.com>
…act a false sandboxing claim

I claimed in four places that routing a sub-agent's file and shell access
through Noetic's adapters gave it "the same sandboxing, virtual filesystem, and
audit path as a first-party step". That was false, and I verified it was false
by running it: with the local adapters an agent read a file well outside the
workspace and ran an arbitrary shell command.

Worse, it did so with `permissions: { default: 'deny' }` — the strictest policy
the API offers. A permission policy answers `session/request_permission`, which
covers the agent's own TOOL CALLS. `fs/read_text_file`, `fs/write_text_file`,
and `terminal/*` are client methods the agent invokes on us directly. An agent
that simply never asks was never gated by any policy, however strict. A reader
of those docs would reasonably have believed otherwise.

`createLocalFsAdapter()` is a bare passthrough to `node:fs/promises`, so there
was no constraint anywhere in the path.

ACP places boundary enforcement on the client, so the fix belongs here rather
than in an adapter a user might or might not supply. `fs/*` paths are now
confined to the session working directory by default, rejecting — before the
adapter is touched — paths outside the roots, `..` traversal out of them,
sibling directories that merely share a root's name prefix (`/workspace-secrets`
is not inside `/workspace`), and relative paths, which the specification forbids
on the wire in the first place. `clientCapabilities.additionalDirectories`
widens the boundary; `allowAnyPath` removes it. `terminal/create` confines its
starting cwd the same way.

The docs, spec, README, and CLAUDE.md now say what actually holds, including
what confinement does NOT stop: symlinks inside the workspace pointing out of
it (the check is lexical and deliberately does not touch the filesystem), and
anything a terminal command does after it starts, since a shell can `cd`
anywhere — `clientCapabilities: { terminal: false }` is the only hard boundary
for shell access. It narrows what a cooperative-but-careless agent reaches; it
is not a sandbox against a hostile one.

Three of the new over-the-wire tests fail against the unconfined client.

BREAKING CHANGE: an ACP agent can no longer read or write outside the session
working directory by default. A host that relied on the previous unconfined
behaviour must set `clientCapabilities.additionalDirectories` or
`allowAnyPath: true`.

Signed-off-by: Matt Apperson <me@mattapperson.com>
… touches

I claimed an "audit path" alongside the sandboxing claim, retracted the word in
e8f4dfa, and never checked whether the capability existed. It did not. The
client served `fs/*` and `terminal/*` and emitted nothing, so the only trace of
what a sub-agent did was whatever it chose to narrate in its own `tool_call`
updates — its account of itself, not an observation of it.

Every client-side call now reports an `AcpClientActivity` to the host, which
the runtime emits as an `acp_client_activity` framework event: the method, the
path or command line, and whether it was served. Refusals are recorded too, and
are the more useful half — a refusal is the moment an agent reached for
something it was not allowed to have, which a log of successes alone would hide
entirely.

Scope is stated rather than implied: this covers the client boundary. Work the
agent does inside its own process, and whatever a terminal command does once
running, are not visible here.

Signed-off-by: Matt Apperson <me@mattapperson.com>
…ore correct

An adversarial review found four defects that share a theme: the runtime
assumed the happy path and had no answer for an agent, or a peer run, behaving
otherwise.

**A dead agent hung the step forever.** The protocol library breaks its read
loop on end-of-stream without rejecting the responses still pending, and
`openAcpConnection` never read the `signal` it was handed. So a missing binary,
a crash on startup, or an OOM mid-turn left `initialize` and `prompt` unsettled
with no error and no timeout — and aborting the context did not help, because
the promise it would race against never settled either. The `AcpConnectError`
for a failed handshake was unreachable in exactly the case it was written for.
Every request now races a transport watcher that turns end-of-stream, and the
abort signal, into a rejection. A deliberate `close()` rejects in-flight work
too, since once the transport is down nothing can ever answer it.

**Anything kept without a `reuse` key leaked permanently.** Registration only
happened when the step named a key, so `keepAlive: 'run'` or `'harness'`
without one was held by nobody — not swept at the end of the run, and out of
reach of `closeAcpSessions()`. That is a child process whose stdio keeps the
host alive forever: the exact failure the previous two lifecycle commits were
written to fix, surviving in the axis their tests never covered.

**One run's completion closed a concurrent run's session mid-turn.** The sweep
took every entry the harness held, with no notion of ownership, and the store
was keyed only by the user's reuse string — so two unrelated runs also shared
one agent's conversation silently. Run-scoped sessions are now keyed and swept
per root run; `'harness'` scope stays global, which is its purpose.

**Two parallel steps sharing a key opened two connections.** Check, await
connect, register left a window where both missed and the second registration
orphaned the first — unreachable, never closed. The lookup and the registration
of the in-flight promise now happen in one synchronous turn; a second caller
awaits the first's connection instead of racing it. `acpAgentTool` made this
materially easier to hit, since a model can emit parallel tool calls.

The store moved into `AcpSessionStore`, which owns keying, ownership, and
teardown rather than leaving them spread across the interpreter and harness.

All five new regression tests fail against the previous code, and the liveness
suite hangs outright without the fix — the symptom itself.

BREAKING CHANGE: `AcpSessionInfo.key` is now an opaque handle rather than the
step's `session.reuse` value, which moved to the new `reuseKey` field.
`getAcpSession`/`cancelAcpSession` accept either.

Signed-off-by: Matt Apperson <me@mattapperson.com>
… and fix terminal truncation

Four more findings from the adversarial review, three of which made a shipped
claim untrue.

**The prompt-capability gate was dead code.** `assertPromptContentSupported`
was defined, exported, and covered by four tests — and called from nothing on a
runtime path. The spec and docs promised an `AcpCapabilityError` "before
anything reaches the wire"; an image sent to an agent that never advertised
image support went on the wire and failed opaquely inside the agent. The
session now enforces it before the turn, with the agent's advertised
capabilities threaded in.

**A denied tool told the agent the whole turn was cancelled.** When no offered
option matched the decision, the client answered `cancelled` — which the
specification defines as *the prompt turn was cancelled*. A conforming agent
aborts the turn and answers `stopReason: 'cancelled'`, which the step converts
to a thrown error: one denied tool killed the entire step. A deny now falls
back to either reject flavour, and a genuinely unusable option set raises a
Noetic-side error instead of lying on the wire.

**Output truncation destroyed the output.** Trimming dropped whole chunks, so a
single chunk larger than `outputByteLimit` left nothing at all — a `npm test`
producing megabytes read back empty with only `truncated: true` as a hint. ACP
asks the client to truncate from the beginning; it now keeps the tail, backing
off to a UTF-8 boundary rather than splitting a character.

**`terminal/kill` reported a clean exit.** The killed status was inferred from
`exec` rejecting, but the shipped local adapter resolves on a signal-driven
abort — only a timeout rejects — so the signal branch was dead against every
real adapter and an agent could not tell a killed command from a normal one.
The registry tracks the kill itself. The test that "proved" otherwise passed
only because the shell double threw on abort, which no production adapter does;
the double now matches the real contract, which is what makes the test mean
anything.

Also: `finalize` no longer leaves an unterminated response when a turn throws —
a UI driven off `getFullStream()` saw `response.created` and waited forever for
a completion that never came. `StepMeta.acpStopReason` records why a turn
stopped, so a caller can finally distinguish a truncated turn from a complete
one; both return normally, and the docs claimed this was already recorded.
A terminal can no longer be created during or after teardown.

All five new tests fail against the previous code.

BREAKING CHANGE: `selectPermissionOption` returns `undefined` rather than a
`cancelled` outcome when no offered option matches; callers must handle it.
`terminal/kill` now reports `SIGTERM` rather than `SIGKILL`, matching the signal
actually sent.

Signed-off-by: Matt Apperson <me@mattapperson.com>
…packages deprecated

Two gaps: the ACP package exported 53 symbols of which 40 appeared in no prose,
and nothing told a reader on the old sub-harness packages that they had been
replaced.

**The public surface is now 30 symbols, every one tagged `@public` and listed
in an API reference.** The 23 removed were incidental exports — item builders,
the turn accumulator, the terminal registry, the permission engine, the
capability and path assertions. They are implementation detail of the client,
and a smaller surface documented completely is worth more than a larger one
documented partly. Tests already imported them by path, so nothing moved.

`assertPromptContentSupported` in particular should never have been public: it
is now called on the real path, so exporting it only invited callers to
duplicate a check the session already performs.

**A migration guide** covers why the shape changed, a field-by-field mapping of
every renamed option, the two semantic changes that are not renames
(`onComplete` → the opt-in `keepAlive`, `permissionMode` → a permission
policy), and what the move buys. It states plainly that a permission policy does
not govern `fs/*` or `terminal/*`, since someone translating `permissionMode`
would otherwise assume it does. It also explains why opencode and pi have no
preset, and shows the one-liner that reaches them anyway.

The npm packages themselves are not deprecated by this commit — that is a
registry action against published artefacts, and it needs a deliberate decision
rather than a side effect of a docs change.

Signed-off-by: Matt Apperson <me@mattapperson.com>
…eview found false

**Two concurrent turns on one session corrupted both.** ACP notifications carry
no turn id, and `activeTurn` was a single field, so a second prompt overwrote it
and every update fanned into the newest accumulator — the first turn lost its
tail and the second gained it. Silent, with no error and nothing either caller
could detect. Reachable whenever two steps share a `session.reuse` key under
`inParallel`, and easier still now that a model can emit parallel
`acpAgentTool` calls.

Turns are now serialised rather than rejected: an ACP session is one
conversation, so sequencing is what it means anyway, and both callers get a
correct answer. A failed turn does not poison the ones queued behind it.

**Capability comparison raised false conflicts.** Comparing raw JSON made two
spellings of the same policy conflict — a different key order, or an omitted
capability against the default it resolves to — telling an author to make two
identical policies identical. Compared field by field after normalisation now.

Claims the review found untrue, corrected rather than deleted where the honest
version is still worth stating:

- Both headline examples used `harness.execute()`, which returns `void`. The
  first thing a reader copied printed `undefined`. Now `harness.run(...)`.
- "No protocol library enters core's dependency graph" was false as written.
  The *runtime import graph* is genuinely clean — the emitted `types` dist has
  no import of the protocol package, only erased type re-exports — but the npm
  tree does contain it, since `types` declares it a real dependency. Both halves
  are now stated, including in the sentrux `reason`, which a source-import check
  cannot prove.
- The `./stdio` subpath does not keep the module out of a browser bundle: the
  dynamic `import()` uses a static specifier, so bundlers resolve it. The
  runtime property is real and still worth having; the bundling claim was not.
- Withdrawing `writeTextFile` does not make a step read-only — a shell redirect
  through `terminal/create` writes just as well.
- The `user_message_chunk` comment claimed the replayed history "is already in
  the item log". True for a session this run started; false for a `session.load`
  of one it never ran, where that history is genuinely not captured.

Also: a drift test now compares the hand-maintained Zod copies of `ToolKind`
and `McpServer` against the upstream package, so an added variant fails a test
instead of becoming silently unrepresentable in a workflow document. And
`AcpSessionStore` moved from `harness/` to `runtime/` — the interpreter imports
it, and sentrux correctly rejected that layer edge.

Both concurrency tests fail against the previous code.

Signed-off-by: Matt Apperson <me@mattapperson.com>
The last of the adversarial review, plus the one documented claim neither
reviewer could confirm.

**Terminal teardown.** Releasing terminals used to snapshot the list with no
guard while the connection released them *before* closing the transport, so an
agent could keep issuing `terminal/create` throughout teardown and orphan a
child process — precisely what closing a connection is supposed to prevent.
The transport now closes first and the registry refuses work once disposed;
three tests cover the guard, the kill-on-release, and the connection-level path.

**Connection-scoped settings now fail consistently.** A step joining an existing
session with a different `cwd` or `mcpServers` had them silently dropped, while
a mismatched `clientCapabilities` threw — so a step naming different MCP servers
simply got none of them, with no diagnostic. All three raise now.

**Event ordering.** `finalize` synthesized tool-call items *before* closing the
assistant message, so output item 0 finished after items 1..n — interleaved
output to any consumer tracking `outputIndex`.

**A negative `limit` on `fs/read_text_file`** became a negative slice bound,
quietly meaning "all but the last N lines". Out of schema either way, but a
plausible-looking answer to a malformed request is worse than an empty one.

**The stream claims are now verified, not assumed.** The docs promised ACP
output reaches `getTextStream()`, `getReasoningStream()`, and `getItemStream()`;
neither reviewer could confirm it end to end. Driven through a real harness, all
three carry it. The "a turn always emits its output" wording was also true only
on the success path until the previous commit added the error bracket — it now
says which reason each path completes with, since the failure case is the one
that matters to a UI.

Both the ordering and bracket tests fail against the previous code.

Signed-off-by: Matt Apperson <me@mattapperson.com>
…tion live

Two of the three loose ends from the PR, both closed by actually running the
thing rather than reasoning about it.

**opencode and pi now have presets, verified against the real binaries.** Both
were previously reachable only through `customAcpAgent` on the theory that their
ACP support was unconfirmed. It is now confirmed: I ran an `initialize`
handshake against each and both answered protocol version 1.

- `opencode()` runs the official `opencode acp` command — native ACP,
  `agentInfo.name: "OpenCode"`, advertising http/sse MCP and image/embedded
  content.
- `pi()` goes through the community `pi-acp` adapter (the same bridge Zed's
  registry points at), since pi has no native ACP mode yet. `agentInfo.name:
  "pi-acp"`, advertising loadSession and image. The docs say plainly that this
  preset depends on a third-party adapter, so a failure there is not a Noetic
  bug.

**Model-driven delegation is verified against a real model.** `examples/acp-e2e.ts`
gains Path I: a live Claude Sonnet 4.5 (via OpenRouter) with `acpAgentTool` in
its tool set, delegating to a real spawned claude-code-acp agent. The model
called the tool, the agent answered `DELEGATED_OK`, and the model relayed it —
the one integration the loopback tests structurally cannot cover, since it needs
a real model deciding to call the tool. Gated on both `ACP_LIVE_AGENT=1` and
`OPENROUTER_API_KEY`.

Docs, README, spec, migration guide, and the agent-builder skill now list all
six presets and drop the "no preset / unconfirmed" language for opencode and pi.

Signed-off-by: Matt Apperson <me@mattapperson.com>
@mattapperson
mattapperson merged commit eb4710d into main Aug 14, 2026
6 checks passed
@mattapperson
mattapperson deleted the mattapperson/acp branch August 14, 2026 14:01
github-actions Bot pushed a commit that referenced this pull request Aug 14, 2026
## @noetic-tools/types-v3.2.2 (2026-08-14)

* feat!: replace the sub-harness system with an Agent Client Protocol client (#89) ([eb4710d](eb4710d)), closes [#89](#89)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version @noetic-tools/types-v3.2.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

github-actions Bot pushed a commit that referenced this pull request Aug 14, 2026
## @noetic-tools/context-v1.2.2 (2026-08-14)

* feat!: replace the sub-harness system with an Agent Client Protocol client (#89) ([eb4710d](eb4710d)), closes [#89](#89)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version @noetic-tools/context-v1.2.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

github-actions Bot pushed a commit that referenced this pull request Aug 14, 2026
## @noetic-tools/acp-v1.0.0 (2026-08-14)

* feat!: replace the sub-harness system with an Agent Client Protocol client (#89) ([eb4710d](eb4710d)), closes [#89](#89)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version @noetic-tools/acp-v1.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

github-actions Bot pushed a commit that referenced this pull request Aug 14, 2026
## @noetic-tools/core-v5.4.1 (2026-08-14)

* feat!: replace the sub-harness system with an Agent Client Protocol client (#89) ([eb4710d](eb4710d)), closes [#89](#89)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version @noetic-tools/core-v5.4.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

github-actions Bot pushed a commit that referenced this pull request Aug 14, 2026
## @noetic-tools/eval-v1.0.2 (2026-08-14)

* feat!: replace the sub-harness system with an Agent Client Protocol client (#89) ([eb4710d](eb4710d)), closes [#89](#89)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version @noetic-tools/eval-v1.0.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

mattapperson added a commit that referenced this pull request Aug 19, 2026
main moved 15 commits ahead while this branch was in review, including two
breaking changes: the sub-harness system was replaced wholesale by an ACP
client (#89), and the public API was renamed (#68). Eleven of the files this
branch touches also changed there, so this is a real integration rather than a
fast-forward.

Resolutions:

- `.sentrux/rules.toml` — main deleted the sub-harness section outright. Took
  main's file and re-applied only the agent-plugins layer + boundaries, with
  the rationale reworded against the ACP client, which is now the package that
  establishes "no protocol library or vendor SDK in core's graph".
- `release-packages.yml` — the five sub-harness jobs are gone. The chain is now
  types → context → acp → core → openui → agent-plugins → eval, which keeps
  this package's `needs: openui` and eval's dependency on it intact.
- `ci.yml` — kept both sides: main's new root-examples typecheck and this
  branch's agent-plugins typecheck/test steps.
- Root `package.json`, `bun.lock`, `tsconfig.kiira.json` — took main's package
  set and re-added agent-plugins. The lockfile was regenerated rather than
  hand-merged.
- `CLAUDE.md`, `specs/00-overview.md` — main rewrote both dependency graphs.
  Re-added agent-plugins alongside acp, which is where it belongs: both depend
  only on types and are composed in by a host.
- Docs `meta.json` — every context-layer page was renamed by #68
  (`static-content` → `instructions`, `file-reference` → `filesystem`, and so
  on). Took the new list and re-inserted the agent-plugins page.

Two API renames from #68 reached this branch's own documentation, both caught
by the docs typechecker rather than by review: `AgentHarnessOpts.context` is
now `contextLayers`, and `llm` is now `callModelDefaults`. Fixed in the docs
page and in the agent-builder skill. The package's own source needed no
changes — every type it imports from `@noetic-tools/types` survived the rename.

Verified on the merged tree: lint clean, every package typechecks, the whole
workspace suite passes with zero failures, doc snippets typecheck, the built
package imports under plain Node, and `sentrux check` now reports **all rules
pass** — main resolved the three violations this branch had been carrying as
pre-existing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant