research phase 8 c5 acp web demo - sinrimin/harness-nexus GitHub Wiki
Status: reference studied (2026-09). Local copy:
~/acp-ref/(extracted fromweb-demo.7z, 83 entries). This note distills what the demo teaches for C5 (ACP chat) and the future file-management / web-terminal channels. Parent design:design-phase-8-client.mdΒ§ "ACP chat (C5)".
A minimal but complete "browser vibe-coding" loop from an internal demo project, trimmed to three features: chat with an agent (streaming, tool-call visualization, permission approval, session management), a web terminal (xterm.js β node-pty), and file management (file tree + Monaco + git diff).
browser (React) β socket.io '/' Portal (Node :8090) β dumb router + local fs/PTY gateway
Portal β socket.io /proxy acp-bridge (DSH plugin) β maps DSH β ACP, one agent per channel
Key source files (worth re-reading when implementing C5):
| File | Role |
|---|---|
acp-bridge/src/bridge.js (660) |
session lifecycle (new/load/fork/close/delete), prompt single-flight + followup queue, cancel watchdog, permission round-trip, reconnect registration |
acp-bridge/src/updates.js (133) |
pure DSH session-event β ACP session/update mapping β a working first row for our C5 adapter matrix |
acp-bridge/src/portal-client.js (107) |
socket.io back-connection; first-connect vs reconnect distinction |
portal/server.js (631) |
channel registry, envelope routing by channelId room, /api/fs/* gateway, /terminal PTY namespace |
portal/src/hooks/useAcpConnection.ts (1352) |
browser-side: all ACP message consumption, session ops with 90s timeout, optimistic insert reconciliation |
portal/src/model/fold.ts (304) |
pure fold reducer: update stream β conversation rows (O(1) hot path) |
portal/workspace-fs.js (227) |
path clamping (resolve + containment + symlink realpath), git status/diff |
Every WS message is { type, channelId, payload, timestamp }; type IS the
socket.io event name. Two families, exactly our domain:verb split:
-
control-plane β
control.register(agent online; capabilities gate the UI:sessionCapabilities.{list,fork,delete}show/hide buttons),control.session_register({sessionId, cwd}β Portal tracks cwd for IDE/terminal),control.session_status(active|idleβ turn-level busy/idle, sent onturn/start/turn/end),control.permission_request/control.permission_response/control.permission_audit,control.proxy_status/control.agent_status(Portal-generated on bridge connect/disconnect),control.agent_restart. -
data-plane β
acpenvelopes{jsonrpc:'2.0', method, params}:session/{new,list,load,fork,prompt,cancel,close,delete,set_mode, set_config_option}down,session/updateup (streaming).
session/update shapes actually consumed by the frontend:
user_message_chunk | agent_message_chunk | agent_thought_chunk | tool_call | tool_call_update | available_commands_update | plan | usage_update
(plus current_mode_update | config_option_update | session_info_update in
the standard). Tool name rides _meta.claudeCode.toolName; tool kind
(read|edit|execute|search|fetch|other) is derived by regex from the name.
Request/response correlation is method-name + single-flight β no
correlationId. One session op at a time with a 90s timer; the reply is the
same method with either a result object or {error}. Simple, and adequate
because ops are strictly serialized (see pitfalls).
-
ACP as the chat payload dialect. The demo's pragmatic middle is what
our "daemon as adaptation edge" actually wants: browser and server speak
ACP-shaped payloads (the de-facto standard β Zed's
agent-client-protocol: ContentBlocks,
session/updatefamily,requestPermissionoptions), and each daemon-side adapter maps agent-native events β ACP updates.updates.jsis exactly this for DSH β the first proven row of our adapter matrix. Our envelope/auth/rooms stay as designed (chat:*events carrying ACP-shaped params). -
Turn-level status as a control event β their
control.session_status active|idlemaps 1:1 to our plannedagent:state; the UI's "generating" hinge is exactly this, and the demo shows what breaks when it's missing (see cancel watchdog). -
Permission round-trip semantics (load-bearing):
-
optionIdmust be passed through VERBATIM (allow_always | allow | reject); any transformation counts as rejection. - 60s timeout β settle as
cancelled(never leave a dangling waiter); agent-side abort signal also settles. - Every decision emits an audit event (
permission_audit) β matches our AcSession audit requirement.
-
-
Registration on EVERY (re)connect β
control.register+control.session_registerare resent after socket.io auto-reconnect, or Portal/frontend think the agent is dead. (We already do this formachine:hello; chat session state needs the same treatment.) -
Session-op mutual exclusion + prompt single-flight:
- new/load/fork/close are serialized through a session-op promise chain; a prompt arriving mid-switch waits for it (else it lands on the OLD agent, which the switch then disposes β killing the turn).
- Prompt while running: same session β
followupqueue; different session β reject (switching would kill the running turn). - "Load current session" is allowed while running (replay-only, no agent swap); loading a DIFFERENT session is not.
-
Cancel watchdog: upstream abort can hang; 60s after cancel with no
turn/endβ force-rebuild the agent (resume last session) and re-emit idle β otherwise every later prompt latches and the UI spins forever. -
Optimistic session insert + reconciliation:
session/new's backing store lags (the new session isn't listed bysession/listimmediately); the frontend inserts optimistically from the response and keeps pending entries during list reconciliation. -
Streaming UI model (web): fold reducer β pure, deterministic, O(1)
append (concat into last block / upsert tool node by callId), plus a
100ms StreamBuffer to batch chunk floods. Directly reusable in our chat
page. Notable rules: tool row settles the current text step (no flicker);
turn_settledsweeps ALL running steps/tools (idempotent tail); liveuser_message_chunkis suppressed (frontend echoes optimistically) but included during replay. - fs/terminal gateway boundaries (for our future channels): path clamping = resolve + containment + symlink realpath on EVERY op; terminal cwd clamped to workspace; PTY killed on socket disconnect; single PTY per socket; terminal data base64 over WS.
| Demo | Harness Nexus Phase 8 |
|---|---|
| Single user, every endpoint unauthenticated | JWT/PAT auth; machine PAT blast radius; chat additionally gated by machine.remoteChatEnabled (off by default), owner-only, AcSession audit rows, concurrent-session cap |
| Portal = dumb router; browser understands ACP fully | Server stays the routing/authorization point; daemon is the protocol-adaptation edge; browser consumes ACP-shaped payloads over our chat:* events |
| channelId in every envelope, room = channelId | Explicit ids in envelopes + identity binding; rooms are addressing only (chan:<sessionId>) |
onAny passthrough on both sides |
Whitelisted handlers only, every payload zod-validated (our isolation model) |
| One agent process per bridge connection | Daemon hosts a session manager with N sessions; AgentInstance rows make agents addressable platform-side |
| fs/terminal on the Portal itself (host machine) | fs/terminal must ride the daemon on the USER's machine via /ctl routing β never the server |
- permission
optionIdpassthrough; any field mangling = rejection. - reconnect MUST resend register + session_register.
- session switch vs prompt mutual exclusion (adopt kills running turns).
- cancel needs a watchdog; after rebuild, re-emit
idleor the UI hangs. - optimistic session insert after
session/new(store lags). - request/response has no id β strict single-flight is what makes it safe.
- live user-message suppression vs replay inclusion (avoid double render).
- tool-call blocks in assistant messages are ignored β the dedicated
tool/callevent is the single source of truth. - output ordering: updates are serialized through an
outputTailpromise chain sosession/prompt's ack never races ahead of the final chunks. - DSH specifics that will generalize: static system-prompt overlay (template
variables fail in headless mode), per-channel session registry file
(multi-instance sharing one DSH_HOME),
trimToClosedPrefixfor fork seeds (events must be a closed prefix).
The per-target matrix the parent design demanded. Every adapter speaks ACP v1 (JSON-RPC 2.0, newline-delimited) over stdio, which is exactly the daemon's subprocess contract β the daemon never cares which adapter runs.
| Target | Adapter | How to spawn | Notes |
|---|---|---|---|
| claude-code |
@zed-industries/claude-agent-acp (renamed from claude-code-acp; repo) |
npx -y @zed-industries/claude-agent-acp |
Official Zed adapter, now powered by the Claude Agent SDK (bundles the CLI). Requires local Claude auth. Zed blog. |
| codex |
@zed-industries/codex-acp (npm) |
npx -y @zed-industries/codex-acp |
Official Zed adapter wrapping the OpenAI Codex CLI; needs codex on PATH. Codex-in-Zed announcement. |
| hermes | Hermes Agent's native acp_adapter (internals) |
python3 -m acp_adapter |
Ships in Hermes as an install extra (agent-client-protocol); runs Hermes as an ACP server over stdio. Issue #569 framed it as a first-class interface mode. |
| dsh | bridge plugin from this demo (acp-bridge/src/updates.js) |
(demo-specific) | Proven by the reference; not an install target we ship. |
| zcode | none found | β | No ACP adapter exists; zcode also has no install adapter (3.x). Row deferred until one appears. |
Consequences for the daemon (C5): one generic spawn <command> + ACP client
covers every target; only the command table differs. The table lives in code
with per-target env overrides (HN_ACP_COMMAND_<TARGET>), so the fixture
agent used by tests and users with pinned/local adapter installs both work
without code changes.
Wire surface actually needed (from the ACP v1 schema):
clientβagent initialize (β initialized notification), session/new,
session/prompt, session/cancel, session/close; agentβclient
session/update (notification) and session/request_permission (request;
option kinds allow_once | allow_always | reject_once | reject_always,
optionId returned verbatim; a client-side turn cancel MUST answer
cancelled). stopReason: end_turn | cancelled | max_tokens | refusal.
Tool kinds: read | edit | delete | move | search | execute | think | fetch | switch_mode | other; tool statuses: pending | in_progress | completed | failed. fs/*, terminal/*, elicitation/* exist in the schema but are
capability-gated β the daemon does not implement them in v1 (agents that
need them degrade or refuse).
- The prerequisite "per-target ACP adapter matrix" now has one filled row:
DSH β ACP via bridge plugin (proven by this demo's
updates.js/bridge.js). Remaining rows to research: Claude Code (native ACP adapters exist in the Zed ecosystem), Codex (community adapters), Hermes (wrapper needed; hooks model differs), zcode (unknown). β Resolved above: claude-code/codex via official Zed adapters, hermes native, zcode deferred. - C5's daemon session manager should be modeled on
bridge.js's state machine (inflight ctl, sessionOp chain, pendingToolCalls, permissionWaiter, timers set with unified disposal) β but multiplexed per session instead of one agent per connection. - The web chat UI can port fold.ts + StreamBuffer nearly verbatim.
- Permission UI renders from
options(server-declared), never hardcoded allow/reject β matches the "permission prompts render from ACP method names" line in the parent design. - Envelope validation: we add zod schemas for the ACP shapes we accept (session/update family + permission options), keeping the whitelisted-handler isolation rule.
- Running the demo here (needs
dsh+ an LLM gateway; not reproducible in this environment). Findings are from source reading, which the thorough AGENTS.md in the demo corroborates. - Multi-agent orchestration (their source project had it; trimmed out).