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 <session-id>. Discovery against the local Claude Code 2.1.316
installation:
- Transcripts live at
~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl. <encoded-cwd>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"andtype: "assistant"records linked byparentUuid. - A minimal two-record transcript (user message, assistant text) resumed
successfully with
deepseek-claude -p --resume <uuid>. - Assistant records carry
message.id,type: "message",role,model,contentblocks,stop_reason,stop_sequence, andusage. - 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/<pid>.jsonholds live process/session metadata (sessionId,cwd, status), not the transcript.- Monitors are normal
Monitortool calls recorded in the transcript (command,description,timeout_ms). Monitor events arrive during the turn as task notifications/system reminders. deepseek-claudeis a wrapper aroundclaudethat sets the model and API base URL; it accepts-p --resume <uuid>.
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:
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 <format>=<path>overrides a single harness root and is repeatable.--no-sessiondisables writing. -
--session-format <format>is repeatable. Default:claude-code-cli. Unknown formats fail at startup. -
Claude Code CLI default root:
$CLAUDE_CONFIG_DIRwhen 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_allonly; - open the transcript with
create_new(true); - on an existing path, generate another UUID and retry;
- append only to the file this process created.
- create the project directory with
-
First stdout line is NDJSON:
{"work_name":"session","result":{ "session_id":"<first-harness-uuid>", "session_file":"<first-harness-path>", "harness":"claude-code-cli", "agent_name":"<name>", "harnesses":[{"format":"claude-code-cli","session_id":"<uuid>","session_file":"<path>"}] }}
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:
cwdcharacters preserved only when ASCII alphanumeric or-; all others become-. - Session file:
<root>/projects/<encoded-cwd>/<uuid>.jsonl. - Record schema and resume behavior below.
Record shape
Common fields:
{
"parentUuid": "<previous uuid or null>",
"isSidechain": false,
"uuid": "<new uuid>",
"timestamp": "<RFC3339 millis>",
"userType": "external",
"entrypoint": "cli",
"cwd": "<cwd>",
"sessionId": "<session uuid>",
"version": "2.1.316",
"gitBranch": "<branch or null>"
}
User message:
{
"type": "user",
"message": {"role": "user", "content": "<text>"},
"...common fields..."
}
Assistant message:
{
"type": "assistant",
"message": {
"id": "<message uuid>",
"type": "message",
"role": "assistant",
"model": "<model>",
"content": [{"type": "text", "text": "<text>"}],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {"input_tokens": 0, "output_tokens": 0}
},
"...common fields..."
}
Tool call:
{
"type": "assistant",
"message": {
"id": "<message uuid>",
"type": "message",
"role": "assistant",
"model": "<model>",
"content": [{
"type": "tool_use",
"id": "<tool call id>",
"name": "<tool name>",
"input": {}
}],
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": {"input_tokens": 0, "output_tokens": 0}
},
"...common fields..."
}
Tool result:
{
"type": "user",
"message": {
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "<tool call id>",
"content": "<result text>",
"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 "<command>" captures each stdout line from the
command as one monitor event. --monitor is repeatable.
On each line, Claude Code CLI harness writes three records.
- Queue enqueue (metadata; no
uuid/parentUuid, does not advance chain):
{
"type": "queue-operation",
"operation": "enqueue",
"timestamp": "<rfc3339>",
"sessionId": "<session uuid>",
"content": "<task-notification>...</task-notification>"
}
- Attachment (chain record; advances
parentUuid):
{
"type": "attachment",
"parentUuid": "<previous chain uuid>",
"uuid": "<new uuid>",
"attachment": {
"type": "queued_command",
"prompt": "<task-notification>...</task-notification>",
"source_uuid": "<source uuid>",
"delivery_id": "<delivery uuid>",
"commandMode": "task-notification",
"origin": {
"kind": "task-notification",
"producer": "session-task"
},
"timestamp": "<rfc3339>"
},
"rendered": [{
"content": "<system-reminder>...SYSTEM NOTIFICATION...</system-reminder>",
"renderedRole": "system"
}],
"renderedInHumanTurn": [{
"content": "<system-reminder>...SYSTEM NOTIFICATION...</system-reminder>",
"renderedRole": "system"
}],
"renderedRole": "system",
"session_id": "<session uuid>",
"...common fields..."
}
- Queue remove (metadata; no chain):
{
"type": "queue-operation",
"operation": "remove",
"timestamp": "<rfc3339>",
"sessionId": "<session uuid>",
"content": "<task-notification>...</task-notification>",
"reason": "absorbed_mid_turn",
"commandUuid": "<source uuid>",
"deliveryId": "<delivery uuid>"
}
Rules:
attachment.source_uuidmust equal removecommandUuid.attachment.delivery_idmust equal removedeliveryId.- Queue enqueue/remove are not parent-chain records.
- Attachment is a parent-chain record.
<summary>usesMonitor event: "<description>".event,description, andtask_idare XML-escaped (&,<,>).rendereduses the normal system-notification preamble;renderedInHumanTurnuses 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_commandattachment, queue remove); each assistant output becomes an assistant record. atirc messages are system events, not user records. The monitor description is the synthetic subscription commandatirc sub -c ... --cursor ..., even though the subscription runs in Rust. - MCP tool calls/results: the OpenAI backend emits
ToolEventvalues through an observer.SessionLoggerconverts them toTranscriptEventvalues and fans them out to every harness. - Monitor commands: each stdout line becomes
TranscriptEvent::MonitorEventfollowed byTranscriptEvent::MonitorConsumed. - Watch notices (ADR 0005):
MonitorEventwith anote, 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 <uuid>on a live transcript appends its own metadata records (mode,permission-mode,cost-state, ...) to the same file. Writes useO_APPEND, so lines interleave without clobbering. The harness counts its own bytes and logswarnsession.foreign_appendonce when the file grows beyond them. Resume a copy, or stop the agent first.
Invariants
- Existing Claude Code files are read-only by convention. Never truncate, rename, delete, or append to a file created by another process.
- One asyncai process writes exactly one new transcript file per configured harness.
- Session UUID is random per harness; collision with an existing path is handled by generating another UUID, not by reusing the path.
- 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=<temp>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 <uuid> "Reply with exactly: resumed-ok"from the same cwd. Expectresumed-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 <uuid>:
| 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:
user:text | assistant:tool_use | user:tool_result | assistant:text
Monitor transcript record order per stdout line:
queue-operation:enqueue | attachment:queued_command | queue-operation:remove
Observed first stdout line shape:
{"work_name":"session","result":{"session_id":"<uuid>","session_file":"<path>","harness":"claude-code-cli","agent_name":"<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).