3.8 KiB
Session transport
Status: Implemented · Code: Scripts/client.js, Scripts/cli.js
Drives the claude executable in streaming JSON mode and owns the wire
protocol. Everything above this layer deals in parsed messages.
Command line
claude --print
--input-format stream-json --output-format stream-json --verbose
--permission-prompt-tool stdio
[--include-partial-messages]
(--session-id <uuid> | --resume <id>)
[--model <alias>] [--effort <level>] [--permission-mode <mode>]
[--add-dir <path>]… [extra args]
--permission-prompt-tool stdio is load-bearing and undocumented. It does
not appear in claude --help; it was found in the Agent SDK bundle, which
passes it whenever a canUseTool callback is supplied. Without it the CLI has
nowhere to send permission prompts and auto-denies every tool that needs one
— the session appears to work and silently accomplishes nothing.
--verbose is required for stream-json output under --print.
We deliberately do not pass --replay-user-messages: it echoes our own user
turns back, which would double them in the transcript since we render them
locally on send.
Protocol
Two kinds of traffic share stdout, distinguished by type.
Session messages — system (subtype: init carries session_id, model,
permissionMode, cwd, tools), assistant, user, stream_event,
result. Also seen and ignored: rate_limit_event, system:status,
system:thinking_tokens, keep_alive, transcript_mirror.
Control traffic — request/response in both directions:
// out: we ask the CLI to do something
{"type":"control_request","request_id":"…","request":{"subtype":"initialize"}}
// in: the reply, matched by request_id
{"type":"control_response","response":{"subtype":"success","request_id":"…","response":{…}}}
// in: the CLI asks us something (permission prompts)
{"type":"control_request","request_id":"…","request":{"subtype":"can_use_tool",…}}
// out: our answer
{"type":"control_response","response":{"subtype":"success","request_id":"…","response":{…}}}
{"type":"control_response","response":{"subtype":"error","request_id":"…","error":"…"}}
// in: a prompt was withdrawn
{"type":"control_cancel_request","request_id":"…"}
Outbound control requests used: initialize, interrupt, set_permission_mode,
set_model. Inbound: can_use_tool. Any other inbound subtype is answered with
an error response rather than ignored.
A control request must always be answered. The CLI blocks on can_use_tool,
so a handler that throws — or silently returns — hangs the session forever.
_handleControlRequest wraps the handler and converts a throw into an error
response for exactly this reason.
User input
{"type":"user","message":{"role":"user","content":"…"},"parent_tool_use_id":null}
content may be a string or an array of content blocks. No session_id is
required on input.
Framing
Process.onStdout delivers chunks, not guaranteed whole lines, so _onStdout
buffers and splits on \n. Non-JSON lines are logged and skipped — the CLI
occasionally emits human-readable chatter.
Binary resolution
cli.js looks in order: the configured path, ~/.local/bin/claude,
~/.claude/local/claude, /opt/homebrew/bin, /usr/local/bin, /usr/bin,
then asks the user's login shell ($SHELL -l -c 'command -v claude') — which is
what catches nvm, mise and asdf installs. The result is cached until the
claudenova.binaryPath setting changes.
Gotchas
Processstdin is aWritableStreamthat also has a conveniencewrite().processWriter()inutil.jshandles both shapes.- Never close stdin: the session stays open for streaming input.
- Exit status 143 is normal — it is SIGTERM from our own
stop().