One message crosses three representations

ACP agent
   │ typed notification: session/update
   ▼
┌──────────────── protocol connection ────────────────┐
│ validates message shape and session ownership      │
└────────────────────────┬────────────────────────────┘
                         ▼
┌──────────────── client projection ─────────────────┐
│ transcript nodes · plan · config · auth · usage    │
└────────────────────────┬────────────────────────────┘
                         ▼ immutable render snapshot
┌──────────────── terminal renderer ─────────────────┐
│ layout · clipping · scroll · focus · current frame │
└─────────────────────────────────────────────────────┘
A source typed value, an internal event, and a rendered cell are related but not interchangeable. The projection layer is where retries, replacement, and session scoping become deterministic.

Source map

These repository paths are pinned to the verified revision shown above, so each claim and excerpt can be checked against the implementation that the article describes.

PathResponsibility
npm/lib/acp-client.jsCordis ACP client service and connection ownership
npm/lib/cordis-protocol.jsExtension names and capability reader
src/acp.rsTyped ACP lifecycle and request handlers
src/acp_auth.rsForm and terminal authentication state
src/events.rsProtocol update to UI-event projection
scripts/acp-client.test.mjsSpawn, stream, and lifecycle contract tests

Make connection ownership a data type

Martty's Node ACP plugin accepts either config.stream or an agent specification. Stream mode means the caller owns the process and hands the client readable and writable endpoints. Spawn mode means the client owns a child command, its arguments, stderr, shutdown, and replacement. Treating these as separate service kinds prevents both sides from trying to kill the same process—or neither side cleaning it up.

The default executable is dsh-acp, but that is policy at the edge, not an import dependency. resolveAgent can read explicit configuration or DSH_TUI_AGENT; the plugin itself imports no Harness. If the agent specification changes, the old owned child receives SIGTERM before a new one is spawned. If spawn emits ENOENT or EACCES, stream errors are surfaced so pending requests fail instead of hanging forever.

Excerptnpm/lib/acp-client.js
if (config.stream !== undefined && config.stream !== null) {
  provide(ctx, {
    kind: 'stream',
    stdin: config.stream.stdin,
    stdout: config.stream.stdout,
    child: config.stream.child,
  })
  return
}

const agent = resolveAgent(config)
const child = spawn(agent.command, agent.args ?? [], {
  stdio: ['pipe', 'pipe', 'inherit'],
  env: { ...process.env, ...(agent.env ?? {}) },
})

provide(ctx, {
  kind: 'spawn',
  command: agent.command,
  args: agent.args ?? [],
  stdin: child.stdin,
  stdout: child.stdout,
  child,
})

Both branches expose the same semantic service, but kind records who owns lifetime and restart behavior.

stderr is inherited rather than mixed into stdout because stdout must remain a clean ACP transport.

Initialize is a contract, not a greeting

Martty's Rust client builds an official InitializeRequest with implementation name and version, filesystem read/write support, terminal operations, session config options, terminal authentication, and form elicitation. The response is converted into a client Surface: image-prompt support, load-session support, model and mode catalogs, auth methods, and the negotiated Cordis bit all originate here or in later standard updates.

Do not infer capabilities from the command name. A binary named dsh-acp can change its feature set, and another agent may implement more ACP features than expected. A button, slash command, or extension request is legal only when the current connection advertised the required capability. Unknown metadata should remain forward-compatible; a known extension with the wrong protocol version should remain disabled.

A session is more than a transcript array

Creating a session establishes the workspace, configuration surface, and identity used by later prompts, cancellation, permissions, and updates. Loading a session is a different capability and may reconstruct its transcript through subsequent notifications. Martty therefore keeps session identity and connection state outside the scrollback nodes rather than treating the latest visible line as proof of the active server session.

The client projects session/update into several bounded views. Available commands become the skill or command catalog. Config-option updates become model, effort, preset, and mode selectors. Plan updates feed a plan service. Usage and timing feed statistics. Transcript nodes keep stable ids so a running tool call or streaming assistant message can be replaced rather than appended as a duplicate.

Requests and notifications have different timing obligations

A prompt request can remain pending while dozens of session notifications arrive. Martty runs prompt work in a Tokio task so the ACP dispatch loop continues painting updates and answering agent-initiated requests. Cancellation sends session/cancel immediately and does not wait for the original prompt promise to settle. The eventual prompt result is still observed so the client can classify completion, cancellation, authentication failure, or transport loss correctly.

Tool calls also expose a frame-order problem. A fast tool can emit start and completion in the same receive burst. If the renderer draws only after draining the queue, the pending state never appears. Martty marks tool-call transitions as requiring an immediate frame, stops the current receive burst, paints once, and then consumes the result. This is a UI guarantee built on protocol semantics, not an arbitrary animation delay.

Authentication and permission are protocol subflows

The initialize response may advertise environment-backed methods, in-app forms, or terminal authentication. A form-capable method can already have persistent credentials, so startup remains optimistic until session/new proves otherwise. When authentication is required, the client parks the unsent prompt, runs the chosen flow, calls authenticate again to confirm state, then retries the parked prompt only after success.

Terminal auth is deliberately explicit. _meta["terminal-auth"] tells the agent that the method needs an out-of-band child attached to the real TTY. Permission requests are agent-to-client ACP requests tied to a tool call and active session; the client renders the options, returns the selected outcome through the responder, and distinguishes denial from cancellation or agent exit. A local keybinding is never a substitute for that response.

Optional extensions must degrade to nothing

Martty and DSH negotiate _meta.dsh.cordis.protocol = 0. Only then may the client send inspect, run, plugin, or TUI child-domain methods. The JavaScript reader walks the initialize result defensively and returns either { protocol: 0 } or null. The Rust side repeats the check before user-triggered extension operations and reports a UI failure instead of emitting an unsupported request.

This design makes extension absence boring. Core ACP sessions, prompts, updates, cancellation, auth, permissions, and filesystem requests continue. DSH-only plugin controls or semantic shell nodes simply do not appear. That is a stronger compatibility property than catching method not found after optimistic calls, because it keeps server logs clean and prevents a partial vendor feature from mutating client state.

Project state before it reaches pixels

ACP delivers domain facts: content blocks, tool lifecycle, plan entries, usage, config options, and requests. The client converts them into internal events and services. The renderer receives a snapshot and terminal dimensions. This means a resize can recompute wrapping without replaying the network stream, and a restored session can paint the same result even if updates arrived in different batches.

The boundary also contains authority. Server extensions can send validated semantic UI nodes, but they do not receive Ratatui objects, raw-mode access, coordinates, or the global frame loop. Terminal chrome is not encoded inside session/update, and Client plugins cannot fabricate durable assistant events. Each side can evolve as long as the serialized schema remains compatible.

Test the state machine without opening a terminal

Connection and projection logic should be testable with in-memory streams and fixture agents. Spawn tests verify command selection, child reuse, replacement, error handling, and cleanup. Protocol tests feed initialize results with and without capabilities, interleave prompt responses with notifications, and assert that optional methods are gated. Renderer snapshots are a later layer, not the only integration test.

For a live smoke test, martty --check-runtime spawns the configured agent, sends initialize, prints the negotiated name, and exits before entering full-screen mode. It isolates binary discovery, process spawn, pipe integrity, and initialization from session and rendering failures. Only after that passes should a test create a session and assert visible update projection.

npm install --global marttymartty --check-runtimemartty --agent <acp-command> --agent-arg <argument>

Failure modes worth distinguishing

Start with the first check in the same row. Each symptom can look similar on screen, but it belongs to a different ownership or state boundary.

SymptomLikely causeFirst check
A request hangs forever after spawn failureThe child emitted error, but pending stream readers never saw EOFVerify child error handlers destroy stdin/stdout and clear the cached handle
Old tool cards are duplicated on every updateNotifications are printed directly instead of replacing projected nodes by idInspect the session/update reducer and node identity
Cancel freezes until the model returnsThe client awaits the prompt promise before sending session/cancelSend cancellation as an independent notification and observe the pending task separately
Vendor controls fail against a plain ACP agentThe client inferred extensions from the executable nameInspect initialize metadata and gate on Cordis protocol 0
Login succeeds but the parked prompt disappearsAuthentication state and pending composer payload were not modeled togetherTrace authenticate completion and the parked-prompt retry path

Verify transport, capability, and session layers separately

node --test scripts/acp-client.test.mjsmartty --check-runtimemartty --agent <acp-command> --agent-arg <argument>

Expected evidence

  1. Spawn and stream modes expose the same ACP service while retaining different lifetime ownership.
  2. Initialize succeeds before a session is created; optional DSH controls appear only when protocol 0 is advertised.
  3. During a live prompt, session/update can repaint transcript state while the prompt request remains pending, and cancel does not wait for prompt completion.

Common implementation questions

What is the difference between an ACP client and agent?

The client owns interaction, local state projection, and agent-directed requests. The agent owns model execution and tools. ACP is their only required shared contract.

Why not render each JSON message immediately?

Messages describe transitions, not final layout. Projection is required for replacement, session scoping, resize, scroll, and deterministic replay.

Does an ACP terminal client need DSH?

No. Martty defaults to DSH, but standard ACP works without DSH. Cordis extensions are optional and capability-gated.

Why are stderr and stdout treated differently?

stdout carries protocol frames and must stay parseable. Human diagnostics belong on stderr so they cannot corrupt the ACP stream.