allaboard 0.0.0-stage → 0.5.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,846 @@
1
+ import { monotonicClock } from "../../clock.js";
2
+ import { blank } from "../../text.js";
3
+ import { formatTokens } from "../footer.js";
4
+ import * as texts from "../texts.js";
5
+ import { ASKERS, answered, commandLine, continued, described, LAUNCHERS, launchedTogether, PREVIEWED, preview, resuming, } from "./previews.js";
6
+ import { BACKGROUND, oneLine, STOPPED, splitLines, TITLE_LIMIT } from "./words.js";
7
+ export const OUTPUT_LIMIT = 200;
8
+ export const CHILD_LINES = 10;
9
+ function line(id, title, status, fields = {}) {
10
+ return {
11
+ id,
12
+ title,
13
+ status,
14
+ details: null,
15
+ output: null,
16
+ name: "",
17
+ task: false,
18
+ calls: 0,
19
+ preview: null,
20
+ folded: null,
21
+ startedAt: null,
22
+ ...fields,
23
+ };
24
+ }
25
+ /** `Name: first non-empty string argument`, whatever the tool. */
26
+ export function taskTitle(name, input) {
27
+ for (const value of Object.values(input)) {
28
+ if (typeof value === "string" && !blank(value)) {
29
+ return oneLine(`${name}: ${value}`, TITLE_LIMIT);
30
+ }
31
+ }
32
+ return name;
33
+ }
34
+ /** A result's text as a line of its call: its first line that says something, or null. */
35
+ export function resultSummary(output) {
36
+ if (output === null)
37
+ return null;
38
+ const first = splitLines(output).find((text) => !blank(text)) ?? "";
39
+ return oneLine(first, OUTPUT_LIMIT) || null;
40
+ }
41
+ export function formatDuration(seconds) {
42
+ const whole = Math.trunc(seconds);
43
+ if (whole < 60)
44
+ return `${whole}s`;
45
+ if (whole < 3600)
46
+ return `${Math.floor(whole / 60)}m ${whole % 60}s`;
47
+ return `${Math.floor(whole / 3600)}h ${Math.floor((whole % 3600) / 60)}m`;
48
+ }
49
+ /**
50
+ * How a task's end opens the agent's report of it: the notification's own summary, as the
51
+ * terminal prints it (`Agent "..." finished · 10s`), with the duration when there is one.
52
+ */
53
+ export function endedLine(summary, status, durationMs) {
54
+ const said = `${status === "failed" ? "✗" : "✓"} ${summary}`;
55
+ return durationMs === null ? said : `${said} · ${formatDuration(durationMs / 1000)}`;
56
+ }
57
+ export function terminalStatus(status) {
58
+ if (status === "failed")
59
+ return ["error", null];
60
+ if (status === "stopped" || status === "killed")
61
+ return ["complete", STOPPED];
62
+ return ["complete", null];
63
+ }
64
+ export class TurnRenderer {
65
+ #sink;
66
+ /** The session's folder, which previews name paths from. */
67
+ #cwd;
68
+ /** The names the texts that name the agent or the chat take. */
69
+ #names;
70
+ #lines = new Map();
71
+ /** The lines the sink has been given: a held call's line is in `#lines` before it is drawn. */
72
+ #drawn = new Set();
73
+ #rootOf = new Map();
74
+ #children = new Map();
75
+ #lineOfTask = new Map();
76
+ // Tasks started and not yet ended (task id -> line id). Only the lifecycle events say this
77
+ // reliably: a subagent can move to the background without a second `task_started`.
78
+ #running = new Map();
79
+ /** Lines of tasks no call started (a command's). */
80
+ #commands = new Set();
81
+ /** Tasks shown on a command's line, not on their own. */
82
+ #nested = new Set();
83
+ // Tasks a call inside another call started (a long command a subagent runs), held aside
84
+ // while that call is open: the root's work, shown on its card through `#child`. A task
85
+ // that ends before its call's result stays here, dropped; one still running at the result
86
+ // outlives its call and leaves (`#outlived`). Kept after the end, so a later event of a
87
+ // dropped one (a `task_updated` after its notification) still finds it.
88
+ #inside = new Set();
89
+ /** The held-aside tasks not yet ended. */
90
+ #aside = new Map();
91
+ /** Nested calls whose result arrived. */
92
+ #closedCalls = new Set();
93
+ /** Calls of a tool that starts agents, kept from the sink until their task says how it runs. */
94
+ #undecided = new Set();
95
+ // Calls whose agent runs in the background, and lines of tasks in the background no call of
96
+ // this reply started: no card. What the agent does under such a call writes nothing, as in
97
+ // the terminal.
98
+ #launched = new Set();
99
+ // The tasks started in the background whose launch line is not written yet. The terminal
100
+ // writes one line for the agents of one message, so they wait for the message to end and for
101
+ // every held call of it to say how its agent runs; the cards of the message's calls come
102
+ // before them, and anything else the reply writes after them.
103
+ #launches = [];
104
+ // The calls whose agent launched in the background and whose result has not come: it names
105
+ // the model the agent runs on, so their launch line waits for it. `close` and the notes that
106
+ // follow a launch write the line without it when the result never comes.
107
+ #awaiting = new Set();
108
+ /** Tasks whose call has not ended: in the foreground, as far as the reply knows. */
109
+ #waited = new Set();
110
+ /** A message of the agent's is being written, at the top level. */
111
+ #messageOpen = false;
112
+ /** A line of the daemon's was written into the reply. */
113
+ #wroteLine = false;
114
+ /** What kind of task each one is, as it said when it started. */
115
+ #kinds = new Map();
116
+ // Tasks in the background under a call that stays a call, counted in its run like any other
117
+ // (captured on Claude Code 2.1.296, 2026-10-14): no card of their own. Each shows by its launch
118
+ // line, in the list of what still runs and by its end line.
119
+ #quiet = new Set();
120
+ // A line of the daemon's inside the reply (an agent's launch, a task's end) is the last thing
121
+ // written. It ends with no break of its own, since a card that follows needs none and Slack
122
+ // draws the empty lines; text that follows is set a paragraph apart.
123
+ #breakOwed = false;
124
+ /** `takePromoted`'s, not yet taken. */
125
+ #promoted = [];
126
+ /** A question's answers, until its call ends. */
127
+ #answers = new Map();
128
+ #wroteText = false;
129
+ /** Whether the chat shows a text as it is written; without it a text is held until it ends. */
130
+ #streaming;
131
+ /** The pieces of the text being written, not yet handed to the sink (`streaming` off). */
132
+ #held = "";
133
+ /** The agent's text is the last thing in the reply, no card since. */
134
+ #afterText = false;
135
+ /** The text that just started follows text directly. */
136
+ #breakDue = false;
137
+ /** The end of the last turn fed here, or null: how it ended and its last text. */
138
+ result = null;
139
+ authFailed = false;
140
+ /** The category of the last message the agent wrote itself about a failure, if any. */
141
+ error = null;
142
+ #closedOut = false;
143
+ /** Whether the session saw a task start before this reply did (an agent a message continues). */
144
+ #startedBefore;
145
+ /** The name of a model by its id, for the model an agent ran on. */
146
+ #modelName;
147
+ /** Whether the session saw the task start in an earlier reply and it has ended since. */
148
+ #endedBefore;
149
+ /** Seconds on the clock the chat compares `TaskUpdate.startedAt` with. */
150
+ #now;
151
+ // Calls that continue an agent that has ended: a line of words and no card, kept by id so that a
152
+ // call that fails can still draw the card that says why.
153
+ #resumed = new Map();
154
+ constructor(sink, names, cwd = null, streaming = true, startedBefore = () => false, modelName = (id) => id, endedBefore = () => false, now = () => monotonicClock.time()) {
155
+ this.#sink = sink;
156
+ this.#names = names;
157
+ this.#cwd = cwd;
158
+ this.#streaming = streaming;
159
+ this.#startedBefore = startedBefore;
160
+ this.#modelName = modelName;
161
+ this.#endedBefore = endedBefore;
162
+ this.#now = now;
163
+ }
164
+ /** One event of the session into the reply. An event that shows nothing is left out. */
165
+ async feed(event) {
166
+ switch (event.type) {
167
+ case "text_started":
168
+ // Two texts with no card between them (a goal's inner turns, a Stop hook that
169
+ // continues the turn) would run together: the break waits for the first piece, so a
170
+ // text that stays empty adds none.
171
+ await this.#handOver(); // the text before this one has ended
172
+ this.#breakDue = this.#afterText;
173
+ break;
174
+ case "text_delta": {
175
+ const broken = this.#breakDue;
176
+ this.#breakDue = false;
177
+ await this.#text(broken ? `\n\n${event.text}` : event.text, { piece: true });
178
+ break;
179
+ }
180
+ case "message_started":
181
+ if (event.parentCallId === null)
182
+ this.#messageOpen = true;
183
+ break;
184
+ case "message_ended":
185
+ if (event.parentCallId === null) {
186
+ await this.#handOver();
187
+ this.#messageOpen = false;
188
+ await this.#settleLaunches();
189
+ }
190
+ break;
191
+ case "text":
192
+ // Text that came whole is a command's own output (`Goal set: …` before a goal's first
193
+ // inner turn): like a text that follows another, it needs a break only when text is
194
+ // the last thing written.
195
+ await this.#text((this.#afterText ? "\n\n" : "") + event.text);
196
+ break;
197
+ case "agent_error":
198
+ await this.#failed(event);
199
+ break;
200
+ case "call_started":
201
+ await this.#callStarted(event);
202
+ break;
203
+ case "call_ended":
204
+ await this.#callEnded(event);
205
+ break;
206
+ case "task_started":
207
+ await this.#taskStarted(event);
208
+ break;
209
+ case "task_progress": {
210
+ // A task with no line of its own writes nothing: its call's line, if it had one, is
211
+ // an ended call's.
212
+ const entry = this.lineless(event.taskId)
213
+ ? undefined
214
+ : this.#lines.get(this.#lineOfTask.get(event.taskId) ?? "");
215
+ if (entry !== undefined) {
216
+ await this.#set({ ...entry, details: oneLine(event.description, OUTPUT_LIMIT) });
217
+ }
218
+ break;
219
+ }
220
+ case "task_ended":
221
+ await this.#taskEnded(event.taskId, event.status, event.summary);
222
+ break;
223
+ case "task_updated":
224
+ if (event.terminal && event.status !== null) {
225
+ await this.#taskEnded(event.taskId, event.status, null);
226
+ }
227
+ break;
228
+ case "compacted":
229
+ await this.#compacted(event.tokensBefore, event.tokensAfter);
230
+ break;
231
+ case "turn_ended":
232
+ this.result = event;
233
+ if (!this.#wroteText && event.finalText) {
234
+ await this.#text(event.finalText);
235
+ }
236
+ else if (!this.#wroteText && this.error !== null) {
237
+ await this.#text(texts.fill(texts.named(texts.ERROR_REPLY, this.#names), { error: this.error }), { notice: true });
238
+ }
239
+ break;
240
+ default:
241
+ break;
242
+ }
243
+ }
244
+ /** Ids of the tasks that outlive the turn; a later task event fed here updates them. */
245
+ get runningTasks() {
246
+ return [...this.#running.keys()];
247
+ }
248
+ /**
249
+ * The running tasks that are in the background: every one but a task whose call still waits
250
+ * for it (a long command in the foreground, a subagent the call waits for), which the reply
251
+ * shows on that call's line.
252
+ */
253
+ get backgroundTasks() {
254
+ return this.runningTasks.filter((taskId) => !this.#waited.has(taskId));
255
+ }
256
+ /** The line title of a task this reply shows, running or ended. */
257
+ taskTitle(taskId) {
258
+ return this.#lines.get(this.#lineOfTask.get(taskId) ?? "")?.title ?? null;
259
+ }
260
+ /** Whether this reply shows that task, running or ended: on a line, or as a launch line. */
261
+ shows(taskId) {
262
+ const id = this.#lineOfTask.get(taskId);
263
+ return (id !== undefined && (this.#quiet.has(taskId) || this.#lines.has(id) || this.#launched.has(id)));
264
+ }
265
+ /**
266
+ * Whether this reply shows that task with no line of its own (an agent or a command in the
267
+ * background): nothing here says its end, which is said in words.
268
+ */
269
+ lineless(taskId) {
270
+ return this.#quiet.has(taskId) || this.#launched.has(this.#lineOfTask.get(taskId) ?? "");
271
+ }
272
+ /**
273
+ * Whether this task event is of a task held aside or dropped as the work of a call inside
274
+ * another call, a call whose root this reply holds: the agent reports such a task to the
275
+ * subagent, not to the conversation. False for a call this reply never saw, for a task that
276
+ * started after its call's result, and for one that outlived its call.
277
+ */
278
+ nests(event) {
279
+ if (event.type === "task_started") {
280
+ const call = event.callId;
281
+ return call !== null && this.#rootOf.has(call) && !this.#closedCalls.has(call);
282
+ }
283
+ return this.#inside.has(event.taskId);
284
+ }
285
+ /**
286
+ * The tasks that outlived the nested call that started them since the last call: each is now
287
+ * an ordinary task of this reply (a line, a running task), which the session records as it
288
+ * does any task it saw start.
289
+ */
290
+ takePromoted() {
291
+ const promoted = this.#promoted;
292
+ this.#promoted = [];
293
+ return promoted;
294
+ }
295
+ /**
296
+ * Keep a question's answers for the line of its call, which shows them once the call ends.
297
+ * False when this reply has no line of its own for that call (one asked inside a subagent
298
+ * shows on the subagent's line) or there is no answer to show: the caller keeps the answers
299
+ * elsewhere.
300
+ */
301
+ answered(callId, questions, answers) {
302
+ const shown = answered(questions, answers, this.#names);
303
+ if (shown === null || !this.#lines.has(callId))
304
+ return false;
305
+ this.#answers.set(callId, shown);
306
+ return true;
307
+ }
308
+ /** Whether this reply holds the line of that call, or of the subagent it runs in. */
309
+ owns(callId) {
310
+ return this.#lines.has(callId) || this.#rootOf.has(callId) || this.#launched.has(callId);
311
+ }
312
+ /**
313
+ * End the turn's own part of the reply: every open line is closed, except a task still
314
+ * running, whose line stays open until its own end arrives through `feed` or `stopRunning`.
315
+ * Does not end the reply; the caller follows with `closeOut` once it knows nothing more is
316
+ * coming, which can be after more than one call here (a background task's own report turn
317
+ * closes the same reply again).
318
+ */
319
+ async close(signal) {
320
+ const interrupted = this.result !== null && this.result.ending === "interrupted";
321
+ await this.#handOver();
322
+ await this.#writeLaunches();
323
+ if (!this.#wroteText &&
324
+ !this.#wroteLine &&
325
+ this.#lines.size === 0 &&
326
+ this.#launched.size === 0) {
327
+ // A command that prints nothing (a local one, say) still gets a visible answer.
328
+ await this.#text(interrupted ? texts.STOPPED : texts.named(texts.NO_OUTPUT, this.#names), {
329
+ notice: true,
330
+ });
331
+ }
332
+ const running = new Set(this.#carded().map(([, lineId]) => lineId));
333
+ const closing = [];
334
+ for (const entry of this.#lines.values()) {
335
+ if (running.has(entry.id)) {
336
+ closing.push({ ...entry, status: "in_progress", details: BACKGROUND, task: true });
337
+ }
338
+ else if (entry.status === "pending" || entry.status === "in_progress") {
339
+ closing.push({
340
+ ...entry,
341
+ status: "complete",
342
+ output: interrupted ? STOPPED : entry.output,
343
+ });
344
+ }
345
+ }
346
+ for (const entry of closing)
347
+ this.#lines.set(entry.id, entry);
348
+ await this.#sink.finish(closing, signal);
349
+ }
350
+ /** Whether this reply has already ended. */
351
+ get closedOut() {
352
+ return this.#closedOut;
353
+ }
354
+ /**
355
+ * End the reply; call after `close`. True when it ended in the chat. A second call is a no-op
356
+ * of the sink's.
357
+ */
358
+ async closeOut(signal) {
359
+ await this.#handOver();
360
+ this.#closedOut = true;
361
+ return this.#sink.closeOut(signal);
362
+ }
363
+ get sink() {
364
+ return this.#sink;
365
+ }
366
+ /** The agent's process is gone and its tasks with it: close their lines as stopped. */
367
+ async stopRunning() {
368
+ for (const taskId of [...this.#running.keys()]) {
369
+ await this.#taskEnded(taskId, "stopped", null);
370
+ }
371
+ }
372
+ /**
373
+ * A note before what follows it: usually the daemon's own line, before the agent's reply even
374
+ * starts. It is not the agent's text, so a local command's result still shows after it. A
375
+ * report turn feeds one into an already-written reply instead (the one that started the task
376
+ * it reports): a blank line still separates it from what is there, as a paragraph break would.
377
+ */
378
+ async feedNotice(text, aside = false) {
379
+ await this.#writeLaunches();
380
+ const prefix = this.#wroteText || this.#lines.size > 0 || this.#breakOwed ? "\n\n" : "";
381
+ this.#afterText = false;
382
+ this.#breakOwed = false;
383
+ await this.#handOver();
384
+ await this.#sink.text(`${prefix}${text}\n\n`, { notice: true, ending: false, aside });
385
+ }
386
+ /**
387
+ * What opens a turn the agent starts on its own: the end lines of the tasks it reports. A
388
+ * note as `feedNotice` writes one, and the reply's content: a turn that then says nothing has
389
+ * said this, and gets no line about an answer with no text.
390
+ */
391
+ async feedOpening(text, aside = false) {
392
+ await this.feedNotice(text, aside);
393
+ this.#wroteLine = true;
394
+ }
395
+ /**
396
+ * A line of the daemon's among what the reply holds: a task's end, said where the turn is.
397
+ * A paragraph apart from what is before it, and from the text that follows.
398
+ */
399
+ async feedLine(text) {
400
+ await this.#writeLaunches();
401
+ await this.#line(text, true);
402
+ }
403
+ /** `aside`: a line about the work, which a chat may draw smaller (a task's end). */
404
+ async #line(text, aside = false) {
405
+ const apart = this.#breakOwed || this.#wroteText || this.#lines.size > 0 || this.#wroteLine;
406
+ this.#afterText = false;
407
+ await this.#handOver();
408
+ await this.#sink.text(`${apart ? "\n\n" : ""}${text}`, {
409
+ notice: true,
410
+ ending: false,
411
+ aside,
412
+ });
413
+ this.#breakOwed = true;
414
+ this.#wroteLine = true;
415
+ }
416
+ /** Write the launch lines that wait, once nothing more can join them. */
417
+ async #settleLaunches() {
418
+ if (!this.#messageOpen && this.#undecided.size === 0 && this.#awaiting.size === 0) {
419
+ await this.#writeLaunches();
420
+ }
421
+ }
422
+ /** The launch line of the agents that wait for one: their own, or one for them all. */
423
+ async #writeLaunches() {
424
+ if (this.#launches.length === 0)
425
+ return;
426
+ const launches = this.#launches;
427
+ this.#launches = [];
428
+ this.#awaiting.clear(); // written as it stands: a result that comes later changes nothing
429
+ await this.#line(launchedTogether(launches));
430
+ }
431
+ /** The running tasks that have a line of their own, each with its line's id. */
432
+ #carded() {
433
+ return [...this.#running].filter(([taskId]) => !this.#quiet.has(taskId));
434
+ }
435
+ /** A note of the daemon's after what the reply holds: what was not sent, what a restart dropped. */
436
+ async feedError(text) {
437
+ await this.#text(`\n\n${text}`, { notice: true });
438
+ }
439
+ /**
440
+ * The turn was cut short outside the agent's stream (the process died, the session closed):
441
+ * say so in the reply, as the line on how it ended.
442
+ */
443
+ async feedEnding(text) {
444
+ await this.#text(`\n\n${text}`, { notice: true, ending: true });
445
+ }
446
+ /** The agent compacted the conversation, on request or on its own: say by how much. */
447
+ async #compacted(before, after) {
448
+ const said = before !== null && after !== null
449
+ ? texts.fill(texts.COMPACTED, { before: formatTokens(before), after: formatTokens(after) })
450
+ : texts.COMPACTED_PLAIN;
451
+ await this.#text(`${this.#wroteText ? "\n\n" : ""}${said}\n\n`, { notice: true });
452
+ }
453
+ async #failed(event) {
454
+ if (event.parentCallId !== null) {
455
+ // A subagent's own failed request (recorded: `subagent-api-error.jsonl`): like every
456
+ // other text of a subagent it stays out of the reply, whose turn may well go on and
457
+ // succeed. Its words go under the card of the call that started the subagent, the
458
+ // outermost one when subagents nest, so the failure shows whether or not a task event
459
+ // of that subagent ever reaches this reply.
460
+ await this.#childNote(event.parentCallId, event.text);
461
+ return;
462
+ }
463
+ if (event.kind === "authentication") {
464
+ this.authFailed = true;
465
+ const failed = this.#names.login === null ? texts.AUTH_FAILED_NO_LOGIN : texts.AUTH_FAILED;
466
+ await this.#text(texts.named(failed, this.#names), { notice: true });
467
+ return;
468
+ }
469
+ // The agent wrote this message itself: its words name the failure and what to do next, as
470
+ // the terminal shows them. With no words here the turn's end speaks: it repeats them, and
471
+ // `feed` falls back to the category.
472
+ this.error = event.category;
473
+ if (event.text !== "") {
474
+ await this.#text((this.#wroteText ? "\n\n" : "") + event.text, { notice: true });
475
+ }
476
+ }
477
+ async #callStarted(event) {
478
+ const title = described(event.toolName, event.input) ?? taskTitle(event.toolName, event.input);
479
+ const parent = event.parentCallId;
480
+ const root = parent === null ? null : (this.#rootOf.get(parent) ?? parent);
481
+ if (root !== null && (this.#lines.has(root) || this.#launched.has(root))) {
482
+ this.#rootOf.set(event.callId, root);
483
+ await this.#child(root, title);
484
+ }
485
+ else if (this.#continues(event)) {
486
+ // The terminal draws no line for a message to an agent it knows: it writes under the agent
487
+ // that it resumes. The task that starts again under this call finds no card (`#taskStarted`).
488
+ this.#resumed.set(event.callId, line(event.callId, title, "in_progress", { name: event.toolName }));
489
+ await this.feedLine(resuming(continued(event.toolName, event.input)));
490
+ }
491
+ else if (PREVIEWED.has(event.toolName) ||
492
+ ASKERS.has(event.toolName) ||
493
+ LAUNCHERS.has(event.toolName)) {
494
+ // Kept from the sink until it ends: what shows it then is its preview with no card (a
495
+ // question's record, once answered), or a card that says why it failed. An approval has a
496
+ // message of its own. A call that starts an agent is kept until its task starts
497
+ // (`#taskStarted`).
498
+ if (LAUNCHERS.has(event.toolName))
499
+ this.#undecided.add(event.callId);
500
+ this.#lines.set(event.callId, line(event.callId, title, "in_progress", { name: event.toolName }));
501
+ this.#afterText = false;
502
+ }
503
+ else {
504
+ await this.#set(line(event.callId, title, "in_progress", this.#timed(event)));
505
+ }
506
+ }
507
+ /**
508
+ * Whether the call continues an agent that has ended, which is the case the terminal was seen
509
+ * to word (2026-10-16); a message to one that still runs keeps its card.
510
+ */
511
+ #continues(event) {
512
+ const task = continued(event.toolName, event.input);
513
+ if (task === null)
514
+ return false;
515
+ return this.#lineOfTask.has(task) ? !this.#running.has(task) : this.#endedBefore(task);
516
+ }
517
+ /**
518
+ * A call's fields: its name, and for a call the terminal times, when it began and its command.
519
+ */
520
+ #timed(event) {
521
+ const command = commandLine(event.toolName, event.input);
522
+ if (command === null)
523
+ return { name: event.toolName };
524
+ return { name: event.toolName, details: command, startedAt: this.#now() };
525
+ }
526
+ async #callEnded(event) {
527
+ await this.#outlived(event.callId);
528
+ const model = event.model === null ? null : this.#modelName(event.model);
529
+ if (this.#awaiting.delete(event.callId)) {
530
+ this.#launches = this.#launches.map((launch) => launch.callId === event.callId ? { ...launch, model } : launch);
531
+ }
532
+ const entry = this.#lines.get(event.callId);
533
+ if (entry === undefined) {
534
+ const resumed = this.#resumed.get(event.callId);
535
+ if (resumed !== undefined && event.isError) {
536
+ // The words were drawn; a call that failed says why on a card, as any failed call does.
537
+ await this.#set({ ...resumed, status: "error", output: resultSummary(event.output) });
538
+ }
539
+ await this.#settleLaunches();
540
+ return;
541
+ }
542
+ this.#undecided.delete(event.callId); // no task came: the call shows as any other
543
+ // The call waits for no task any more: one still running is in the background from here.
544
+ for (const [taskId, lineId] of this.#running)
545
+ if (lineId === entry.id)
546
+ this.#waited.delete(taskId);
547
+ let outliving = this.#carded()
548
+ .filter(([, lineId]) => lineId === entry.id)
549
+ .map(([taskId]) => taskId);
550
+ if (outliving.length > 0 && outliving.every((id) => this.#kinds.get(id) === "command")) {
551
+ // A command left running in the background: its call has ended like any other, and is
552
+ // counted so. The command goes on with no line of its own.
553
+ for (const taskId of outliving)
554
+ this.#quiet.add(taskId);
555
+ outliving = [];
556
+ }
557
+ if (outliving.length > 0) {
558
+ // The call only launched a task, which is still running: it outlives the call, so its
559
+ // line becomes a task's line and stays open. A task that ends before its call's result
560
+ // (a long command in the foreground) never gets here.
561
+ await this.#set({
562
+ ...entry,
563
+ details: BACKGROUND,
564
+ task: true,
565
+ output: resultSummary(event.output),
566
+ });
567
+ }
568
+ else if (entry.output === STOPPED) {
569
+ // Its task already ended as stopped; the result that follows only reports the rejection
570
+ // (recorded: `interrupt.jsonl`, CLI 2.1.283), and must not turn a stopped call into a
571
+ // failed one.
572
+ }
573
+ else {
574
+ const kept = this.#answers.get(event.callId) ?? null;
575
+ this.#answers.delete(event.callId);
576
+ const shown = event.isError
577
+ ? null
578
+ : (kept ?? preview(entry.name, event.fileChange, this.#cwd));
579
+ await this.#set({
580
+ ...entry,
581
+ // The terminal names a subagent's model on its call line; a card has the title.
582
+ title: model === null || !LAUNCHERS.has(entry.name) ? entry.title : `${entry.title} · ${model}`,
583
+ status: event.isError ? "error" : "complete",
584
+ output: resultSummary(event.output),
585
+ preview: shown,
586
+ });
587
+ }
588
+ await this.#settleLaunches();
589
+ }
590
+ async #taskStarted(event) {
591
+ this.#kinds.set(event.taskId, event.kind);
592
+ if (this.nests(event)) {
593
+ // The agent starts a task for a long command a subagent runs in the foreground of its
594
+ // own context (recorded: `subagent-nested-command.jsonl`, CLI 2.1.286). The call already
595
+ // shows on its root's card (`#child`): one work, one line, unless the task outlives the
596
+ // call (`#outlived`).
597
+ this.#inside.add(event.taskId);
598
+ this.#aside.set(event.taskId, event);
599
+ return;
600
+ }
601
+ // An id held aside before, starting again after its call closed, is an ordinary task now:
602
+ // its later events must reach it, or its end would never close its line.
603
+ this.#inside.delete(event.taskId);
604
+ const call = event.callId;
605
+ if (call !== null && this.#resumed.has(call)) {
606
+ // The agent a message continues runs again: no card and no launch line, as the terminal
607
+ // draws none. It is bound to the message's call, a line nothing draws, so that the card
608
+ // it first had (an agent the call waited for) is never reopened by this reply's close.
609
+ this.#launched.add(call);
610
+ this.#bind(event.taskId, call);
611
+ return;
612
+ }
613
+ if (call !== null && this.#launched.has(call)) {
614
+ // The agent of a call already written as a line of text starts again, under that call
615
+ // (recorded: `subagent-nested-background.jsonl`, where the end of a command it left
616
+ // running wakes it): running once more, and no second line.
617
+ this.#bind(event.taskId, call);
618
+ return;
619
+ }
620
+ const held = call !== null && this.#undecided.delete(call) ? this.#lines.get(call) : undefined;
621
+ // A task that started before, here or in an earlier reply, and starts again under another
622
+ // call (an agent a message continues) is that call's work, with no second launch line: a
623
+ // resumed task keeps its id (SDK 0.3.300, `SDKTaskStartedMessage.run_id`).
624
+ const known = this.#lineOfTask.has(event.taskId) || this.#startedBefore(event.taskId);
625
+ const line = call === null ? undefined : this.#lines.get(call);
626
+ if (line !== undefined && event.background === true && !known) {
627
+ await this.#launch(event, line, held !== undefined);
628
+ return;
629
+ }
630
+ // An agent the call waits for: its card, from here on.
631
+ if (held !== undefined)
632
+ await this.#set(held);
633
+ if (call !== null && this.#lines.has(call)) {
634
+ // A call's task: the agent starts one for a long command in the foreground too
635
+ // (recorded: `interrupt.jsonl`, CLI 2.1.283). The line stays the call's until the call's
636
+ // result arrives with the task still running (`#callEnded`).
637
+ this.#bind(event.taskId, call);
638
+ this.#waited.add(event.taskId);
639
+ return;
640
+ }
641
+ const command = [...this.#running.values()].reverse().find((id) => this.#commands.has(id));
642
+ if (call !== null && command !== undefined && !this.#rootOf.has(call)) {
643
+ // Started by a call this reply never saw while a command's task runs: an agent inside
644
+ // that command (a forked skill typed as a command streams none of its own calls,
645
+ // recorded: `skill-fork-command.jsonl`, CLI 2.1.283). It shows on the command's line, as
646
+ // a subagent's calls show on the subagent's: one work, one line.
647
+ this.#nested.add(event.taskId);
648
+ await this.#child(command, oneLine(event.description, TITLE_LIMIT));
649
+ return;
650
+ }
651
+ await this.#openLine(event);
652
+ }
653
+ /**
654
+ * A task a call of this reply started in the background, whatever its kind (issue #311): its
655
+ * launch line, as text of the reply, and no card. A call held until its task said how it runs
656
+ * (one that starts an agent) is that line alone, as in the terminal; any other call stays a
657
+ * call, counted in its run. The task still runs for this reply, which the list of what still
658
+ * runs reads.
659
+ */
660
+ async #launch(event, line, held) {
661
+ const call = line.id;
662
+ this.#bind(event.taskId, call);
663
+ if (held) {
664
+ this.#lines.delete(call);
665
+ this.#launched.add(call);
666
+ }
667
+ else {
668
+ this.#quiet.add(event.taskId);
669
+ }
670
+ // A line of the daemon's: it is not the turn's text, so a turn that says nothing else still
671
+ // gets its last text or its error from the turn's end.
672
+ this.#launches.push({
673
+ callId: call,
674
+ name: line.name,
675
+ agentType: event.agentType,
676
+ description: event.description,
677
+ model: null,
678
+ });
679
+ // The result of the call carries the model, and comes after the task starts.
680
+ if (LAUNCHERS.has(line.name))
681
+ this.#awaiting.add(call);
682
+ await this.#settleLaunches();
683
+ }
684
+ /** The task runs for this reply, on that line or under that call. */
685
+ #bind(taskId, lineId) {
686
+ this.#lineOfTask.set(taskId, lineId);
687
+ this.#running.set(taskId, lineId);
688
+ }
689
+ async #openLine(event) {
690
+ const lineId = `task-${event.taskId}`;
691
+ this.#bind(event.taskId, lineId);
692
+ if (event.background === true) {
693
+ // A task in the background whose call this reply does not hold: an agent that started
694
+ // again once the reply of its call had ended (recorded:
695
+ // `subagent-nested-background-mid-turn.jsonl`), or a task a subagent started. No card, as
696
+ // for any task in the background, and no launch line, which belongs under its call.
697
+ this.#launched.add(lineId);
698
+ return;
699
+ }
700
+ if (event.kind === "command") {
701
+ // A command no call of this reply holds: one a subagent left running, or one started
702
+ // before a restart. No card, as for any command in the background.
703
+ this.#quiet.add(event.taskId);
704
+ return;
705
+ }
706
+ if (event.callId === null)
707
+ this.#commands.add(lineId);
708
+ const title = oneLine(event.description, TITLE_LIMIT);
709
+ const name = event.taskType ?? "task";
710
+ await this.#set(line(lineId, title, "in_progress", { name, task: true }));
711
+ }
712
+ /**
713
+ * A nested call's result: a task it started that is still running outlives it (recorded:
714
+ * `subagent-nested-background.jsonl`, CLI 2.1.286, where the result comes first), and from
715
+ * here is an ordinary background task, as for a call at the top level (`#callEnded`).
716
+ */
717
+ async #outlived(call) {
718
+ if (!this.#rootOf.has(call))
719
+ return;
720
+ this.#closedCalls.add(call);
721
+ for (const [taskId, started] of [...this.#aside]) {
722
+ if (started.callId === call) {
723
+ this.#aside.delete(taskId);
724
+ this.#inside.delete(taskId);
725
+ // Queued before the line is written: a write that fails must not leave a running
726
+ // task the session never adopts.
727
+ this.#promoted.push(started);
728
+ await this.#openLine(started);
729
+ }
730
+ }
731
+ }
732
+ async #taskEnded(taskId, status, summary) {
733
+ if (this.#inside.has(taskId)) {
734
+ this.#aside.delete(taskId); // dropped: it ended before its call's result
735
+ return;
736
+ }
737
+ if (this.#nested.has(taskId)) {
738
+ this.#nested.delete(taskId); // the command's own end closes its line
739
+ return;
740
+ }
741
+ this.#running.delete(taskId);
742
+ this.#waited.delete(taskId);
743
+ const lineId = this.#lineOfTask.get(taskId) ?? `task-${taskId}`;
744
+ // No card: its end is the session's end line.
745
+ if (this.#launched.has(lineId) || this.#quiet.has(taskId))
746
+ return;
747
+ const entry = this.#lines.get(lineId) ??
748
+ line(lineId, oneLine(summary || taskId, TITLE_LIMIT), "in_progress", {
749
+ name: "task",
750
+ task: true,
751
+ });
752
+ const [final, stopped] = terminalStatus(status);
753
+ await this.#set({
754
+ ...entry,
755
+ status: final,
756
+ details: null,
757
+ output: stopped ?? (summary ? oneLine(summary, OUTPUT_LIMIT) : null),
758
+ });
759
+ }
760
+ async #child(root, said) {
761
+ const entry = this.#lines.get(root);
762
+ if (entry === undefined)
763
+ return;
764
+ // A call that runs calls of its own (a subagent) keeps its line once it ends, whether it
765
+ // ran in the foreground or not: the nested calls are its work, not one more call.
766
+ await this.#noted(root, {
767
+ ...entry,
768
+ details: this.#under(root, said),
769
+ task: true,
770
+ calls: entry.calls + 1,
771
+ });
772
+ }
773
+ /** A change to a card from under it: kept, not sent, while the card's call is still held. */
774
+ async #noted(root, update) {
775
+ if (this.#undecided.has(root))
776
+ this.#lines.set(root, update);
777
+ else
778
+ await this.#set(update);
779
+ }
780
+ /**
781
+ * What a subagent's error message says, as a nested line of its card: not one more call, so
782
+ * the count stays.
783
+ */
784
+ async #childNote(parent, words) {
785
+ const root = this.#rootOf.get(parent) ?? parent;
786
+ const entry = this.#lines.get(root);
787
+ if (entry === undefined || words === "")
788
+ return;
789
+ const details = this.#under(root, oneLine(words, 200));
790
+ await this.#noted(root, { ...entry, details, task: true });
791
+ }
792
+ /** The nested lines of a card with `said` added: its last `CHILD_LINES`. */
793
+ #under(root, said) {
794
+ const lines = [...(this.#children.get(root) ?? []), said].slice(-CHILD_LINES);
795
+ this.#children.set(root, lines);
796
+ return lines.join("\n");
797
+ }
798
+ async #set(update) {
799
+ if (!this.#lines.has(update.id)) {
800
+ // Only a new line puts a card after the text. A line that changes does so where its card
801
+ // sits, and the text stays the last thing in the reply.
802
+ this.#afterText = false;
803
+ }
804
+ this.#lines.set(update.id, update);
805
+ // The calls of the message that started a task keep their run whole: its launch line waits
806
+ // for the message to end, below them.
807
+ if (!this.#messageOpen)
808
+ await this.#writeLaunches();
809
+ // A card that changes where it sits (a command's call ending under its own launch line)
810
+ // leaves the line of the daemon's last, and its break still owed.
811
+ if (!this.#drawn.has(update.id))
812
+ this.#breakOwed = false;
813
+ await this.#handOver();
814
+ this.#drawn.add(update.id);
815
+ await this.#sink.task(update);
816
+ }
817
+ /**
818
+ * Hand the sink the text held since it began, whole: before anything else the sink is given,
819
+ * so it lands in its place. Nothing is held when the chat shows a text as it is written.
820
+ */
821
+ async #handOver() {
822
+ if (this.#held === "")
823
+ return;
824
+ const text = this.#held;
825
+ this.#held = "";
826
+ await this.#sink.text(text, { notice: false, ending: false });
827
+ }
828
+ /** `piece`: the next piece of a text being written, which a chat with no streaming waits to have whole. */
829
+ async #text(markdown, options = {}) {
830
+ if (markdown === "")
831
+ return;
832
+ const { notice = false, ending = false, piece = false } = options;
833
+ await this.#writeLaunches();
834
+ if (this.#breakOwed && !markdown.startsWith("\n"))
835
+ markdown = `\n\n${markdown}`;
836
+ this.#breakOwed = false;
837
+ this.#wroteText = true;
838
+ this.#afterText = !notice; // a notice ends with its own break
839
+ if (piece && !this.#streaming) {
840
+ this.#held += markdown;
841
+ return;
842
+ }
843
+ await this.#handOver();
844
+ await this.#sink.text(markdown, { notice, ending });
845
+ }
846
+ }