Imported from jsdf/many (
src/web/claude-ui/AGENTS.md). Install upstream withnpx skills add jsdf/many --skill claude-ui. Copyright stays with the author.
Claude UI (CLI-backed sessions)
Live Claude sessions backed by @libclaude/core's ClaudeSession are hosted
in the terminal daemon (src/daemon/claude-ui-manager.ts's ClaudeUiManager),
the same detached process that already owns terminal PTYs and headless
many agent sessions — so a Claude UI session survives the web server /
Electron app closing and is still there when it reconnects. ClaudeUiService
(service.ts) is now a thin async RPC client over the daemon
(TerminalManagerClient); it no longer owns any ClaudeSession itself.
ClaudeUiManager spawns one long-lived
claude -p --input-format stream-json --output-format stream-json --verbose
process per session and treats each prompt as a turn in the same conversation.
It maps raw CLI events to ClaudeUiEvents (protocol.ts) and broadcasts them to
subscribers (over the daemon socket) with a replay buffer for reconnects.
stream-json control protocol
Everything is newline-delimited JSON over the child process's stdin/stdout. Two message families flow over the same pipes:
- Transcript events (CLI -> us):
system/init,assistant,user,result. Mapped inmapClaudeEvent. - Control messages (bidirectional):
control_request/control_response.
Verified against the Agent SDK source
(node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs). The SDK is just a
wrapper over the same CLI invocation, so anything it does is reproducible by
reading/writing JSON lines ourselves.
Direction matters
- We -> CLI
control_requests (already implemented inlibclaude/packages/core/src/session.ts):interrupt,set_permission_mode,generate_session_title. We send these and match the CLI'scontrol_responsebyrequest_id(handleControlResponse). - CLI -> us
control_requests (NOT yet handled): the CLI asks us something and waits for ourcontrol_response. The readline loop currently drops these.can_use_tool(permission prompts) is the important one.
Permission modes: DONE
setPermissionMode sends control_request / set_permission_mode. Covers the
full settable set: default, acceptEdits, plan, bypassPermissions
(plus our auto default). Mode also re-applies on respawn after a crash.
Accept / reject individual changes (can_use_tool): NOT WIRED
This is the per-tool approval prompt. It is fully supportable over JSON lines;
it just needs the reverse-direction control request handled. Three changes, all
in libclaude/packages/core/src/session.ts:
-
Enable routing. Add
--permission-prompt-tool stdioinbuildArgs(). Without it the CLI decides from mode/settings and never asks. (The SDK pushes exactly--permission-prompt-tool stdiowhen acanUseToolcallback is set.) Note:bypassPermissionsmode suppresses prompts entirely. -
Handle the inbound
control_requestin therl.on("line")branch (alongside the existingcontrol_responsecase). Shape from the CLI:{"type":"control_request","request_id":"...", "request":{"subtype":"can_use_tool","tool_name":"Edit","input":{...}, "tool_use_id":"...","permission_suggestions":..., "title":...,"description":...,"display_name":..., "blocked_path":...,"decision_reason":...}}Also handle
control_cancel_request(CLI cancels a pending prompt, e.g. on interrupt) so stale UI prompts get cleared. -
Write back the decision as a
control_responsereusing the samerequest_id:// allow (echo input back, optionally modified): {"type":"control_response","response":{"subtype":"success","request_id":"...", "response":{"behavior":"allow","updatedInput":{...},"toolUseID":"..."}}} // reject: {"type":"control_response","response":{"subtype":"success","request_id":"...", "response":{"behavior":"deny","message":"User denied"}}}The SDK's
canUseToolresult is{behavior:"allow", updatedInput} | {behavior:"deny", message}, and the SDK addstoolUseIDto the response.
Then surface it: track the pending request_id in ManagedSession, emit a new
ClaudeUiEvent (e.g. permission_request) up to the renderer, and resolve it
when the user clicks allow/deny (write the control_response).
Permission UI contract
The permission flow surfaces a PermissionRequest and resolves it allow/deny.
The UI contract lives in protocol.ts:
PermissionRequest(requestId, toolName, toolInput, description, displayName)permission_request/permission_resolvedevents- result
{behavior:"allow"} | {behavior:"deny", message}
History note: an earlier SDK-based session wrapper
(src/claude-session/server/claude-service.ts, on @anthropic-ai/claude-agent-sdk)
implemented this via the SDK's canUseTool callback. It has been removed; the
CLI-backed ClaudeUiManager is now the only implementation.
Learning the protocol from the Agent SDK source
The docs do not spell out the wire-level control protocol, but the SDK bundle
does. It is the authoritative reference because the SDK literally spawns the
same claude -p --input-format stream-json --output-format stream-json process
and exchanges these JSON lines. To re-derive or extend our handling, read it.
-
File:
node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs. It is minified (single-letter identifiers, no newlines), so don't try to read it top-to-bottom. Grep for protocol string literals with surrounding bytes:# survey which control subtypes exist grep -oE '"subtype":"[a-z_]*"|subtype:"[a-z_]*"' sdk.mjs | sort -u # pull a method/handler with N bytes of trailing context grep -oE 'subtype==="can_use_tool"\)\{.{700}' sdk.mjs grep -oE 'handleControlRequest\([A-Za-z]\)\{.{400}' sdk.mjs grep -oE '.{40}control_response.{300}' sdk.mjs grep -oE 'permission-prompt-tool.{120}' sdk.mjsVary the
.{N}byte window to widen/narrow the slice. Match on the stable string literals (control_request,can_use_tool,set_permission_mode,--permission-prompt-tool,behavior,updatedInput), not on the mangled identifiers, which change between SDK versions. -
Code landmarks (names are version-specific; find them by the literals):
- The transport/query driver class holds
canUseTool,hooks,pendingControlResponses, andhasBidirectionalNeeds(). That last method returns true when any ofcanUseTool/hooks/ SDK MCP servers /onElicitationis set, which is what flips the CLI into bidirectional mode. request(Q)builds{request_id, type:"control_request", request:Q}, stores a resolver inpendingControlResponseskeyed byrequest_id. This is the we -> CLI path (interrupt, set_permission_mode, etc.).- The read loop dispatches inbound messages by
type:control_response(resolve a pending request byresponse.request_id),control_request(handleControlRequest),control_cancel_request, and ignoreskeep_alive/streamlined_text/streamlined_tool_use_summary. handleControlRequestwraps the result as{type:"control_response", response:{subtype:"success", request_id, response:X}}, and routes byrequest.subtype:can_use_tool-> thecanUseToolcallback,hook_callback-> hooks,mcp_message-> SDK MCP transports. This is the CLI -> us path we still need to implement.- The CLI arg builder pushes the base
--output-format stream-json --verbose --input-format stream-jsonand adds--permission-prompt-tool stdioonly when acanUseToolcallback exists.
- The transport/query driver class holds
-
Gotcha: an error
control_responsecan carrypending_permission_requests; the SDK drains them through the samecan_use_toolhandler. Permission prompts are not always a standalone inboundcontrol_request, so route by subtype, not by message position. -
Cross-check before trusting a slice: the same literals appear twice in the bundle (the
query()path and theunstable_v2_*session path). Confirm a shape against both occurrences, and prefer it over guessing from field names.