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.
- package/LICENSE +21 -0
- package/README.md +43 -2
- package/dist/agent/claude/backend.js +124 -0
- package/dist/agent/claude/capabilities.js +30 -0
- package/dist/agent/claude/chrome.js +42 -0
- package/dist/agent/claude/hooks.js +37 -0
- package/dist/agent/claude/info.js +61 -0
- package/dist/agent/claude/listing.js +230 -0
- package/dist/agent/claude/module.js +82 -0
- package/dist/agent/claude/prompt.js +24 -0
- package/dist/agent/claude/record.js +9 -0
- package/dist/agent/claude/requests.js +122 -0
- package/dist/agent/claude/session.js +441 -0
- package/dist/agent/claude/translate.js +581 -0
- package/dist/agent/claude/trust.js +576 -0
- package/dist/agent/claude/usage-probe.js +103 -0
- package/dist/agent/claude/usage.js +110 -0
- package/dist/agent/claude/wire.js +22 -0
- package/dist/agent/seam.js +21 -0
- package/dist/chat/seam.js +10 -0
- package/dist/chat/slack/app/answers.js +209 -0
- package/dist/chat/slack/app/app.js +262 -0
- package/dist/chat/slack/app/clip.js +191 -0
- package/dist/chat/slack/app/guards.js +162 -0
- package/dist/chat/slack/app/home-controls.js +91 -0
- package/dist/chat/slack/app/messages.js +181 -0
- package/dist/chat/slack/app/open.js +392 -0
- package/dist/chat/slack/app/order.js +37 -0
- package/dist/chat/slack/app/requests.js +482 -0
- package/dist/chat/slack/app/resume-bind.js +71 -0
- package/dist/chat/slack/app/wire.js +83 -0
- package/dist/chat/slack/attachments.js +348 -0
- package/dist/chat/slack/bind.js +67 -0
- package/dist/chat/slack/channel.js +67 -0
- package/dist/chat/slack/channels.js +43 -0
- package/dist/chat/slack/delete.js +278 -0
- package/dist/chat/slack/footer.js +43 -0
- package/dist/chat/slack/hold.js +37 -0
- package/dist/chat/slack/home.js +1069 -0
- package/dist/chat/slack/module.js +174 -0
- package/dist/chat/slack/notice.js +64 -0
- package/dist/chat/slack/openfile/modal.js +362 -0
- package/dist/chat/slack/openfile/read.js +29 -0
- package/dist/chat/slack/quiet-logger.js +52 -0
- package/dist/chat/slack/repair.js +123 -0
- package/dist/chat/slack/reply/blocks.js +372 -0
- package/dist/chat/slack/reply/chars.js +59 -0
- package/dist/chat/slack/reply/clients.js +171 -0
- package/dist/chat/slack/reply/errors.js +125 -0
- package/dist/chat/slack/reply/escape.js +25 -0
- package/dist/chat/slack/reply/limiter.js +86 -0
- package/dist/chat/slack/reply/markdown.js +210 -0
- package/dist/chat/slack/reply/places.js +262 -0
- package/dist/chat/slack/reply/sinks.js +2403 -0
- package/dist/chat/slack/reply/status.js +433 -0
- package/dist/chat/slack/reply/tasks.js +11 -0
- package/dist/chat/slack/requests.js +552 -0
- package/dist/chat/slack/resume.js +127 -0
- package/dist/chat/slack/setup.js +114 -0
- package/dist/chat/slack/texts.js +185 -0
- package/dist/chat/slack/thread.js +537 -0
- package/dist/chat/slack/update.js +71 -0
- package/dist/chat/slack/voice.js +84 -0
- package/dist/cli.js +326 -0
- package/dist/clock.js +21 -0
- package/dist/core/cleanup.js +145 -0
- package/dist/core/commands.js +172 -0
- package/dist/core/config.js +174 -0
- package/dist/core/files/changed.js +125 -0
- package/dist/core/files/listing.js +382 -0
- package/dist/core/files/paths.js +9 -0
- package/dist/core/files/read.js +191 -0
- package/dist/core/files/waiting.js +34 -0
- package/dist/core/flows/answers.js +133 -0
- package/dist/core/flows/ask-owner.js +95 -0
- package/dist/core/flows/asking.js +14 -0
- package/dist/core/flows/bind.js +95 -0
- package/dist/core/flows/contract.js +1 -0
- package/dist/core/flows/flows.js +35 -0
- package/dist/core/flows/hold.js +22 -0
- package/dist/core/flows/inbound.js +24 -0
- package/dist/core/flows/lists.js +21 -0
- package/dist/core/flows/messages.js +293 -0
- package/dist/core/flows/notices.js +51 -0
- package/dist/core/flows/repair.js +106 -0
- package/dist/core/flows/resume.js +163 -0
- package/dist/core/flows/setup.js +130 -0
- package/dist/core/flows/status.js +98 -0
- package/dist/core/flows/talk.js +207 -0
- package/dist/core/flows/update.js +370 -0
- package/dist/core/flows/words.js +171 -0
- package/dist/core/folders.js +93 -0
- package/dist/core/footer.js +313 -0
- package/dist/core/guide.js +50 -0
- package/dist/core/hold.js +126 -0
- package/dist/core/instance.js +46 -0
- package/dist/core/lock.js +412 -0
- package/dist/core/models.js +10 -0
- package/dist/core/paths.js +75 -0
- package/dist/core/port/file-flows.js +1 -0
- package/dist/core/port/home.js +1 -0
- package/dist/core/port/requests.js +1 -0
- package/dist/core/port/threads.js +1 -0
- package/dist/core/port/updates.js +1 -0
- package/dist/core/port.js +17 -0
- package/dist/core/reply/fold.js +175 -0
- package/dist/core/reply/previews.js +258 -0
- package/dist/core/reply/renderer.js +846 -0
- package/dist/core/reply/tail.js +24 -0
- package/dist/core/reply/words.js +32 -0
- package/dist/core/requests.js +158 -0
- package/dist/core/sessions/constants.js +61 -0
- package/dist/core/sessions/deps.js +1 -0
- package/dist/core/sessions/directory.js +86 -0
- package/dist/core/sessions/errors.js +79 -0
- package/dist/core/sessions/manager.js +378 -0
- package/dist/core/sessions/session.js +3297 -0
- package/dist/core/sessions/tasks.js +266 -0
- package/dist/core/sessions/turn.js +45 -0
- package/dist/core/setup.js +28 -0
- package/dist/core/state.js +724 -0
- package/dist/core/texts.js +451 -0
- package/dist/core/updates.js +332 -0
- package/dist/daemon.js +566 -0
- package/dist/host/npm.js +134 -0
- package/dist/host/program.js +49 -0
- package/dist/host/run.js +52 -0
- package/dist/host/service/launchd.js +385 -0
- package/dist/host/service/listing.js +42 -0
- package/dist/host/service/seam.js +1 -0
- package/dist/log.js +37 -0
- package/dist/main.js +29 -0
- package/dist/node-version.js +40 -0
- package/dist/registry.js +12 -0
- package/dist/semver.js +53 -0
- package/dist/text.js +89 -0
- package/dist/updates-host.js +113 -0
- package/dist/values.js +30 -0
- package/dist/version.js +54 -0
- package/package.json +32 -4
- 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
|
+
}
|