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 + ACPChannel
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.
Process channel construction (Mac)
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.
SSH exec channel construction (iOS)
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.
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
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.
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-25 — Scarf v2.5.0 (ScarfCore extraction + ACPChannel abstraction + iOS SSHExecACPChannel section)
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
Wiki edited via the local .wiki-worktree/ clone. See Wiki Maintenance for the workflow. Last sync: 2026-04-20.