allaboard 0.0.0-stage → 0.4.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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/agent/claude/backend.js +124 -0
  4. package/dist/agent/claude/capabilities.js +30 -0
  5. package/dist/agent/claude/chrome.js +42 -0
  6. package/dist/agent/claude/hooks.js +37 -0
  7. package/dist/agent/claude/info.js +61 -0
  8. package/dist/agent/claude/listing.js +230 -0
  9. package/dist/agent/claude/module.js +82 -0
  10. package/dist/agent/claude/prompt.js +24 -0
  11. package/dist/agent/claude/record.js +9 -0
  12. package/dist/agent/claude/requests.js +122 -0
  13. package/dist/agent/claude/session.js +441 -0
  14. package/dist/agent/claude/translate.js +581 -0
  15. package/dist/agent/claude/trust.js +576 -0
  16. package/dist/agent/claude/usage-probe.js +103 -0
  17. package/dist/agent/claude/usage.js +110 -0
  18. package/dist/agent/claude/wire.js +22 -0
  19. package/dist/agent/seam.js +21 -0
  20. package/dist/chat/seam.js +10 -0
  21. package/dist/chat/slack/app/answers.js +209 -0
  22. package/dist/chat/slack/app/app.js +262 -0
  23. package/dist/chat/slack/app/clip.js +191 -0
  24. package/dist/chat/slack/app/guards.js +162 -0
  25. package/dist/chat/slack/app/home-controls.js +91 -0
  26. package/dist/chat/slack/app/messages.js +181 -0
  27. package/dist/chat/slack/app/open.js +392 -0
  28. package/dist/chat/slack/app/order.js +37 -0
  29. package/dist/chat/slack/app/requests.js +482 -0
  30. package/dist/chat/slack/app/resume-bind.js +71 -0
  31. package/dist/chat/slack/app/wire.js +83 -0
  32. package/dist/chat/slack/attachments.js +348 -0
  33. package/dist/chat/slack/bind.js +67 -0
  34. package/dist/chat/slack/channel.js +67 -0
  35. package/dist/chat/slack/channels.js +43 -0
  36. package/dist/chat/slack/delete.js +278 -0
  37. package/dist/chat/slack/footer.js +43 -0
  38. package/dist/chat/slack/hold.js +37 -0
  39. package/dist/chat/slack/home.js +1069 -0
  40. package/dist/chat/slack/module.js +174 -0
  41. package/dist/chat/slack/notice.js +64 -0
  42. package/dist/chat/slack/openfile/modal.js +362 -0
  43. package/dist/chat/slack/openfile/read.js +29 -0
  44. package/dist/chat/slack/quiet-logger.js +52 -0
  45. package/dist/chat/slack/repair.js +123 -0
  46. package/dist/chat/slack/reply/blocks.js +372 -0
  47. package/dist/chat/slack/reply/chars.js +59 -0
  48. package/dist/chat/slack/reply/clients.js +171 -0
  49. package/dist/chat/slack/reply/errors.js +125 -0
  50. package/dist/chat/slack/reply/escape.js +25 -0
  51. package/dist/chat/slack/reply/limiter.js +86 -0
  52. package/dist/chat/slack/reply/markdown.js +210 -0
  53. package/dist/chat/slack/reply/places.js +262 -0
  54. package/dist/chat/slack/reply/sinks.js +2403 -0
  55. package/dist/chat/slack/reply/status.js +433 -0
  56. package/dist/chat/slack/reply/tasks.js +11 -0
  57. package/dist/chat/slack/requests.js +552 -0
  58. package/dist/chat/slack/resume.js +127 -0
  59. package/dist/chat/slack/setup.js +114 -0
  60. package/dist/chat/slack/texts.js +185 -0
  61. package/dist/chat/slack/thread.js +537 -0
  62. package/dist/chat/slack/update.js +71 -0
  63. package/dist/chat/slack/voice.js +84 -0
  64. package/dist/cli.js +326 -0
  65. package/dist/clock.js +21 -0
  66. package/dist/core/cleanup.js +145 -0
  67. package/dist/core/commands.js +172 -0
  68. package/dist/core/config.js +174 -0
  69. package/dist/core/files/changed.js +125 -0
  70. package/dist/core/files/listing.js +382 -0
  71. package/dist/core/files/paths.js +9 -0
  72. package/dist/core/files/read.js +191 -0
  73. package/dist/core/files/waiting.js +34 -0
  74. package/dist/core/flows/answers.js +133 -0
  75. package/dist/core/flows/ask-owner.js +95 -0
  76. package/dist/core/flows/asking.js +14 -0
  77. package/dist/core/flows/bind.js +95 -0
  78. package/dist/core/flows/contract.js +1 -0
  79. package/dist/core/flows/flows.js +35 -0
  80. package/dist/core/flows/hold.js +22 -0
  81. package/dist/core/flows/inbound.js +24 -0
  82. package/dist/core/flows/lists.js +21 -0
  83. package/dist/core/flows/messages.js +293 -0
  84. package/dist/core/flows/notices.js +51 -0
  85. package/dist/core/flows/repair.js +106 -0
  86. package/dist/core/flows/resume.js +163 -0
  87. package/dist/core/flows/setup.js +130 -0
  88. package/dist/core/flows/status.js +98 -0
  89. package/dist/core/flows/talk.js +207 -0
  90. package/dist/core/flows/update.js +370 -0
  91. package/dist/core/flows/words.js +171 -0
  92. package/dist/core/folders.js +93 -0
  93. package/dist/core/footer.js +313 -0
  94. package/dist/core/guide.js +50 -0
  95. package/dist/core/hold.js +126 -0
  96. package/dist/core/instance.js +46 -0
  97. package/dist/core/lock.js +412 -0
  98. package/dist/core/models.js +10 -0
  99. package/dist/core/paths.js +75 -0
  100. package/dist/core/port/file-flows.js +1 -0
  101. package/dist/core/port/home.js +1 -0
  102. package/dist/core/port/requests.js +1 -0
  103. package/dist/core/port/threads.js +1 -0
  104. package/dist/core/port/updates.js +1 -0
  105. package/dist/core/port.js +17 -0
  106. package/dist/core/reply/fold.js +175 -0
  107. package/dist/core/reply/previews.js +258 -0
  108. package/dist/core/reply/renderer.js +846 -0
  109. package/dist/core/reply/tail.js +24 -0
  110. package/dist/core/reply/words.js +32 -0
  111. package/dist/core/requests.js +158 -0
  112. package/dist/core/sessions/constants.js +61 -0
  113. package/dist/core/sessions/deps.js +1 -0
  114. package/dist/core/sessions/directory.js +86 -0
  115. package/dist/core/sessions/errors.js +79 -0
  116. package/dist/core/sessions/manager.js +378 -0
  117. package/dist/core/sessions/session.js +3297 -0
  118. package/dist/core/sessions/tasks.js +266 -0
  119. package/dist/core/sessions/turn.js +45 -0
  120. package/dist/core/setup.js +28 -0
  121. package/dist/core/state.js +724 -0
  122. package/dist/core/texts.js +451 -0
  123. package/dist/core/updates.js +332 -0
  124. package/dist/daemon.js +566 -0
  125. package/dist/host/npm.js +134 -0
  126. package/dist/host/program.js +49 -0
  127. package/dist/host/run.js +52 -0
  128. package/dist/host/service/launchd.js +385 -0
  129. package/dist/host/service/listing.js +42 -0
  130. package/dist/host/service/seam.js +1 -0
  131. package/dist/log.js +37 -0
  132. package/dist/main.js +29 -0
  133. package/dist/node-version.js +40 -0
  134. package/dist/registry.js +12 -0
  135. package/dist/semver.js +53 -0
  136. package/dist/text.js +89 -0
  137. package/dist/updates-host.js +113 -0
  138. package/dist/values.js +30 -0
  139. package/dist/version.js +54 -0
  140. package/package.json +32 -4
  141. package/slack-app-manifest.json +50 -0
@@ -0,0 +1,581 @@
1
+ /**
2
+ * Claude Code's wire records into the daemon's session events.
3
+ *
4
+ * A record is one object as the CLI wrote it, which the Agent SDK's `query()` yields unchanged.
5
+ * Each is read as `unknown` and narrowed field by field. What is read here is what the renderer
6
+ * and the session need off the SDK's messages, so the core decides from events and never from a
7
+ * wire record.
8
+ *
9
+ * Three things need memory across records, and `Translator` keeps them for one session: which
10
+ * messages a stream announced, since Claude Code sends a streamed text again whole; which
11
+ * prompts the daemon sent, since a prompt comes back as a user record; and whether a compaction
12
+ * is under way, since the status record that ends one says so only to who knows it started.
13
+ */
14
+ import { strip } from "../../text.js";
15
+ import { isRecord, records } from "../../values.js";
16
+ import { integer, number, string, words } from "./wire.js";
17
+ // Each task_type (measured on Claude Code 2.1.280: `local_bash` for a background command,
18
+ // `local_agent` for a subagent). A type not listed is a task of no kind the daemon names.
19
+ const TASK_KINDS = new Map([
20
+ ["local_agent", "subagent"],
21
+ ["local_bash", "command"],
22
+ ]);
23
+ // The Python package's `TERMINAL_TASK_STATUSES` (claude-agent-sdk 0.2.165), which the TypeScript
24
+ // package does not export: a task can end with a `task_updated` of one of these and no
25
+ // notification.
26
+ const TERMINAL_TASK_STATUSES = new Set([
27
+ "completed",
28
+ "failed",
29
+ "stopped",
30
+ "killed",
31
+ ]);
32
+ const INTERRUPTED = new Set(["aborted_streaming", "aborted_tools"]);
33
+ const AUTHENTICATION_FAILED = "authentication_failed";
34
+ const SYNTHETIC_MODEL = "<synthetic>";
35
+ // The signs a line of a `structuredPatch` hunk opens with; the backslash is
36
+ // ``, about the line above.
37
+ const SIGNS = new Set(["-", "+", " ", "\\"]);
38
+ // Runs remembered for a notification that may never be folded (one a turn of its own reports).
39
+ const RUNS_KEPT = 200;
40
+ /**
41
+ * A record's kind: its `type`, with the `subtype` of a `system` record and the event type of a
42
+ * `stream_event`. Null for what is no record.
43
+ */
44
+ export function kindOf(record) {
45
+ if (!isRecord(record))
46
+ return null;
47
+ const type = string(record.type);
48
+ if (type === null)
49
+ return null;
50
+ if (type === "system")
51
+ return `system:${string(record.subtype) ?? ""}`;
52
+ if (type === "stream_event") {
53
+ return `stream_event:${isRecord(record.event) ? (string(record.event.type) ?? "") : ""}`;
54
+ }
55
+ return type;
56
+ }
57
+ function parentOf(record) {
58
+ return words(record.parent_tool_use_id);
59
+ }
60
+ // Stream events: the Claude API's streaming events, passed through unparsed.
61
+ function messageStart(memory, record) {
62
+ const event = isRecord(record.event) ? record.event : {};
63
+ const messageId = isRecord(event.message) ? string(event.message.id) : null;
64
+ const parentCallId = parentOf(record);
65
+ if (parentCallId === null)
66
+ memory.open = true;
67
+ if (messageId !== null)
68
+ memory.streamed.add(messageId);
69
+ else if (parentCallId === null)
70
+ memory.unnamed = true;
71
+ return [{ type: "message_started", parentCallId }];
72
+ }
73
+ function messageStop(memory, record) {
74
+ const parentCallId = parentOf(record);
75
+ if (parentCallId === null)
76
+ memory.open = false;
77
+ return [{ type: "message_ended", parentCallId }];
78
+ }
79
+ // A subagent's text never reaches a reply, so no text event carries a parent call.
80
+ function contentBlockStart(_memory, record) {
81
+ const event = isRecord(record.event) ? record.event : {};
82
+ if (!isRecord(event.content_block) || event.content_block.type !== "text")
83
+ return [];
84
+ if (parentOf(record) !== null)
85
+ return [];
86
+ return [{ type: "text_started" }];
87
+ }
88
+ function contentBlockDelta(_memory, record) {
89
+ const event = isRecord(record.event) ? record.event : {};
90
+ if (!isRecord(event.delta) || event.delta.type !== "text_delta")
91
+ return [];
92
+ const text = words(event.delta.text);
93
+ if (text === null || parentOf(record) !== null)
94
+ return [];
95
+ return [{ type: "text_delta", text }];
96
+ }
97
+ // Messages.
98
+ /** The text of a message's text blocks, joined and trimmed. */
99
+ function wordsOf(blocks) {
100
+ return strip(blocks
101
+ .filter((block) => block.type === "text")
102
+ .map((block) => string(block.text) ?? "")
103
+ .join(""));
104
+ }
105
+ /**
106
+ * Whether a message's text reached the core through no stream event. At the top level, never
107
+ * while a streamed message is unfinished (its text may be this one's, sent again whole after a
108
+ * failed stream, which no recording shows) or once a stream named no id.
109
+ */
110
+ function unannounced(memory, messageId) {
111
+ return !memory.streamed.has(messageId) && !memory.open && !memory.unnamed;
112
+ }
113
+ function callStarted(block, parentCallId) {
114
+ const callId = string(block.id);
115
+ const toolName = string(block.name);
116
+ if (callId === null || toolName === null)
117
+ return [];
118
+ const input = isRecord(block.input) ? block.input : {};
119
+ return [{ type: "call_started", callId, toolName, input, parentCallId }];
120
+ }
121
+ /**
122
+ * The text of a tool result, whatever its form: a string, the text parts of a list a line each,
123
+ * or the type of a single object (a server tool's result). Null for anything else.
124
+ */
125
+ function resultText(content) {
126
+ if (typeof content === "string")
127
+ return content;
128
+ if (Array.isArray(content)) {
129
+ return records(content)
130
+ .map((part) => string(part.text) ?? "")
131
+ .join("\n");
132
+ }
133
+ if (isRecord(content))
134
+ return string(content.type) ?? "";
135
+ return null;
136
+ }
137
+ function callEnded(block, parentCallId, isError, change, model = null) {
138
+ const callId = string(block.tool_use_id);
139
+ if (callId === null)
140
+ return [];
141
+ return [
142
+ {
143
+ type: "call_ended",
144
+ callId,
145
+ isError,
146
+ output: resultText(block.content),
147
+ fileChange: change,
148
+ model,
149
+ parentCallId,
150
+ },
151
+ ];
152
+ }
153
+ function assistant(memory, record) {
154
+ const message = isRecord(record.message) ? record.message : {};
155
+ if (!Array.isArray(message.content))
156
+ return [];
157
+ const blocks = records(message.content);
158
+ const parentCallId = parentOf(record);
159
+ const text = wordsOf(blocks);
160
+ if (record.error !== undefined && record.error !== null) {
161
+ // Claude Code wrote this message itself, about a request of its own that failed: its words
162
+ // name the failure and what to do next, and nothing else in it is a call or an answer. In
163
+ // the recorded 529 they come in this message with no stream event of their own.
164
+ const category = String(record.error);
165
+ const kind = category === AUTHENTICATION_FAILED ? "authentication" : "other";
166
+ return [{ type: "agent_error", kind, category, text, parentCallId }];
167
+ }
168
+ const events = [];
169
+ // A message Claude Code wrote itself (a local command's answer, recorded: `usage.jsonl`) names
170
+ // `<synthetic>`, no model the session runs on.
171
+ const model = words(message.model);
172
+ if (parentCallId === null && model !== null && model !== SYNTHETIC_MODEL) {
173
+ memory.mainModel = model;
174
+ }
175
+ const messageId = string(message.id);
176
+ if (text !== "" &&
177
+ parentCallId === null &&
178
+ messageId !== null &&
179
+ unannounced(memory, messageId)) {
180
+ // Text no stream event announced is a command's own output (`Goal set: …` before a goal's
181
+ // first inner turn, recorded: `goal.jsonl`). A subagent's is left out: no reply shows it.
182
+ events.push({ type: "text", text });
183
+ }
184
+ for (const block of blocks) {
185
+ if (block.type === "tool_use" || block.type === "server_tool_use") {
186
+ events.push(...callStarted(block, parentCallId));
187
+ }
188
+ else if (block.type === "tool_result") {
189
+ events.push(...callEnded(block, parentCallId, block.is_error === true, null));
190
+ }
191
+ else if (block.type === "advisor_tool_result") {
192
+ // The one server tool result read, as the Python SDK's `parse_message` keeps this one
193
+ // alone (claude-agent-sdk 0.2.165). It carries no error flag.
194
+ events.push(...callEnded(block, parentCallId, false, null));
195
+ }
196
+ }
197
+ return events;
198
+ }
199
+ function hunkOf(value) {
200
+ if (!isRecord(value))
201
+ return null;
202
+ const oldStart = integer(value.oldStart);
203
+ const newStart = integer(value.newStart);
204
+ if (oldStart === null || newStart === null || !Array.isArray(value.lines))
205
+ return null;
206
+ const lines = [];
207
+ for (const line of value.lines) {
208
+ if (typeof line !== "string" || !SIGNS.has(line.charAt(0)))
209
+ return null;
210
+ lines.push(line);
211
+ }
212
+ return { oldStart, newStart, lines };
213
+ }
214
+ function hunksOf(patch) {
215
+ if (!Array.isArray(patch))
216
+ return null;
217
+ const hunks = [];
218
+ for (const entry of patch) {
219
+ const hunk = hunkOf(entry);
220
+ if (hunk === null)
221
+ return null;
222
+ hunks.push(hunk);
223
+ }
224
+ return hunks;
225
+ }
226
+ /**
227
+ * What an `Edit` or a `Write` did to a file, from `tool_use_result`: a shape the SDK types as
228
+ * `unknown` and does not document, measured on Claude Code 2.1.283 (2026-09-27,
229
+ * `test/fixtures/sdk/edit-write.jsonl`). Any other shape gives null, and the core shows the
230
+ * call's generic line.
231
+ */
232
+ export function fileChange(result) {
233
+ if (!isRecord(result))
234
+ return null;
235
+ const path = string(result.filePath);
236
+ if (path === null)
237
+ return null;
238
+ const hunks = hunksOf(result.structuredPatch);
239
+ if (result.type === "create") {
240
+ const content = string(result.content);
241
+ if (content === null)
242
+ return null;
243
+ return { path, kind: "created", hunks: hunks ?? [], content };
244
+ }
245
+ if (hunks === null || hunks.length === 0)
246
+ return null;
247
+ return { path, kind: "edited", hunks, content: null };
248
+ }
249
+ /**
250
+ * The model an Agent call's agent ran on, from `resolvedModel` of the call's `tool_use_result`
251
+ * (the SDK's `AgentOutput`, SDK 0.3.300; measured on `subagent`, `subagent-foreground` and
252
+ * `background-forms`), when it is not the model of the main thread's last message, which made
253
+ * the call: the terminal names a subagent's model only then (captured on Claude Code 2.1.300,
254
+ * 2026-10-15). Null for any other result and when either model is unknown.
255
+ */
256
+ function agentModel(memory, result) {
257
+ if (!isRecord(result) || memory.mainModel === null)
258
+ return null;
259
+ const ran = words(result.resolvedModel);
260
+ return ran === memory.mainModel ? null : ran;
261
+ }
262
+ function user(memory, record) {
263
+ const promptId = string(record.uuid);
264
+ if (promptId !== null && memory.sent.has(promptId)) {
265
+ // The replay of a prompt this session sent (`replay-user-messages`): it shows nothing. A
266
+ // uuid that names no prompt of ours (a resumed session's, a command's output) is none.
267
+ return [{ type: "prompt_taken", promptId }];
268
+ }
269
+ // A background subagent hands its report back as a message from a peer that names its task
270
+ // (`SDKMessageOrigin`, kind `peer`, `senderTaskId`), and the agent answers it in a turn of
271
+ // its own: measured 2026-10-10 on Claude Code 2.1.294 and 2.1.296 with the owner's setting
272
+ // sources loaded (`test/fixtures/sdk/subagent-handback.jsonl`). A peer with no task of this
273
+ // session (another session's message) says nothing about a task's report.
274
+ const origin = isRecord(record.origin) ? record.origin : {};
275
+ const sender = origin.kind === "peer" ? string(origin.senderTaskId) : null;
276
+ if (sender !== null)
277
+ return [{ type: "report_started", taskId: sender }];
278
+ // A task's notification folded into the running turn is replayed with the run it reports
279
+ // (recorded 2026-10-15, `task-end-in-turn-folded`); one a turn of its own reports is not
280
+ // (`task-end-in-turn-unfolded`), nor is a Monitor's line, which names no run.
281
+ const runId = origin.kind === "task-notification" ? string(origin.runId) : null;
282
+ const folded = runId === null ? undefined : memory.runs.get(runId);
283
+ if (runId !== null && folded !== undefined) {
284
+ memory.runs.delete(runId);
285
+ return [{ type: "notification_folded", taskId: folded }];
286
+ }
287
+ const message = isRecord(record.message) ? record.message : {};
288
+ if (!Array.isArray(message.content))
289
+ return [];
290
+ const blocks = records(message.content);
291
+ const parentCallId = parentOf(record);
292
+ // One `tool_use_result` per message: it belongs to a result only when it is alone.
293
+ const results = blocks.filter((block) => block.type === "tool_result");
294
+ const change = results.length === 1 ? fileChange(record.tool_use_result) : null;
295
+ const model = results.length === 1 ? agentModel(memory, record.tool_use_result) : null;
296
+ const events = [];
297
+ for (const block of blocks) {
298
+ if (block.type === "tool_use")
299
+ events.push(...callStarted(block, parentCallId));
300
+ else if (block.type === "tool_result") {
301
+ events.push(...callEnded(block, parentCallId, block.is_error === true, change, model));
302
+ }
303
+ }
304
+ return events;
305
+ }
306
+ // System records.
307
+ function init(_memory, record) {
308
+ return [
309
+ {
310
+ type: "session_started",
311
+ sessionId: string(record.session_id),
312
+ agentVersion: string(record.claude_code_version),
313
+ },
314
+ ];
315
+ }
316
+ function status(memory, record) {
317
+ const events = [];
318
+ // Claude Code reports the permission mode after a change (measured after each
319
+ // `set_permission_mode`).
320
+ const mode = string(record.permissionMode);
321
+ if (mode !== null)
322
+ events.push({ type: "mode_changed", mode });
323
+ // A compaction opens with `status` `compacting` and ends 14 to 37 seconds later in the
324
+ // recordings (2026-10-08, CLI 2.1.292) with a `status` record that carries `compact_result`.
325
+ // The record that reports a permission mode carries neither, so one that arrives meanwhile
326
+ // ends nothing.
327
+ const reported = record.status ?? null;
328
+ const compacting = reported === "compacting" ||
329
+ (memory.compacting && reported === null && !Object.hasOwn(record, "compact_result"));
330
+ if (compacting !== memory.compacting) {
331
+ memory.compacting = compacting;
332
+ events.push(compacting ? { type: "compaction_started" } : { type: "compaction_ended" });
333
+ }
334
+ return events;
335
+ }
336
+ // `running` comes 0.5 seconds after a prompt and 0.07 seconds after the notification of a
337
+ // background command, about a second before the turn's `message_start`; `idle` right after a
338
+ // turn's result, an interrupted one too, and only after the report of a background subagent,
339
+ // with none in between (recorded 2026-10-14, CLI 2.1.296: `session-state-shell`,
340
+ // `session-state-agent`, `session-state-approval`).
341
+ const AGENT_STATES = new Map([
342
+ ["running", "working"],
343
+ ["requires_action", "waiting"],
344
+ ["idle", "idle"],
345
+ ]);
346
+ function sessionState(_memory, record) {
347
+ const state = AGENT_STATES.get(string(record.state) ?? "");
348
+ return state === undefined ? [] : [{ type: "state_changed", state }];
349
+ }
350
+ function compactBoundary(_memory, record) {
351
+ const metadata = isRecord(record.compact_metadata) ? record.compact_metadata : {};
352
+ return [
353
+ {
354
+ type: "compacted",
355
+ tokensBefore: integer(metadata.pre_tokens),
356
+ tokensAfter: integer(metadata.post_tokens),
357
+ },
358
+ ];
359
+ }
360
+ function taskStarted(memory, record) {
361
+ const taskId = string(record.task_id);
362
+ const description = string(record.description);
363
+ if (taskId === null || description === null)
364
+ return [];
365
+ // Absent for a task this process did not register (`SDKTaskStartedMessage.run_id`).
366
+ const runId = string(record.run_id);
367
+ if (runId !== null) {
368
+ memory.runs.set(runId, taskId);
369
+ for (const kept of memory.runs.keys()) {
370
+ if (memory.runs.size <= RUNS_KEPT)
371
+ break;
372
+ memory.runs.delete(kept);
373
+ }
374
+ }
375
+ const taskType = words(record.task_type);
376
+ return [
377
+ {
378
+ type: "task_started",
379
+ taskId,
380
+ kind: TASK_KINDS.get(taskType ?? "") ?? "other",
381
+ taskType,
382
+ description,
383
+ callId: words(record.tool_use_id),
384
+ agentType: words(record.subagent_type),
385
+ background: typeof record.is_backgrounded === "boolean" ? record.is_backgrounded : null,
386
+ parentTaskId: words(record.parent_task_id),
387
+ },
388
+ ];
389
+ }
390
+ function taskProgress(_memory, record) {
391
+ const taskId = string(record.task_id);
392
+ const description = string(record.description);
393
+ if (taskId === null || description === null)
394
+ return [];
395
+ return [{ type: "task_progress", taskId, description, callId: words(record.tool_use_id) }];
396
+ }
397
+ function taskUpdated(_memory, record) {
398
+ // Terminal task completion sometimes arrives only as a `task_updated` patch, with no
399
+ // notification (Python SDK, `parse_message`): the status is the patch's, when it has one.
400
+ const patch = isRecord(record.patch) ? record.patch : {};
401
+ const reported = string(patch.status);
402
+ return [
403
+ {
404
+ type: "task_updated",
405
+ taskId: string(record.task_id) ?? "",
406
+ status: reported,
407
+ terminal: reported !== null && TERMINAL_TASK_STATUSES.has(reported),
408
+ },
409
+ ];
410
+ }
411
+ function taskNotification(memory, record) {
412
+ const taskId = string(record.task_id);
413
+ const reported = string(record.status);
414
+ const summary = string(record.summary);
415
+ if (taskId === null || reported === null || summary === null)
416
+ return [];
417
+ const usage = isRecord(record.usage) ? record.usage : {};
418
+ // The replay of a folded notification names the run, which the task's start recorded: a task
419
+ // whose run this process never saw cannot be named (`SDKTaskStartedMessage.run_id`).
420
+ const runId = string(record.run_id);
421
+ return [
422
+ {
423
+ type: "task_ended",
424
+ taskId,
425
+ status: reported,
426
+ summary,
427
+ durationMs: number(usage.duration_ms),
428
+ callId: words(record.tool_use_id),
429
+ foldNamed: runId !== null && memory.runs.has(runId),
430
+ },
431
+ ];
432
+ }
433
+ // The rest.
434
+ function rateLimit() {
435
+ return [{ type: "limits_changed" }];
436
+ }
437
+ function tokensOf(usage) {
438
+ const tokens = {};
439
+ if (!isRecord(usage))
440
+ return tokens;
441
+ for (const [model, counts] of Object.entries(usage)) {
442
+ if (!isRecord(counts))
443
+ continue;
444
+ // A count a model does not report (no cache, say) counts as none.
445
+ tokens[model] = {
446
+ input: number(counts.inputTokens) ?? 0,
447
+ output: number(counts.outputTokens) ?? 0,
448
+ cacheRead: number(counts.cacheReadInputTokens) ?? 0,
449
+ cacheCreation: number(counts.cacheCreationInputTokens) ?? 0,
450
+ };
451
+ }
452
+ return tokens;
453
+ }
454
+ function endingOf(record) {
455
+ const reason = string(record.terminal_reason);
456
+ if (reason !== null && INTERRUPTED.has(reason))
457
+ return "interrupted";
458
+ return record.is_error === true ? "error" : "done";
459
+ }
460
+ // The outputs of `/effort` and `/model`, measured on Claude Code 2.1.280 (2026-09-23) and 2.1.301
461
+ // (2026-10-16); the "with X effort" form is the one ccstatusline reads from transcripts.
462
+ const EFFORT_OUTPUT = /^(?:Set effort level to|Effort level set to) ([a-z0-9-]+)/i;
463
+ const MODEL_OUTPUT = /^Set model to\b(?:.*? with ([a-z0-9-]+) effort)?/is;
464
+ /**
465
+ * The effort a result's command set. `/effort` and `/model` run no Stop hook (2.1.280) and no
466
+ * message carries the level, so it is read from the output, and only from the result that names
467
+ * the command in `local_command`: the text of any other turn is the model's. The result of a
468
+ * command that changed nothing (a failure, a read) gives no event; a model change that names no
469
+ * effort gives a null level.
470
+ */
471
+ export function effortChanged(record) {
472
+ const command = string(record.local_command);
473
+ const output = strip(string(record.result) ?? "");
474
+ if (command === "effort") {
475
+ const set = EFFORT_OUTPUT.exec(output);
476
+ return set ? [{ type: "effort_changed", level: set[1].toLowerCase() }] : [];
477
+ }
478
+ if (command === "model") {
479
+ const set = MODEL_OUTPUT.exec(output);
480
+ return set ? [{ type: "effort_changed", level: set[1]?.toLowerCase() ?? null }] : [];
481
+ }
482
+ return [];
483
+ }
484
+ function result(memory, record) {
485
+ // The stream's memory is a turn's: an interrupted stream has no `message_stop`, and the next
486
+ // turn must not read as still inside it.
487
+ memory.streamed.clear();
488
+ memory.open = false;
489
+ memory.unnamed = false;
490
+ memory.compacting = false;
491
+ // Claude Code started the turn itself (a task notification, say) when the origin names
492
+ // anything but the owner: none or `human` is the owner's own prompt.
493
+ const origin = isRecord(record.origin) ? string(record.origin.kind) : null;
494
+ return [
495
+ ...effortChanged(record),
496
+ {
497
+ type: "turn_ended",
498
+ sessionId: words(record.session_id),
499
+ startedBy: origin === null || origin === "human" ? "owner" : "agent",
500
+ finalText: string(record.result),
501
+ ending: endingOf(record),
502
+ // 0 for the turn a task's notification gets once its report was already handed back.
503
+ steps: typeof record.num_turns === "number" ? record.num_turns : null,
504
+ tokens: tokensOf(record.modelUsage),
505
+ },
506
+ ];
507
+ }
508
+ const HANDLERS = {
509
+ assistant,
510
+ rate_limit_event: rateLimit,
511
+ result,
512
+ "stream_event:content_block_delta": contentBlockDelta,
513
+ "stream_event:content_block_start": contentBlockStart,
514
+ "stream_event:message_start": messageStart,
515
+ "stream_event:message_stop": messageStop,
516
+ "system:compact_boundary": compactBoundary,
517
+ "system:init": init,
518
+ "system:session_state_changed": sessionState,
519
+ "system:status": status,
520
+ "system:task_notification": taskNotification,
521
+ "system:task_progress": taskProgress,
522
+ "system:task_started": taskStarted,
523
+ "system:task_updated": taskUpdated,
524
+ user,
525
+ };
526
+ /** The kinds of record that can give an event. */
527
+ export const TRANSLATED_KINDS = Object.keys(HANDLERS);
528
+ /**
529
+ * The recorded kinds the daemon does not read: nothing in the reply or in the session depends
530
+ * on them. A kind in neither list is new, and `test/agent/claude/translate.test.ts` fails on the
531
+ * recording that carries it.
532
+ */
533
+ export const UNREAD_KINDS = [
534
+ // The queue of a prompt sent while a turn runs; absent from the SDK's union of messages.
535
+ "command_lifecycle",
536
+ // `/clear`: the new session id comes with the `init` that follows.
537
+ "conversation_reset",
538
+ // The end of a content block and a message's stop reason: neither shows anything.
539
+ "stream_event:content_block_stop",
540
+ "stream_event:message_delta",
541
+ // A request being retried: the message about the failure follows when the retries run out.
542
+ "system:api_retry",
543
+ // The list of background tasks: the task records say the same, one task at a time.
544
+ "system:background_tasks_changed",
545
+ "system:thinking_tokens",
546
+ // A subagent's heartbeat while its call runs.
547
+ "tool_progress",
548
+ ];
549
+ function handlerOf(kind) {
550
+ return kind !== null && Object.hasOwn(HANDLERS, kind)
551
+ ? HANDLERS[kind]
552
+ : null;
553
+ }
554
+ /**
555
+ * One session's translator, as a class: it has two entry points over one memory (`translate`
556
+ * for what arrives, `promptSent` for what leaves), and the session wrapper holds exactly one
557
+ * for as long as its process lives.
558
+ */
559
+ export class Translator {
560
+ #memory = {
561
+ sent: new Set(),
562
+ mainModel: null,
563
+ streamed: new Set(),
564
+ open: false,
565
+ unnamed: false,
566
+ compacting: false,
567
+ runs: new Map(),
568
+ };
569
+ /** The daemon is sending a prompt under `promptId`: its replay will be `prompt_taken`. */
570
+ promptSent(promptId) {
571
+ this.#memory.sent.add(promptId);
572
+ }
573
+ /**
574
+ * The events one record gives, in the order the reply acts on them. A record of a kind the
575
+ * daemon does not read, or of none it knows, gives none.
576
+ */
577
+ translate(record) {
578
+ const handler = handlerOf(kindOf(record));
579
+ return handler !== null && isRecord(record) ? handler(this.#memory, record) : [];
580
+ }
581
+ }