@ccmsg/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/package.json +32 -0
  4. package/src/cli.ts +1074 -0
  5. package/src/daemon/control.ts +88 -0
  6. package/src/daemon/index.ts +6 -0
  7. package/src/daemon/link.ts +93 -0
  8. package/src/daemon/log.ts +116 -0
  9. package/src/daemon/registry.ts +285 -0
  10. package/src/daemon/snapshot.ts +115 -0
  11. package/src/daemon/supervise.ts +446 -0
  12. package/src/dispatch/caller.ts +47 -0
  13. package/src/dispatch/dispatch.ts +128 -0
  14. package/src/dispatch/handler.ts +55 -0
  15. package/src/dispatch/identity.ts +22 -0
  16. package/src/dispatch/index.ts +5 -0
  17. package/src/dispatch/result.ts +58 -0
  18. package/src/files/containment.ts +263 -0
  19. package/src/files/files.ts +421 -0
  20. package/src/files/index.ts +14 -0
  21. package/src/files/sandbox.ts +0 -0
  22. package/src/greeting/hook.ts +48 -0
  23. package/src/greeting/index.ts +2 -0
  24. package/src/greeting/meta.ts +66 -0
  25. package/src/instance/config.ts +424 -0
  26. package/src/instance/handlers.ts +28 -0
  27. package/src/instance/identity.ts +44 -0
  28. package/src/instance/index.ts +8 -0
  29. package/src/instance/instance.ts +911 -0
  30. package/src/instance/lock.ts +108 -0
  31. package/src/instance/log.ts +30 -0
  32. package/src/instance/paths.ts +200 -0
  33. package/src/instance/socket.ts +62 -0
  34. package/src/kv/index.ts +2 -0
  35. package/src/kv/merge.ts +66 -0
  36. package/src/kv/store.ts +195 -0
  37. package/src/launcher/index.ts +4 -0
  38. package/src/launcher/launcher.ts +190 -0
  39. package/src/launcher/roots.ts +32 -0
  40. package/src/launcher/spawn.ts +81 -0
  41. package/src/launcher/tree.ts +80 -0
  42. package/src/mesh/index.ts +5 -0
  43. package/src/mesh/keys.ts +158 -0
  44. package/src/mesh/mesh.ts +1169 -0
  45. package/src/mesh/probe.ts +100 -0
  46. package/src/mesh/relay.ts +147 -0
  47. package/src/mesh/wire.ts +96 -0
  48. package/src/messaging/delivery.ts +375 -0
  49. package/src/messaging/direct.ts +433 -0
  50. package/src/messaging/handlers.ts +14 -0
  51. package/src/messaging/inbox.ts +191 -0
  52. package/src/messaging/index.ts +5 -0
  53. package/src/messaging/notify.ts +117 -0
  54. package/src/plugin/claude.ts +148 -0
  55. package/src/plugin/index.ts +13 -0
  56. package/src/plugin/install.ts +416 -0
  57. package/src/service/index.ts +1 -0
  58. package/src/service/service.ts +359 -0
  59. package/src/sessions/classify.ts +66 -0
  60. package/src/sessions/dump.ts +105 -0
  61. package/src/sessions/fork.ts +127 -0
  62. package/src/sessions/handlers.ts +158 -0
  63. package/src/sessions/harness.ts +167 -0
  64. package/src/sessions/index.ts +26 -0
  65. package/src/sessions/last-live.ts +111 -0
  66. package/src/sessions/processes.ts +413 -0
  67. package/src/sessions/registry.ts +785 -0
  68. package/src/sessions/search.ts +278 -0
  69. package/src/sessions/status.ts +209 -0
  70. package/src/sessions/terminals.ts +72 -0
  71. package/src/sessions/workspace.ts +140 -0
  72. package/src/topics/handlers.ts +42 -0
  73. package/src/topics/index.ts +2 -0
  74. package/src/topics/topics.ts +290 -0
  75. package/src/transcript/files.ts +201 -0
  76. package/src/transcript/fold.ts +833 -0
  77. package/src/transcript/index.ts +16 -0
  78. package/src/transcript/read.ts +82 -0
  79. package/src/transcript/tail.ts +195 -0
  80. package/src/transcript/transcripts.ts +162 -0
  81. package/src/translate/helper.ts +87 -0
  82. package/src/translate/index.ts +2 -0
  83. package/src/translate/translate.ts +127 -0
  84. package/src/transport/conn.ts +129 -0
  85. package/src/transport/dial.ts +65 -0
  86. package/src/transport/driver.ts +102 -0
  87. package/src/transport/entry.ts +39 -0
  88. package/src/transport/framing.ts +131 -0
  89. package/src/transport/index.ts +8 -0
  90. package/src/transport/listener.ts +39 -0
  91. package/src/transport/uds.ts +88 -0
  92. package/src/transport/ws.ts +170 -0
  93. package/src/upstream/events.ts +125 -0
  94. package/src/upstream/gateway.ts +275 -0
  95. package/src/upstream/index.ts +8 -0
  96. package/src/upstream/json.ts +81 -0
  97. package/src/upstream/requests.ts +234 -0
  98. package/src/upstream/stats.ts +99 -0
  99. package/src/upstream/status.ts +281 -0
  100. package/src/upstream/usage.ts +208 -0
  101. package/src/upstream/webhook.ts +141 -0
  102. package/src/version.ts +8 -0
@@ -0,0 +1,833 @@
1
+ import { isAbsolute } from "node:path";
2
+ import type {
3
+ AgentTreeGroups,
4
+ AgentTreeNode,
5
+ ExternalFile,
6
+ SessionApiError,
7
+ SessionBackgroundStatus,
8
+ SessionTeammate,
9
+ SessionTodo,
10
+ SessionWorkflowStatus,
11
+ Timestamp,
12
+ } from "@ccmsg/protocol";
13
+
14
+ /** Everything one session's transcript is folded into (§3.3).
15
+ *
16
+ * One fold, not one per consumer: the same line settles whether the session is
17
+ * stopped, when a person last spoke to it, which files it named and what is
18
+ * running below it, so it is read once and every value it can settle is
19
+ * settled from that read (M5). The two optional fields are absent until a line
20
+ * says otherwise, and the lists are empty — which is what a transcript that has
21
+ * not been read yet looks like, and is also the contract's "nothing was
22
+ * declared". */
23
+ export interface TranscriptFacts {
24
+ /** The error the latest turn ended on. Present only while it stands: a real
25
+ * turn after it clears it, so this is the session's current state and not
26
+ * every error it ever hit. One of the two things §5.2 calls Waiting. */
27
+ readonly api_error?: SessionApiError;
28
+ /** When a person last put something into the session (§5.3). */
29
+ readonly last_user_input_at?: Timestamp;
30
+ /** What answered on the latest turn, and how hard it was asked to think.
31
+ *
32
+ * Both are the transcript's answer rather than the greeting's: a session
33
+ * names its model once, when it greets, and `/model` and `/effort` move it
34
+ * afterwards without saying so again. The two are read off the same row, so
35
+ * they can never describe different turns. */
36
+ readonly model?: string;
37
+ readonly effort?: string;
38
+ /** Every absolute path the transcript named, whichever way it named it.
39
+ *
40
+ * Not yet the contract's `external_files`, which is the paths outside the
41
+ * session's root: the root is a greeting's fact and the fold holds no
42
+ * greeting, so the fold states what was named and the filter runs where the
43
+ * root is known. */
44
+ readonly named_files: readonly ExternalFile[];
45
+ readonly todos: readonly SessionTodo[];
46
+ readonly teammates: readonly SessionTeammate[];
47
+ readonly background: readonly SessionBackgroundStatus[];
48
+ readonly workflows: readonly SessionWorkflowStatus[];
49
+ readonly agent_tree: AgentTreeGroups;
50
+ }
51
+
52
+ /** What a transcript nobody has read yet says. Every list empty rather than
53
+ * absent, so a consumer never has to tell "not read" from "nothing declared" —
54
+ * the contract spells both the same way. */
55
+ export const NO_FACTS: TranscriptFacts = {
56
+ named_files: [],
57
+ todos: [],
58
+ teammates: [],
59
+ background: [],
60
+ workflows: [],
61
+ agent_tree: { teammates: [], agents: [], workflows: [] },
62
+ };
63
+
64
+ /** The one place a transcript line is interpreted.
65
+ *
66
+ * Nothing outside this module parses a transcript record. A line arrives, the
67
+ * fold updates what it can from it, and the values the domain states are read
68
+ * off the result — so a value can never be derived by two different readings
69
+ * of the same file (§3.3, M5).
70
+ *
71
+ * Feeding lines is order-dependent by design: the api error is the state of
72
+ * the latest turn, so a later line undoing an earlier one is the point.
73
+ * `reset` starts over, which is what a rewritten transcript needs. */
74
+ export class TranscriptFold {
75
+ #apiError: SessionApiError | undefined;
76
+ #lastUserInputAt: Timestamp | undefined;
77
+ #model: string | undefined;
78
+ #effort: string | undefined;
79
+ /** Paths in the order they were first named, so the value is stable across
80
+ * reads of the same file. */
81
+ readonly #files = new Map<string, ExternalFile>();
82
+ readonly #todos = new Map<string, SessionTodo>();
83
+ readonly #teammates = new Map<string, Teammate>();
84
+ readonly #background = new Map<string, Mutable<SessionBackgroundStatus>>();
85
+ readonly #workflows = new Map<string, Mutable<SessionWorkflowStatus>>();
86
+ readonly #agents = new Map<string, Mutable<AgentTreeNode>>();
87
+ /** A tool call waiting for its result. The call carries the arguments and the
88
+ * result carries the identifiers the arguments never mention, so neither half
89
+ * describes what started on its own. */
90
+ readonly #calls = new Map<string, PendingCall>();
91
+
92
+ get facts(): TranscriptFacts {
93
+ return {
94
+ ...(this.#apiError === undefined ? {} : { api_error: this.#apiError }),
95
+ ...(this.#lastUserInputAt === undefined ? {} : { last_user_input_at: this.#lastUserInputAt }),
96
+ ...(this.#model === undefined ? {} : { model: this.#model }),
97
+ ...(this.#effort === undefined ? {} : { effort: this.#effort }),
98
+ named_files: [...this.#files.values()],
99
+ todos: [...this.#todos.values()],
100
+ teammates: [...this.#teammates.values()].map((each) => each.status),
101
+ background: [...this.#background.values()],
102
+ workflows: [...this.#workflows.values()],
103
+ agent_tree: this.#agentTree(),
104
+ };
105
+ }
106
+
107
+ reset(): void {
108
+ this.#apiError = undefined;
109
+ this.#lastUserInputAt = undefined;
110
+ this.#model = undefined;
111
+ this.#effort = undefined;
112
+ this.#files.clear();
113
+ this.#todos.clear();
114
+ this.#teammates.clear();
115
+ this.#background.clear();
116
+ this.#workflows.clear();
117
+ this.#agents.clear();
118
+ this.#calls.clear();
119
+ }
120
+
121
+ /** Fold one whole record. Answers whether anything a consumer reads changed,
122
+ * so a file that grew without saying anything new publishes nothing.
123
+ *
124
+ * A line that is not JSON is skipped rather than treated as an error: the
125
+ * transcript is written by another process, and a record still being written
126
+ * is only ever half a line. */
127
+ line(text: string): boolean {
128
+ if (text.length === 0) return false;
129
+ let row: unknown;
130
+ try {
131
+ row = JSON.parse(text);
132
+ } catch {
133
+ return false;
134
+ }
135
+ if (!isRecord(row)) return false;
136
+ // Every value this fold derives, derived from the one parse (M5).
137
+ let changed = this.#foldApiError(row);
138
+ if (this.#foldAnswered(row)) changed = true;
139
+ if (this.#foldUserInput(row)) changed = true;
140
+ if (this.#foldCalls(row)) changed = true;
141
+ if (this.#foldResult(row)) changed = true;
142
+ if (this.#foldRelay(row)) changed = true;
143
+ if (this.#foldAttachment(row)) changed = true;
144
+ if (this.#foldNotification(row)) changed = true;
145
+ return changed;
146
+ }
147
+
148
+ /** The api-error state, from an assistant row.
149
+ *
150
+ * The harness writes its own failures as assistant messages carrying
151
+ * `isApiErrorMessage: true` ("Prompt is too long", "API Error: 500 …",
152
+ * "Please run /login"): the turn stopped and the session sits idle until a
153
+ * person intervenes, which is why it counts as Waiting (§5.2). A row the
154
+ * model actually produced clears it — a row the harness wrote itself carries
155
+ * `model: "<synthetic>"` and does not, so the harness's own "No response
156
+ * requested." cannot pass for the agent answering again. A user row is not a
157
+ * clear either: a person typing does not resolve the error, and the
158
+ * assistant row that follows settles it either way.
159
+ *
160
+ * Sidechain rows never signal. A subagent's transcript interleaves into the
161
+ * same file, and neither its failure nor its recovery describes what the
162
+ * session's main context is doing.
163
+ *
164
+ * Each condition above is the old daemon's observation of real transcripts,
165
+ * carried over as an observed fact about the harness rather than as a rule
166
+ * this daemon chose. */
167
+ #foldApiError(row: Record<string, unknown>): boolean {
168
+ if (row["type"] !== "assistant" || row["isSidechain"] === true) return false;
169
+ const message = row["message"];
170
+ if (!isRecord(message)) return false;
171
+ if (row["isApiErrorMessage"] !== true) {
172
+ const model = str(message["model"]);
173
+ if (model === undefined || model === "<synthetic>") return false;
174
+ if (this.#apiError === undefined) return false;
175
+ this.#apiError = undefined;
176
+ return true;
177
+ }
178
+ const text = blockText(message["content"]);
179
+ if (text === undefined) return false;
180
+ const occurredAt = instant(row["timestamp"]);
181
+ if (occurredAt === undefined) return false;
182
+ // A stall writes several error rows as it is retried; the newest is the
183
+ // one the person is stuck on.
184
+ if (this.#apiError?.text === text && this.#apiError.occurred_at === occurredAt) return false;
185
+ this.#apiError = { text, occurred_at: occurredAt };
186
+ return true;
187
+ }
188
+
189
+ /** What answered the latest turn, from an assistant row.
190
+ *
191
+ * The model sits on `message.model` and the effort beside it on the row, and
192
+ * both are taken from the same row so that they describe one turn. The
193
+ * newest row wins outright: a session moved to another model mid-transcript
194
+ * is on that model now, which is the whole reason this is not the greeting's
195
+ * value.
196
+ *
197
+ * A row the harness wrote itself carries `model: "<synthetic>"` and says
198
+ * nothing about what is answering, and a sidechain row is a subagent, which
199
+ * runs on a model of its own. Effort absent from an otherwise real row
200
+ * clears what an earlier row said rather than keeping it: the row states the
201
+ * turn, and a turn that names no effort has none to report. */
202
+ #foldAnswered(row: Record<string, unknown>): boolean {
203
+ if (row["type"] !== "assistant" || row["isSidechain"] === true) return false;
204
+ const message = row["message"];
205
+ if (!isRecord(message)) return false;
206
+ const model = str(message["model"]);
207
+ if (model === undefined || model === "<synthetic>") return false;
208
+ const effort = str(row["effort"]);
209
+ if (this.#model === model && this.#effort === effort) return false;
210
+ this.#model = model;
211
+ this.#effort = effort;
212
+ return true;
213
+ }
214
+
215
+ /** When a person last spoke, from a user row.
216
+ *
217
+ * A user row is only sometimes a person: the harness injects skill bodies,
218
+ * command caveats and notifications as user rows too. `isMeta: true` marks
219
+ * an injection and `promptSource: "system"` marks a row the harness raised
220
+ * on its own — neither is someone typing. The remaining exclusions are by
221
+ * the text's opening, which is how the injections that carry neither marker
222
+ * were observed to be recognisable.
223
+ *
224
+ * A sidechain user row is a subagent being prompted by its parent, which is
225
+ * a session speaking to itself rather than a person speaking to it. */
226
+ #foldUserInput(row: Record<string, unknown>): boolean {
227
+ if (row["type"] !== "user" || row["isSidechain"] === true) return false;
228
+ if (row["isMeta"] === true || row["promptSource"] === "system") return false;
229
+ const message = row["message"];
230
+ if (!isRecord(message)) return false;
231
+ const text = blockText(message["content"]);
232
+ if (text === undefined || !isHuman(text)) return false;
233
+ const at = instant(row["timestamp"]);
234
+ // Only forwards: a transcript is appended in order, and a row without a
235
+ // readable instant says nothing about when anyone spoke.
236
+ if (at === undefined || (this.#lastUserInputAt ?? 0) >= at) return false;
237
+ this.#lastUserInputAt = at;
238
+ return true;
239
+ }
240
+
241
+ /** The tool calls an assistant row makes.
242
+ *
243
+ * A file tool settles here rather than on its result: the call already
244
+ * carries the path, and the old daemon observed a read whose result never
245
+ * arrived as still having named the file. Everything else waits, because the
246
+ * identifiers a started thing is known by — a background task's id, a
247
+ * teammate's, a workflow's run — appear only in the result.
248
+ *
249
+ * A sidechain row names files the same way any other does: the contract asks
250
+ * which paths the transcript names, and a subagent's read is one of them. It
251
+ * starts nothing, though — a spawn made inside a subagent is that subagent's
252
+ * child, not the session's, and this fold can only see depth from the file
253
+ * the spawn was written in. */
254
+ #foldCalls(row: Record<string, unknown>): boolean {
255
+ if (row["type"] !== "assistant") return false;
256
+ const message = row["message"];
257
+ if (!isRecord(message)) return false;
258
+ const content = message["content"];
259
+ if (!Array.isArray(content)) return false;
260
+ const sidechain = row["isSidechain"] === true;
261
+ const at = instant(row["timestamp"]);
262
+ let changed = false;
263
+ for (const block of content) {
264
+ if (!isRecord(block) || block["type"] !== "tool_use") continue;
265
+ const name = str(block["name"]);
266
+ if (name === undefined) continue;
267
+ const input = isRecord(block["input"]) ? block["input"] : {};
268
+ const field = FILE_INPUT[name];
269
+ if (field !== undefined && this.#named(str(input[field]), "tool")) changed = true;
270
+ const id = str(block["id"]);
271
+ if (sidechain || id === undefined) continue;
272
+ // Oldest out first: a call whose result never came is the one least
273
+ // likely to still be answered.
274
+ if (this.#calls.size >= MAX_PENDING_CALLS) {
275
+ const oldest = this.#calls.keys().next();
276
+ if (oldest.done !== true) this.#calls.delete(oldest.value);
277
+ }
278
+ this.#calls.set(id, { name, input, ...optional("at", at) });
279
+ }
280
+ return changed;
281
+ }
282
+
283
+ /** What a tool call turned out to have started, from the user row carrying
284
+ * its result. The arguments and the result are read together because neither
285
+ * half names the thing on its own. */
286
+ #foldResult(row: Record<string, unknown>): boolean {
287
+ if (row["type"] !== "user") return false;
288
+ const message = row["message"];
289
+ if (!isRecord(message)) return false;
290
+ const content = message["content"];
291
+ if (!Array.isArray(content)) return false;
292
+ // One result per row, as the harness writes them, so the row's own
293
+ // `toolUseResult` belongs to the block that names the call.
294
+ const result = isRecord(row["toolUseResult"]) ? row["toolUseResult"] : undefined;
295
+ let changed = false;
296
+ for (const block of content) {
297
+ if (!isRecord(block) || block["type"] !== "tool_result") continue;
298
+ const id = str(block["tool_use_id"]);
299
+ if (id === undefined) continue;
300
+ const call = this.#calls.get(id);
301
+ this.#calls.delete(id);
302
+ // A call that failed started nothing, and a result the harness wrote in
303
+ // some other shape says nothing this fold can read.
304
+ if (call === undefined || result === undefined || block["is_error"] === true) continue;
305
+ if (this.#started(call, result)) changed = true;
306
+ }
307
+ return changed;
308
+ }
309
+
310
+ /** One completed call, in the kinds the old daemon observed results for. */
311
+ #started(call: PendingCall, result: Record<string, unknown>): boolean {
312
+ switch (call.name) {
313
+ case "Agent":
314
+ // The one call with two outcomes: a long-lived member of the team, or
315
+ // a one-off spawn. The result says which.
316
+ return str(result["status"]) === "teammate_spawned"
317
+ ? this.#teammateSpawned(call, result)
318
+ : this.#agentSpawned(call, result);
319
+ case "Monitor":
320
+ return this.#running(str(result["taskId"]), "monitor", call, {});
321
+ case "Bash":
322
+ // Only a background one is a task; a foreground command is the turn.
323
+ return call.input["run_in_background"] === true
324
+ ? this.#running(str(result["backgroundTaskId"]), "bash", call, {})
325
+ : false;
326
+ case "Workflow":
327
+ return this.#workflowStarted(call, result);
328
+ case "SendMessage":
329
+ return result["success"] === true
330
+ ? this.#teammate(str(call.input["to"]), (each) => {
331
+ each.last_sent_at = call.at;
332
+ })
333
+ : false;
334
+ case "TaskStop":
335
+ return this.#stopped(str(call.input["task_id"]), result);
336
+ case "TaskCreate":
337
+ case "TaskUpdate":
338
+ return this.#todo(call, result);
339
+ default:
340
+ return false;
341
+ }
342
+ }
343
+
344
+ #teammateSpawned(call: PendingCall, result: Record<string, unknown>): boolean {
345
+ const name = str(result["name"]) ?? str(call.input["name"]);
346
+ return this.#teammate(name, (each, side) => {
347
+ each.spawned = true;
348
+ each.state = "active";
349
+ each.spawned_at = call.at;
350
+ each.agent_type = str(result["agent_type"]);
351
+ each.color = str(result["color"]);
352
+ each.model = str(result["model"]);
353
+ side.agent_id = str(result["agent_id"]);
354
+ side.team_name = str(result["team_name"]);
355
+ });
356
+ }
357
+
358
+ /** A one-off spawn: a background task to whoever is watching the session, and
359
+ * a node of the tree below it. Both come from this one result, so the two
360
+ * cannot disagree about what was started. */
361
+ #agentSpawned(call: PendingCall, result: Record<string, unknown>): boolean {
362
+ const id = str(result["agentId"]);
363
+ if (id === undefined) return false;
364
+ // A spawn the caller waits for has already finished by the time its result
365
+ // is written; one launched asynchronously is still going.
366
+ const async = result["isAsync"] === true || launched(str(result["status"]));
367
+ const state = async ? "running" : (str(result["status"]) ?? "completed");
368
+ const agentType = str(call.input["subagent_type"]) ?? str(result["agentType"]);
369
+ this.#running(id, "agent", call, {
370
+ status: state,
371
+ ...optional("agent_type", agentType),
372
+ ...(async ? {} : optional("ended_at", call.at)),
373
+ });
374
+ this.#agents.set(id, {
375
+ agent_id: id,
376
+ spawn_depth: 0,
377
+ kind: "subagent",
378
+ state,
379
+ children: [],
380
+ ...optional("agent_type", agentType),
381
+ ...optional("description", str(call.input["description"])),
382
+ ...optional("model", str(result["resolvedModel"]) ?? str(call.input["model"])),
383
+ ...optional("last_activity_at", call.at),
384
+ });
385
+ return true;
386
+ }
387
+
388
+ #workflowStarted(call: PendingCall, result: Record<string, unknown>): boolean {
389
+ const taskId = str(result["taskId"]);
390
+ const name = str(result["workflowName"]);
391
+ if (taskId === undefined || name === undefined || call.at === undefined) return false;
392
+ this.#workflows.set(taskId, {
393
+ task_id: taskId,
394
+ name,
395
+ // A run that has only just been launched is running; the record it
396
+ // writes at the end is what says how it went.
397
+ status: launched(str(result["status"])) ? "running" : (str(result["status"]) ?? "running"),
398
+ started_at: call.at,
399
+ // Declared in the record the run writes when it ends, which is a file
400
+ // beside the transcript rather than a line in it.
401
+ phases: [],
402
+ agents: [],
403
+ ...optional("summary", str(result["summary"])),
404
+ ...optional("run_id", str(result["runId"])),
405
+ });
406
+ return true;
407
+ }
408
+
409
+ /** A background task, in the three kinds the contract distinguishes. */
410
+ #running(
411
+ taskId: string | undefined,
412
+ kind: SessionBackgroundStatus["kind"],
413
+ call: PendingCall,
414
+ over: Partial<SessionBackgroundStatus>,
415
+ ): boolean {
416
+ if (taskId === undefined || call.at === undefined) return false;
417
+ this.#background.set(taskId, {
418
+ task_id: taskId,
419
+ kind,
420
+ description: str(call.input["description"]) ?? "",
421
+ status: "running",
422
+ started_at: call.at,
423
+ ...over,
424
+ });
425
+ return true;
426
+ }
427
+
428
+ /** `TaskStop` names one thing by id without saying which kind it was, so each
429
+ * place a task can be is tried in turn. */
430
+ #stopped(taskId: string | undefined, result: Record<string, unknown>): boolean {
431
+ if (taskId === undefined) return false;
432
+ if (str(result["task_type"]) === "in_process_teammate") {
433
+ return this.#teammate(taskId, (each) => {
434
+ each.state = "stopped";
435
+ });
436
+ }
437
+ return this.#ended(taskId, "stopped", undefined);
438
+ }
439
+
440
+ /** One started thing reaching its end, wherever it is held.
441
+ *
442
+ * A spawned agent is in two places — the background list and the tree — and
443
+ * both are settled here, so what a reader is told about the same agent
444
+ * cannot differ between the two. */
445
+ #ended(taskId: string, status: string, at: Timestamp | undefined): boolean {
446
+ let changed = false;
447
+ for (const where of [this.#workflows, this.#background]) {
448
+ const entry = where.get(taskId);
449
+ if (entry === undefined) continue;
450
+ entry.status = status;
451
+ if (at !== undefined) entry.ended_at = at;
452
+ changed = true;
453
+ }
454
+ const agent = this.#agents.get(taskId);
455
+ if (agent !== undefined) {
456
+ agent.state = status;
457
+ if (at !== undefined) agent.last_activity_at = at;
458
+ changed = true;
459
+ }
460
+ return changed;
461
+ }
462
+
463
+ /** The session's task list, as the two calls that write it leave it.
464
+ *
465
+ * `TaskCreate` states a task and `TaskUpdate` changes one, and an update adds
466
+ * to the dependency lists rather than replacing them — which is the shape of
467
+ * its arguments, since it names what to add. */
468
+ #todo(call: PendingCall, result: Record<string, unknown>): boolean {
469
+ const task = isRecord(result["task"]) ? result["task"] : undefined;
470
+ const id = str(call.input["taskId"]) ?? str(result["taskId"]) ?? str(task?.["id"]);
471
+ if (id === undefined) return false;
472
+ if (str(call.input["status"]) === "deleted") return this.#todos.delete(id);
473
+ const before = this.#todos.get(id);
474
+ const subject =
475
+ str(call.input["subject"]) ?? str(task?.["subject"]) ?? before?.subject ?? "(unknown)";
476
+ const status = str(call.input["status"]) ?? before?.status ?? "pending";
477
+ this.#todos.set(id, {
478
+ id,
479
+ subject,
480
+ status,
481
+ blocked_by: merge(before?.blocked_by, call.input["addBlockedBy"]),
482
+ blocks: merge(before?.blocks, call.input["addBlocks"]),
483
+ ...optional("owner", str(call.input["owner"]) ?? before?.owner),
484
+ });
485
+ return true;
486
+ }
487
+
488
+ /** A message from a teammate, which the harness relays into the session as a
489
+ * user row wrapping it in a tag. It is the only sign a teammate gives of
490
+ * still being there, so it is also where its liveness comes from. */
491
+ #foldRelay(row: Record<string, unknown>): boolean {
492
+ if (row["type"] !== "user") return false;
493
+ const message = row["message"];
494
+ if (!isRecord(message)) return false;
495
+ const text = blockText(message["content"]);
496
+ if (text === undefined) return false;
497
+ const relay = RELAY.exec(text);
498
+ if (relay === null) return false;
499
+ const name = relay[1];
500
+ // The harness speaks under this name itself; it is not a member of the team.
501
+ if (name === undefined || name === "system") return false;
502
+ const at = instant(row["timestamp"]);
503
+ return this.#teammate(name, (each) => {
504
+ each.last_received_at = at;
505
+ each.state = (relay[2] ?? "").trimStart().startsWith(IDLE) ? "idle" : "active";
506
+ });
507
+ }
508
+
509
+ /** The records the harness writes for something attached to a turn rather
510
+ * than said in it: a file the person put in front of the session, and the
511
+ * task list a subagent's work left changed. */
512
+ #foldAttachment(row: Record<string, unknown>): boolean {
513
+ if (row["type"] !== "attachment") return false;
514
+ const attachment = row["attachment"];
515
+ if (!isRecord(attachment)) return false;
516
+ const kind = str(attachment["type"]);
517
+ if (kind === undefined) return false;
518
+ if (kind === "task_reminder") return this.#reminded(attachment);
519
+ const field = ATTACHMENT_FILE[kind];
520
+ return field === undefined ? false : this.#named(str(attachment[field]), "attachment");
521
+ }
522
+
523
+ /** The whole task list, restated. This is the only sight of a task a subagent
524
+ * created, so the list is upserted rather than replaced: a reminder that
525
+ * omits a task is not the harness saying the task is gone, and deletion has
526
+ * its own call. */
527
+ #reminded(attachment: Record<string, unknown>): boolean {
528
+ const content = attachment["content"];
529
+ if (!Array.isArray(content)) return false;
530
+ let changed = false;
531
+ for (const item of content) {
532
+ if (!isRecord(item)) continue;
533
+ const id = str(item["id"]);
534
+ const subject = str(item["subject"]);
535
+ const status = str(item["status"]);
536
+ if (id === undefined || subject === undefined || status === undefined) continue;
537
+ this.#todos.set(id, {
538
+ id,
539
+ subject,
540
+ status,
541
+ blocked_by: merge(undefined, item["blockedBy"]),
542
+ blocks: merge(undefined, item["blocks"]),
543
+ ...optional("owner", str(item["owner"])),
544
+ });
545
+ changed = true;
546
+ }
547
+ return changed;
548
+ }
549
+
550
+ /** How a started thing is seen to end. The harness queues the notification
551
+ * into the session, and the queue row is the only place the outcome appears —
552
+ * the tool result was written when the thing started. */
553
+ #foldNotification(row: Record<string, unknown>): boolean {
554
+ if (row["type"] !== "queue-operation" || row["operation"] !== "enqueue") return false;
555
+ const content = str(row["content"]);
556
+ if (content === undefined || !content.includes("<task-notification>")) return false;
557
+ // Only as far as the notification's own summary: everything after it is
558
+ // text somebody else wrote, and could spell these tags itself.
559
+ const head = content.slice(0, cut(content));
560
+ const taskId = tagged(head, "task-id");
561
+ const status = tagged(head, "status");
562
+ if (taskId === undefined || status === undefined || status === "running") return false;
563
+ return this.#ended(taskId, status, instant(row["timestamp"]));
564
+ }
565
+
566
+ /** One path the transcript named. A path named both ways keeps the origin it
567
+ * was first seen with, which is what the contract states; a tool naming a
568
+ * path an attachment already carried is still the same file. */
569
+ #named(path: string | undefined, origin: ExternalFile["origin"]): boolean {
570
+ if (path === undefined || !isAbsolute(path) || this.#files.has(path)) return false;
571
+ this.#files.set(path, { path, origin });
572
+ return true;
573
+ }
574
+
575
+ /** A teammate by name, created on first sight. Every route to one goes
576
+ * through here: a spawn, a message out, a message back and a stop are four
577
+ * sightings of one member, and four spellings of "the same name" could come
578
+ * apart. */
579
+ #teammate(
580
+ name: string | undefined,
581
+ change: (status: Mutable<SessionTeammate>, side: Teammate) => void,
582
+ ): boolean {
583
+ if (name === undefined) return false;
584
+ const held = this.#teammates.get(name) ?? {
585
+ // Seen only by having spoken: it exists, but this transcript never
586
+ // watched it start.
587
+ status: { name, spawned: false, state: "active" },
588
+ };
589
+ change(held.status, held);
590
+ this.#teammates.set(name, held);
591
+ return true;
592
+ }
593
+
594
+ /** What the transcript itself says is running below the session.
595
+ *
596
+ * Direct children only, all at depth zero: a spawn is written in the file of
597
+ * whoever made it, so the session's own transcript names the agents it
598
+ * started and says nothing about what those in turn started. Depth, workflow
599
+ * membership and an agent's own last activity are read from the files the
600
+ * harness writes beside the transcript — a source this fold does not have —
601
+ * so the workflow group stays empty rather than being guessed at. */
602
+ #agentTree(): AgentTreeGroups {
603
+ const teammates: AgentTreeNode[] = [];
604
+ for (const held of this.#teammates.values()) {
605
+ const { status } = held;
606
+ if (held.agent_id === undefined) continue;
607
+ teammates.push({
608
+ agent_id: held.agent_id,
609
+ teammate_name: status.name,
610
+ spawn_depth: 0,
611
+ kind: "teammate",
612
+ state: status.state,
613
+ children: [],
614
+ ...optional("agent_type", status.agent_type),
615
+ ...optional("color", status.color),
616
+ ...optional("model", status.model),
617
+ ...optional("team_name", held.team_name),
618
+ ...optional(
619
+ "last_activity_at",
620
+ status.last_received_at ?? status.last_sent_at ?? status.spawned_at,
621
+ ),
622
+ });
623
+ }
624
+ return { teammates, agents: [...this.#agents.values()], workflows: [] };
625
+ }
626
+ }
627
+
628
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
629
+
630
+ /** A teammate as the fold holds it: what the contract states about it, and the
631
+ * two facts only the tree needs. */
632
+ interface Teammate {
633
+ readonly status: Mutable<SessionTeammate>;
634
+ agent_id?: string;
635
+ team_name?: string;
636
+ }
637
+
638
+ interface PendingCall {
639
+ readonly name: string;
640
+ readonly input: Record<string, unknown>;
641
+ readonly at?: Timestamp;
642
+ }
643
+
644
+ /** How many unanswered calls are worth holding. A turn makes a handful; a
645
+ * transcript read from its end can begin part-way through one, leaving calls
646
+ * whose results were never in the window. */
647
+ const MAX_PENDING_CALLS = 256;
648
+
649
+ /** The file tools, and the argument each names its file with. Observed on the
650
+ * harness's own calls rather than derived from a naming rule: a tool whose
651
+ * argument happens to be a path is not thereby a file tool. */
652
+ const FILE_INPUT: Record<string, string> = {
653
+ Read: "file_path",
654
+ Write: "file_path",
655
+ Edit: "file_path",
656
+ MultiEdit: "file_path",
657
+ NotebookEdit: "notebook_path",
658
+ };
659
+
660
+ /** The attachment kinds that carry a file, and the field the path is in. The
661
+ * other kinds attach text the harness composed, which names no file. */
662
+ const ATTACHMENT_FILE: Record<string, string> = {
663
+ edited_text_file: "filename",
664
+ file: "filename",
665
+ };
666
+
667
+ /** How the harness wraps a teammate's message on its way into the session. */
668
+ const RELAY = /<teammate-message[^>]*\steammate_id="([^"]*)"[^>]*>([\s\S]*)/;
669
+
670
+ /** The opening of the message a teammate sends when it has nothing to do. */
671
+ const IDLE = '{"type":"idle_notification"';
672
+
673
+ /** How a result says the thing it started is still going rather than already
674
+ * over: the call returned as soon as it was launched. A result that says
675
+ * nothing at all means the same. */
676
+ function launched(status: string | undefined): boolean {
677
+ return status === undefined || status === "async_launched";
678
+ }
679
+
680
+ /** Where a notification stops describing itself and starts quoting. */
681
+ function cut(content: string): number {
682
+ let end = content.length;
683
+ for (const opening of ["<summary>", "<event>", "<result>"]) {
684
+ const at = content.indexOf(opening);
685
+ if (at >= 0 && at < end) end = at;
686
+ }
687
+ return end;
688
+ }
689
+
690
+ function tagged(text: string, tag: string): string | undefined {
691
+ return str(new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(text)?.[1]);
692
+ }
693
+
694
+ /** A dependency list with what an update adds, in one order however either
695
+ * side spelled it. */
696
+ function merge(before: readonly string[] | undefined, added: unknown): string[] {
697
+ const all = new Set(before ?? []);
698
+ if (Array.isArray(added)) {
699
+ for (const each of added) {
700
+ const id = str(each);
701
+ if (id !== undefined) all.add(id);
702
+ }
703
+ }
704
+ return [...all].sort();
705
+ }
706
+
707
+ /** One transcript record, as the ops that read a whole transcript need it.
708
+ *
709
+ * The fold above follows a file as it grows; a search, a dump and a fork sweep
710
+ * read one that has stopped growing. Both are the same act of interpretation,
711
+ * so both live here: nothing outside this module turns a transcript line into
712
+ * meaning, and the harness's own spellings — its record types, its block
713
+ * kinds, its ISO instants — stop at this boundary (§3.5). */
714
+ export interface TranscriptRecord {
715
+ /** The record id a dump's bounds cut at. */
716
+ readonly uuid?: string;
717
+ readonly said_at?: Timestamp;
718
+ /** Who said it, in the contract's two words. Absent for a record that is
719
+ * neither side speaking — a title change, a summary, a harness note. */
720
+ readonly said_by?: "user" | "agent";
721
+ /** The words, with the thinking blocks kept apart so a dump can leave them
722
+ * out without a second reading of the row. */
723
+ readonly text?: string;
724
+ readonly thinking?: string;
725
+ /** A subagent's turn, interleaved into the session's own file. */
726
+ readonly sidechain: boolean;
727
+ /** Where the session ran, which the first records carry. */
728
+ readonly cwd?: string;
729
+ /** What `/rename` wrote, which is the only writer of a session's title. */
730
+ readonly title?: string;
731
+ /** What the turn ran as, in the transcript's own spelling. */
732
+ readonly model?: string;
733
+ readonly effort?: string;
734
+ }
735
+
736
+ /** Read one line. A line that is not a record — half-written, or not JSON at
737
+ * all — yields nothing, the same way the fold skips it. */
738
+ export function readRecord(line: string): TranscriptRecord | undefined {
739
+ let row: unknown;
740
+ try {
741
+ row = JSON.parse(line);
742
+ } catch {
743
+ return undefined;
744
+ }
745
+ if (!isRecord(row)) return undefined;
746
+ const type = str(row["type"]);
747
+ const message = isRecord(row["message"]) ? row["message"] : undefined;
748
+ const model = message === undefined ? undefined : str(message["model"]);
749
+ return {
750
+ sidechain: row["isSidechain"] === true,
751
+ ...optional("uuid", str(row["uuid"])),
752
+ ...optional("said_at", instant(row["timestamp"])),
753
+ ...optional("said_by", saidBy(type)),
754
+ ...optional("text", message === undefined ? undefined : blockText(message["content"])),
755
+ ...optional("thinking", message === undefined ? undefined : thinkingText(message["content"])),
756
+ ...optional("cwd", str(row["cwd"])),
757
+ ...optional("title", type === "custom-title" ? str(row["customTitle"]) : undefined),
758
+ ...optional("model", model === "<synthetic>" ? undefined : model),
759
+ ...optional("effort", str(row["effort"])),
760
+ };
761
+ }
762
+
763
+ /** The harness's record types, in the contract's two words. Its `assistant` is
764
+ * the contract's `agent`; every other type is a record neither side spoke. */
765
+ function saidBy(type: string | undefined): "user" | "agent" | undefined {
766
+ if (type === "user") return "user";
767
+ return type === "assistant" ? "agent" : undefined;
768
+ }
769
+
770
+ function optional<K extends string, V>(key: K, value: V | undefined): Record<K, V> | object {
771
+ return value === undefined ? {} : { [key]: value };
772
+ }
773
+
774
+ /** The text a message states, whoever wrote it. A plain prompt is a string; a
775
+ * prompt with an attachment, and every row the harness writes, is a block
776
+ * array whose text blocks carry the words. An array holding only tool results
777
+ * yields nothing, which is what a tool answering looks like, and an error row
778
+ * with several blocks reads as all of them rather than as its first line. */
779
+ function blockText(content: unknown): string | undefined {
780
+ if (typeof content === "string") return content.trim() || undefined;
781
+ if (!Array.isArray(content)) return undefined;
782
+ const parts: string[] = [];
783
+ for (const block of content) {
784
+ if (!isRecord(block) || block["type"] !== "text") continue;
785
+ const text = str(block["text"]);
786
+ if (text !== undefined) parts.push(text);
787
+ }
788
+ return parts.join("\n").trim() || undefined;
789
+ }
790
+
791
+ /** The model's own reasoning, which a dump may be asked to leave out. Kept
792
+ * apart from `text` rather than filtered out of it, so leaving it out is a
793
+ * field the dump does not write rather than a second pass over the blocks. */
794
+ function thinkingText(content: unknown): string | undefined {
795
+ if (!Array.isArray(content)) return undefined;
796
+ const parts: string[] = [];
797
+ for (const block of content) {
798
+ if (!isRecord(block) || block["type"] !== "thinking") continue;
799
+ const text = str(block["thinking"]);
800
+ if (text !== undefined) parts.push(text);
801
+ }
802
+ return parts.join("\n").trim() || undefined;
803
+ }
804
+
805
+ /** Openings observed on harness-written user rows that carry no marker of
806
+ * their own. A person's prompt can begin with anything, so these are matched
807
+ * against exactly rather than treated as a shape. */
808
+ const INJECTED_OPENINGS = [
809
+ "<",
810
+ "[SYSTEM NOTIFICATION - NOT USER INPUT]",
811
+ "Another Claude session sent a message:",
812
+ ];
813
+
814
+ function isHuman(text: string): boolean {
815
+ return !INJECTED_OPENINGS.some((opening) => text.startsWith(opening));
816
+ }
817
+
818
+ /** A transcript instant, in the contract's spelling. The harness writes ISO
819
+ * strings; the contract's `Timestamp` is Unix ms (§3.5). */
820
+ function instant(value: unknown): Timestamp | undefined {
821
+ const text = str(value);
822
+ if (text === undefined) return undefined;
823
+ const at = Date.parse(text);
824
+ return Number.isNaN(at) ? undefined : at;
825
+ }
826
+
827
+ function str(value: unknown): string | undefined {
828
+ return typeof value === "string" && value.length > 0 ? value : undefined;
829
+ }
830
+
831
+ function isRecord(value: unknown): value is Record<string, unknown> {
832
+ return typeof value === "object" && value !== null;
833
+ }