research phase 8 c5 acp web demo - sinrimin/harness-nexus GitHub Wiki

Research: Phase 8 C5 β€” ACP web-demo reference (web-vibecoding-demo)

Status: reference studied (2026-09). Local copy: ~/acp-ref/ (extracted from web-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)".

What it is

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

Protocol anatomy (envelope)

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 on turn/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 β€” acp envelopes {jsonrpc:'2.0', method, params}: session/{new,list,load,fork,prompt,cancel,close,delete,set_mode, set_config_option} down, session/update up (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).

What C5 should adopt (candidate decisions)

  1. 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/update family, requestPermission options), and each daemon-side adapter maps agent-native events β†’ ACP updates. updates.js is 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).
  2. Turn-level status as a control event β€” their control.session_status active|idle maps 1:1 to our planned agent:state; the UI's "generating" hinge is exactly this, and the demo shows what breaks when it's missing (see cancel watchdog).
  3. Permission round-trip semantics (load-bearing):
    • optionId must 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.
  4. Registration on EVERY (re)connect β€” control.register + control.session_register are resent after socket.io auto-reconnect, or Portal/frontend think the agent is dead. (We already do this for machine:hello; chat session state needs the same treatment.)
  5. 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 β‡’ followup queue; 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.
  6. 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.
  7. Optimistic session insert + reconciliation: session/new's backing store lags (the new session isn't listed by session/list immediately); the frontend inserts optimistically from the response and keeps pending entries during list reconciliation.
  8. 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_settled sweeps ALL running steps/tools (idempotent tail); live user_message_chunk is suppressed (frontend echoes optimistically) but included during replay.
  9. 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.

What we do differently (locked, non-negotiable)

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

Pitfall checklist (verbatim-critical, from their AGENTS.md + code)

  • permission optionId passthrough; 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 idle or 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/call event is the single source of truth.
  • output ordering: updates are serialized through an outputTail promise chain so session/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), trimToClosedPrefix for fork seeds (events must be a closed prefix).

Adapter matrix (completed 2026-09 β€” the C5 prerequisite)

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).

Impact on the C5 plan

  • 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.

Out of scope of this note

  • 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).
⚠️ **GitHub.com Fallback** ⚠️