Skip to content

ACP Subprocess

Alan Wizemann edited this page Apr 20, 2026 · 4 revisions

ACP Subprocess

ACP — Agent Client Protocol — is Hermes's chat protocol: JSON-RPC 2.0 over stdio. Scarf's Rich Chat surface speaks ACP end-to-end. There is no SQLite polling involved in chat; tokens, thoughts, tool calls, and permission prompts all stream live from the subprocess.

Subprocess construction

ACPClient is an actor that owns the subprocess and exposes an AsyncStream<ACPEvent>.

The Process is created via transport.makeProcess(executable: "hermes", args: ["acp"]):

  • Local: spawns hermes acp with HermesFileService.enrichedEnvironment() so MCP servers and shell tools find brew/nvm/asdf binaries on PATH. TERM is removed so terminal escapes don't pollute JSON-RPC.
  • Remote: the transport returns /usr/bin/ssh -T <opts> host -- hermes acp. SSH_AUTH_SOCK is inherited so the GUI-launched Scarf reaches the user's ssh-agent. TERM is removed.

-T (no PTY) is critical — without it stdin/stdout would be PTY-cooked and the JSON-RPC framing would break.

Lifecycle

Phase What happens
start() Creates the event stream first, builds and configures the Process, attaches pipes, installs the termination handler, calls proc.run(), starts read loops for stdout/stderr, sends initialize, starts a 30-second keepalive ping ({"jsonrpc":"2.0","method":"$/ping"}).
newSession(cwd:) / loadSession(cwd:sessionId:) / resumeSession(cwd:sessionId:) Three modes: fresh, load existing, resume after disconnect. Each sends a session/new/session/load/session/resume RPC; updates currentSessionId.
sendPrompt(sessionId:text:) Sends session/prompt with the user text; returns ACPPromptResult with token usage and stop reason. No timeout — streaming may run for minutes. Tokens, thoughts, tool calls, and permission requests arrive as events on the stream while this awaits.
cancel(sessionId:) Sends session/cancel to interrupt an in-flight prompt.
respondToPermission(requestId:optionId:) Sends a JSON-RPC response to an incoming session/request_permission request.
stop() Cancels background tasks, finishes the event continuation, closes stdin (subprocess sees EOF), sends SIGINT, watchdogs to SIGTERM after 2 seconds, closes pipes.

Event stream

Consumers iterate for await event in client.events:

enum ACPEvent: Sendable {
    case messageChunk(sessionId, text)         // assistant token chunk
    case thoughtChunk(sessionId, text)         // reasoning/thinking token chunk
    case toolCallStart(sessionId, call)        // tool invocation began
    case toolCallUpdate(sessionId, update)     // tool invocation finished/updated
    case permissionRequest(sessionId, requestId, request)  // user approval needed
    case promptComplete(sessionId, response)   // session/prompt resolved
    case availableCommands(sessionId, commands)  // /commands the agent advertises
    case connectionLost(reason)
    case unknown(sessionId, type)
}

Events are extracted from incoming JSON-RPC notifications matching method: "session/update". The sessionUpdate discriminator inside params selects the case (agent_message_chunk, agent_thought_chunk, tool_call, tool_call_update, etc.).

Permission requests (bidirectional)

Most chat traffic is agent → client. Permission requests reverse direction — the agent sends an incoming JSON-RPC request with method: "session/request_permission":

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "session/request_permission",
  "params": {
    "sessionId": "...",
    "toolCall": { ... },
    "options": [
      {"optionId": "allow",  "name": "Allow"},
      {"optionId": "deny",   "name": "Deny"}
    ]
  }
}

The client emits ACPEvent.permissionRequest. The UI (Rich Chat) shows a sheet; once the user clicks an option, respondToPermission(requestId: 42, optionId: "allow") sends back:

{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "outcome": {"kind": "allowed", "optionId": "allow"}
  }
}

Internals

  • Read loop runs detached: availableData → buffer → split on \n → JSON-decode each line → handleMessage.
  • Stderr loop captures the subprocess's stderr into a 50-line ring buffer for attaching to user-visible errors.
  • Pending requests dict maps JSON-RPC idCheckedContinuation<AnyCodable?, Error>. Responses resume the matching continuation; the read loop dispatches them by id.
  • 30-second control-message timeout fires for initialize/session/new/etc. There is no timeout on session/prompt — that one streams for as long as the model takes.
  • safeWrite(fd:data:) handles partial writes and EPIPE; used for both prompt sends and keepalive pings.
  • Disconnect cleanup is single-pathed via performDisconnectCleanup(reason). Three callers: stdout EOF (handleReadLoopEnded), process termination (handleTermination), write failure (handleWriteFailed). All three resume pending requests with processTerminated and finish the event continuation.

Error hints

Raw error messages are noisy. ACPErrorHint (in the same file) pattern-matches across the error message and the stderr ring buffer to attach actionable hints:

Pattern matched Hint surfaced
"No credentials found" / ANTHROPIC_API_KEY "Set ANTHROPIC_API_KEY in ~/.hermes/.env"
"No such file or directory" + binary name "Binary not on PATH; check ~/.zprofile exports"
"Rate limit" / 429 "AI provider rate-limited; try again later"

Last updated: 2026-04-20 — Scarf v2.0.1

Clone this wiki locally