~ 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:

```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 <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:

  ```json
  {"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:

```json
{
  "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:

```json
{
  "type": "user",
  "message": {"role": "user", "content": "<text>"},
  "...common fields..."
}
```

Assistant message:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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):

```json
{
  "type": "queue-operation",
  "operation": "enqueue",
  "timestamp": "<rfc3339>",
  "sessionId": "<session uuid>",
  "content": "<task-notification>...</task-notification>"
}
```

2. Attachment (chain record; advances `parentUuid`):

```json
{
  "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..."
}
```

3. Queue remove (metadata; no chain):

```json
{
  "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>`:

| 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":"<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`).