~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/asyncai

0
0
main
388 lines · 12.4 KB Raw History

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" and type: "assistant" records linked by parentUuid.
  • 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, 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/<pid>.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 <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-session disables writing.

  • --session-format <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:

    {"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: cwd characters 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.

  1. 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>"
}
  1. 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..."
}
  1. 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_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.
  • <summary> uses Monitor event: "<description>".
  • event, description, and task_id are XML-escaped (&amp;, &lt;, &gt;).
  • 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 <uuid> 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=<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. 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 <uuid>:

transcriptobserved output
interactive asyncaiasyncai-resumed-ok
asyncai atirc channel eventatirc-monitor-resumed-ok
asyncai with MCP tool calltool-session-resumed
asyncai with monitor recordsmonitor-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).