# 0002 — Claude Code session files for asyncai Status: accepted Date: 2026-11-02 ## Context `asyncai` needs a default-on transcript that Claude Code can load with `claude --resume `. Discovery against the local Claude Code 2.1.316 installation: - Transcripts live at `~/.claude/projects//.jsonl`. - `` replaces every character that is not ASCII alphanumeric or `-` with `-`. Verified with `/tmp/asyncai-resume-edge_x.y` → `-tmp-asyncai-resume-edge-x-y`. - A transcript is newline-delimited JSON. The records needed for resume are `type: "user"` and `type: "assistant"` records linked by `parentUuid`. - A minimal two-record transcript (user message, assistant text) resumed successfully with `deepseek-claude -p --resume `. - Assistant records carry `message.id`, `type: "message"`, `role`, `model`, `content` blocks, `stop_reason`, `stop_sequence`, and `usage`. - Tool use is represented as an assistant record whose content includes `{"type":"tool_use","id","name","input"}`, followed by a user record whose content includes `{"type":"tool_result","tool_use_id","content","is_error"}`. - `~/.claude/sessions/.json` holds live process/session metadata (`sessionId`, `cwd`, status), not the transcript. - Monitors are normal `Monitor` tool calls recorded in the transcript (`command`, `description`, `timeout_ms`). Monitor events arrive during the turn as task notifications/system reminders. - `deepseek-claude` is a wrapper around `claude` that sets the model and API base URL; it accepts `-p --resume `. ## Decision Session transcripts are harness-agnostic. A `SessionLogger` fans transcript events out to one or more `SessionHarness` implementations. Claude Code CLI is the first harness; additional formats implement the same trait. ``` src/session/mod.rs src/session/harnesses/mod.rs src/session/harnesses/claude_code_cli/mod.rs ``` Interface: ```rust pub enum TranscriptEvent { User { text }, Assistant { text }, ToolCall { id, name, arguments }, ToolResult { id, content, is_error }, } pub trait SessionHarness: Send { fn format_name(&self) -> &'static str; fn session_id(&self) -> &str; fn session_file(&self) -> &Path; fn write_event(&mut self, event: &TranscriptEvent) -> io::Result<()>; fn finish(&mut self) -> io::Result<()>; } ``` `asyncai` and `asyncai atirc` write Claude Code CLI transcripts by default. - Each harness owns its storage root and default location. There is no global session root. - `--session-dir =` overrides a single harness root and is repeatable. `--no-session` disables writing. - `--session-format ` is repeatable. Default: `claude-code-cli`. Unknown formats fail at startup. - Claude Code CLI default root: `$CLAUDE_CONFIG_DIR` when set, otherwise `~/.claude`. - Each harness creates its own fresh random UUID v4 session ID and file. - Never overwrite or modify an existing file: - create the project directory with `create_dir_all` only; - open the transcript with `create_new(true)`; - on an existing path, generate another UUID and retry; - append only to the file this process created. - First stdout line is NDJSON: ```json {"work_name":"session","result":{ "session_id":"", "session_file":"", "harness":"claude-code-cli", "agent_name":"", "harnesses":[{"format":"claude-code-cli","session_id":"","session_file":""}] }} ``` Every accepted inbound message, outbound assistant message, MCP tool call, and MCP tool result is sent to every configured harness; each harness appends and flushes its own records. Claude Code CLI harness specifics: - Project directory: `cwd` characters preserved only when ASCII alphanumeric or `-`; all others become `-`. - Session file: `/projects//.jsonl`. - Record schema and resume behavior below. ## Record shape Common fields: ```json { "parentUuid": "", "isSidechain": false, "uuid": "", "timestamp": "", "userType": "external", "entrypoint": "cli", "cwd": "", "sessionId": "", "version": "2.1.316", "gitBranch": "" } ``` User message: ```json { "type": "user", "message": {"role": "user", "content": ""}, "...common fields..." } ``` Assistant message: ```json { "type": "assistant", "message": { "id": "", "type": "message", "role": "assistant", "model": "", "content": [{"type": "text", "text": ""}], "stop_reason": "end_turn", "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0} }, "...common fields..." } ``` Tool call: ```json { "type": "assistant", "message": { "id": "", "type": "message", "role": "assistant", "model": "", "content": [{ "type": "tool_use", "id": "", "name": "", "input": {} }], "stop_reason": "tool_use", "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0} }, "...common fields..." } ``` Tool result: ```json { "type": "user", "message": { "role": "user", "content": [{ "type": "tool_result", "tool_use_id": "", "content": "", "is_error": false }] }, "...common fields..." } ``` `parentUuid` chains records in write order. Each record gets a fresh UUID. ## Monitor events Claude Code stores monitor events as task notifications, not normal user messages. `asyncai --monitor ""` captures each stdout line from the command as one monitor event. `--monitor` is repeatable. On each line, Claude Code CLI harness writes three records. 1. Queue enqueue (metadata; no `uuid`/`parentUuid`, does not advance chain): ```json { "type": "queue-operation", "operation": "enqueue", "timestamp": "", "sessionId": "", "content": "..." } ``` 2. Attachment (chain record; advances `parentUuid`): ```json { "type": "attachment", "parentUuid": "", "uuid": "", "attachment": { "type": "queued_command", "prompt": "...", "source_uuid": "", "delivery_id": "", "commandMode": "task-notification", "origin": { "kind": "task-notification", "producer": "session-task" }, "timestamp": "" }, "rendered": [{ "content": "...SYSTEM NOTIFICATION...", "renderedRole": "system" }], "renderedInHumanTurn": [{ "content": "...SYSTEM NOTIFICATION...", "renderedRole": "system" }], "renderedRole": "system", "session_id": "", "...common fields..." } ``` 3. Queue remove (metadata; no chain): ```json { "type": "queue-operation", "operation": "remove", "timestamp": "", "sessionId": "", "content": "...", "reason": "absorbed_mid_turn", "commandUuid": "", "deliveryId": "" } ``` Rules: - `attachment.source_uuid` must equal remove `commandUuid`. - `attachment.delivery_id` must equal remove `deliveryId`. - Queue enqueue/remove are not parent-chain records. - Attachment is a parent-chain record. - `` uses `Monitor event: ""`. - `event`, `description`, and `task_id` are XML-escaped (`&`, `<`, `>`). - `rendered` uses the normal system-notification preamble; `renderedInHumanTurn` uses the same-turn variant. - Monitor events are captured into transcripts only. They are not injected as agent conversation turns. - The runner writes removed immediately after attachment with reason `absorbed_mid_turn`. ## Event sources - Interactive mode: each stdin line → user record; each assistant `new_thread_message` → assistant record. - atirc mode: each accepted non-self channel message becomes `MonitorEvent` + `MonitorConsumed` (queue enqueue, `queued_command` attachment, queue remove); each assistant output becomes an assistant record. atirc messages are system events, not user records. The monitor description is the synthetic subscription command `atirc sub -c ... --cursor ...`, even though the subscription runs in Rust. - MCP tool calls/results: the OpenAI backend emits `ToolEvent` values through an observer. `SessionLogger` converts them to `TranscriptEvent` values and fans them out to every harness. - Monitor commands: each stdout line becomes `TranscriptEvent::MonitorEvent` followed by `TranscriptEvent::MonitorConsumed`. - Watch notices (ADR 0005): `MonitorEvent` with a `note`, the instruction the model saw (`... reply exactly NO_REPLY`), in place of the PushNotification sentence. - One writer task; all sources send to one queue, preserving order. - Every harness flushes each record. No file is shared between processes; each asyncai process owns one UUID file per harness. - **Concurrent resume.** `claude --resume ` on a live transcript appends its own metadata records (`mode`, `permission-mode`, `cost-state`, ...) to the same file. Writes use `O_APPEND`, so lines interleave without clobbering. The harness counts its own bytes and logs `warn` `session.foreign_append` once when the file grows beyond them. Resume a copy, or stop the agent first. ## Invariants 1. Existing Claude Code files are read-only by convention. Never truncate, rename, delete, or append to a file created by another process. 2. One asyncai process writes exactly one new transcript file per configured harness. 3. Session UUID is random per harness; collision with an existing path is handled by generating another UUID, not by reusing the path. 4. Session logging failures are reported to stderr and do not stop the agent loop. ## Test plan - Unit: session logger fans events out to multiple in-memory harnesses. - Unit: Claude harness creates a new file under a temp root, emits user/assistant/tool records, links parents, flushes each record, and leaves a second process's file untouched. - Unit: monitor event writes queue enqueue, queued_command attachment, queue remove, with matching source/delivery IDs, XML escaping and no chain advance for queue records. - Unit: monitor runner turns command stdout lines into task-notification records. - CLI integration: `--session-dir claude-code-cli=` produces the session line as the first stdout record and a transcript containing the user text, assistant text, tool_use, and tool_result. - atirc integration: both agent processes create separate UUID transcripts; fake atirc bus still drives message and tool records. - Manual resume: run asyncai with default root, capture the UUID from the first stdout line, then run `deepseek-claude -p --resume "Reply with exactly: resumed-ok"` from the same cwd. Expect `resumed-ok`. ## Verification `cargo test`: all tests pass. `cargo clippy --all-targets -- -D warnings`: clean. `cargo fmt --check`: clean. Manual resume checks with `deepseek-claude -p --resume `: | transcript | observed output | |---|---| | interactive asyncai | `asyncai-resumed-ok` | | asyncai atirc channel event | `atirc-monitor-resumed-ok` | | asyncai with MCP tool call | `tool-session-resumed` | | asyncai with monitor records | `monitor-resumed-ok` | Tool transcript record order: ```text user:text | assistant:tool_use | user:tool_result | assistant:text ``` Monitor transcript record order per stdout line: ```text queue-operation:enqueue | attachment:queued_command | queue-operation:remove ``` Observed first stdout line shape: ```json {"work_name":"session","result":{"session_id":"","session_file":"","harness":"claude-code-cli","agent_name":"","harnesses":[...]}} ``` No-overwrite verification: creating a second session in the same project directory produced a distinct UUID path and left the first transcript unchanged. ## Consequences - Transcripts grow with conversation length; no rotation yet. - File writes are synchronous and flushed per record; acceptable for chat volume. - atirc sender identity is not encoded separately; message text is written as the user content. The atirc message metadata remains the source of sender identity. - Prompt files remain separate from session transcripts (`prompts/interactive.md`, `prompts/atirc.md`).