-
-
Notifications
You must be signed in to change notification settings - Fork 39
ACP Subprocess
ACP — Agent Client Protocol — is Hermes's chat protocol: JSON-RPC 2.0 over stdio (or its bidirectional equivalent). 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 channel.
ACPClient is an actor that owns whatever-it-is that ferries JSON-RPC bytes back and forth, and exposes an AsyncStream<ACPEvent>. The "whatever-it-is" is abstracted behind the ACPChannel protocol so Mac and iOS share the client without #if os(...):
| Channel | Where it lives | What it wraps |
|---|---|---|
ProcessACPChannel |
ScarfCore | A Foundation Process running hermes acp (local) or ssh -T host -- hermes acp (Mac remote). |
SSHExecACPChannel |
ScarfIOS | A Citadel SSH exec channel (no Foundation Process available on iOS) — bidirectional stdin/stdout streams over the SSH transport. |
ACPClient consumes whichever channel the host provides at init; from there the protocol handling is identical.
ProcessACPChannel is created via transport.makeProcess(executable: "hermes", args: ["acp"]):
-
Local: spawns
hermes acpwithHermesFileService.enrichedEnvironment()so MCP servers and shell tools find brew/nvm/asdf binaries onPATH.TERMis removed so terminal escapes don't pollute JSON-RPC. -
Remote: the transport returns
/usr/bin/ssh -T <opts> host -- hermes acp.SSH_AUTH_SOCKis inherited so the GUI-launched Scarf reaches the user's ssh-agent.TERMis removed.
-T (no PTY) is critical — without it stdin/stdout would be PTY-cooked and the JSON-RPC framing would break.
SSHExecACPChannel opens a Citadel exec channel against the user's configured Hermes host, runs hermes acp over it, and surfaces the channel's inbound / outbound byte streams as the same stdin/stdout abstraction ACPClient consumes from Process. There's no PTY allocation — Citadel's exec is binary-clean by default.
Because iOS's PATH is stripped on non-interactive SSH (Citadel doesn't source rc files — see Transport Layer § CitadelServerTransport), the channel inlines PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" in front of hermes acp exactly the way runProcess does. Same fix, same reason.
| 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. |
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.).
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"}
}
}-
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
id→CheckedContinuation<AnyCodable?, Error>. Responses resume the matching continuation; the read loop dispatches them byid. -
30-second control-message timeout fires for
initialize/session/new/etc. There is no timeout onsession/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 withprocessTerminatedand finish the event continuation.
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-25 — Scarf v2.5.0 (ScarfCore extraction + ACPChannel abstraction + iOS SSHExecACPChannel section)
Wiki edited via the local .wiki-worktree/ clone. See Wiki Maintenance for the workflow. Last sync: 2026-04-20.
Getting Started
ScarfGo (iOS)
User Guide
- Dashboard
- Insights & Activity
- Chat
- Slash Commands
- Memory & Skills
- Projects & Profiles
- Project Templates
- Template Catalog
- Template Ideas
- Platforms / Personalities / Quick Commands
- Servers & Remote
- MCP, Plugins, Webhooks, Tools
- Gateway / Cron / Health / Logs
Architecture
- Overview
- Core Services
- Design System
- Data Model
- Transport Layer
- ScarfCore Package
- Sidebar & Navigation
- ACP Subprocess
Developer Guide
Reference
Troubleshooting
Contributing
- Contributing
- Wiki Maintenance
- ScarfGo Roadmap (dev reference)
Release History
Legal & Support