1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
# 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 (`&`, `<`,
`>`).
- `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`).