@opencode-cockpit/subagents 0.7.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +107 -0
  3. package/dist/agent/plugin.js +134 -0
  4. package/dist/cli/preview.js +99 -0
  5. package/dist/core/adapt/v1.js +341 -0
  6. package/dist/core/adapt/v2.js +379 -0
  7. package/dist/core/model/changes.js +17 -0
  8. package/dist/core/model/model.js +301 -0
  9. package/dist/core/sample.js +261 -0
  10. package/dist/core/view/markdown.js +211 -0
  11. package/dist/core/view/report.js +58 -0
  12. package/dist/core/view/rows.js +146 -0
  13. package/dist/core/view/screen.js +767 -0
  14. package/dist/core/view/sidebar.js +151 -0
  15. package/dist/server.js +2 -0
  16. package/dist/tui/index.js +991 -0
  17. package/dist/tui/render.js +103 -0
  18. package/dist/tui/source.js +228 -0
  19. package/dist/tui/view/overlay.js +87 -0
  20. package/dist/tui/view/sidebar.js +78 -0
  21. package/package.json +64 -0
  22. package/server.js +6 -0
  23. package/tui.js +6 -0
  24. package/types/agent/plugin.d.ts +32 -0
  25. package/types/cli/preview.d.ts +10 -0
  26. package/types/core/adapt/v1.d.ts +33 -0
  27. package/types/core/adapt/v2.d.ts +22 -0
  28. package/types/core/model/changes.d.ts +98 -0
  29. package/types/core/model/model.d.ts +99 -0
  30. package/types/core/sample.d.ts +8 -0
  31. package/types/core/view/markdown.d.ts +22 -0
  32. package/types/core/view/report.d.ts +15 -0
  33. package/types/core/view/rows.d.ts +43 -0
  34. package/types/core/view/screen.d.ts +101 -0
  35. package/types/core/view/sidebar.d.ts +30 -0
  36. package/types/server.d.ts +2 -0
  37. package/types/tui/index.d.ts +26 -0
  38. package/types/tui/render.d.ts +27 -0
  39. package/types/tui/source.d.ts +45 -0
  40. package/types/tui/view/overlay.d.ts +30 -0
  41. package/types/tui/view/sidebar.d.ts +22 -0
@@ -0,0 +1,379 @@
1
+ /**
2
+ * OpenCode 2's events and state, as changes.
3
+ *
4
+ * OpenCode 2 names what happened: `session.reasoning.delta`, `session.tool.called`,
5
+ * `session.execution.succeeded`. Its interface context hands them over as
6
+ * `{ name, details: { data } }` (`ctx.data.listen`). Thinking and text are keyed by the assistant
7
+ * message and their ordinal in it; a tool call by its id; what the session was told arrives as an
8
+ * inbox item. Shapes measured on 2.0.15 (test/fixtures/v2.jsonl).
9
+ */
10
+
11
+ import { summaryOf, tokenTotal } from "./v1.js";
12
+ const obj = value => value && typeof value === "object" ? value : {};
13
+ const str = value => typeof value === "string" ? value : undefined;
14
+
15
+ /** A tool's result as text: v2 returns content parts. */
16
+ const contentText = content => Array.isArray(content) ? content.map(part => str(obj(part).text) ?? "").filter(Boolean).join("\n") : str(content) ?? "";
17
+ export function createV2Translator(unknown = () => {}) {
18
+ const sessionInfo = (info, at) => {
19
+ const id = str(info.sessionID) ?? str(info.id);
20
+ if (!id) return [];
21
+ return [{
22
+ type: "session",
23
+ id,
24
+ ...(str(info.parentID) ? {
25
+ parentID: str(info.parentID)
26
+ } : {}),
27
+ ...(str(info.agent) ? {
28
+ agent: str(info.agent)
29
+ } : {}),
30
+ ...(str(info.title) ? {
31
+ title: str(info.title)
32
+ } : {}),
33
+ ...(str(obj(info.model).id) ? {
34
+ model: str(obj(info.model).id)
35
+ } : {}),
36
+ at: Number(obj(info.time).created) || at
37
+ }];
38
+ };
39
+ const statusChange = (id, value, at) => {
40
+ const status = str(value) ?? str(obj(value).type);
41
+ if (status === "busy" || status === "running" || status === "retry") return [{
42
+ type: "status",
43
+ id,
44
+ status: "busy",
45
+ at
46
+ }];
47
+ if (status === "idle") return [{
48
+ type: "status",
49
+ id,
50
+ status: "idle",
51
+ at
52
+ }];
53
+ return [];
54
+ };
55
+
56
+ /** `subagent` calls launched with `background: true`, until their child session is named. */
57
+ const background = new Set();
58
+ return {
59
+ event(raw, at = Date.now()) {
60
+ const event = obj(raw);
61
+ const name = str(event.name) ?? str(event.type);
62
+ /** The interface's events carry `details.data`; the agent side's carry `data`. */
63
+ const details = obj(event.details);
64
+ const data = obj("data" in details ? details.data : event.data);
65
+ const id = str(data.sessionID);
66
+ if (!name) return [];
67
+ const key = () => `${str(data.assistantMessageID) ?? "?"}:${String(data.ordinal ?? 0)}`;
68
+ switch (name) {
69
+ case "session.created":
70
+ case "session.renamed":
71
+ return sessionInfo(data, at);
72
+ case "session.execution.started":
73
+ return id ? [{
74
+ type: "status",
75
+ id,
76
+ status: "busy",
77
+ at
78
+ }] : [];
79
+ case "session.execution.succeeded":
80
+ return id ? [{
81
+ type: "status",
82
+ id,
83
+ status: "idle",
84
+ at
85
+ }] : [];
86
+ case "session.execution.failed":
87
+ case "session.execution.interrupted":
88
+ {
89
+ if (!id) return [];
90
+ const error = str(data.message) ?? str(obj(data.error).message) ?? (name.endsWith("interrupted") ? "interrupted" : "failed");
91
+ return [{
92
+ type: "status",
93
+ id,
94
+ status: "failed",
95
+ error,
96
+ at
97
+ }];
98
+ }
99
+ case "session.inbox.enqueued":
100
+ {
101
+ const item = obj(data.item);
102
+ const text = str(obj(item.payload).text);
103
+ return id && text && item.type === "user" ? [{
104
+ type: "prompt",
105
+ id,
106
+ key: str(data.inboxID) ?? `${at}`,
107
+ text,
108
+ at
109
+ }] : [];
110
+ }
111
+ case "session.reasoning.started":
112
+ return id ? [{
113
+ type: "thinking",
114
+ id,
115
+ key: `r:${key()}`,
116
+ text: "",
117
+ at
118
+ }] : [];
119
+ case "session.reasoning.delta":
120
+ return id && str(data.delta) ? [{
121
+ type: "thinking",
122
+ id,
123
+ key: `r:${key()}`,
124
+ delta: str(data.delta),
125
+ at
126
+ }] : [];
127
+ case "session.reasoning.ended":
128
+ return id ? [{
129
+ type: "thinking",
130
+ id,
131
+ key: `r:${key()}`,
132
+ ...(str(data.text) !== undefined ? {
133
+ text: str(data.text)
134
+ } : {}),
135
+ done: true,
136
+ at
137
+ }] : [];
138
+ case "session.text.started":
139
+ return id ? [{
140
+ type: "reply",
141
+ id,
142
+ key: `t:${key()}`,
143
+ text: "",
144
+ at
145
+ }] : [];
146
+ case "session.text.delta":
147
+ return id && str(data.delta) ? [{
148
+ type: "reply",
149
+ id,
150
+ key: `t:${key()}`,
151
+ delta: str(data.delta),
152
+ at
153
+ }] : [];
154
+ case "session.text.ended":
155
+ return id ? [{
156
+ type: "reply",
157
+ id,
158
+ key: `t:${key()}`,
159
+ ...(str(data.text) !== undefined ? {
160
+ text: str(data.text)
161
+ } : {}),
162
+ done: true,
163
+ at
164
+ }] : [];
165
+ case "session.tool.input.started":
166
+ return id && str(data.id) ? [{
167
+ type: "tool",
168
+ id,
169
+ call: str(data.id),
170
+ ...(str(data.name) ? {
171
+ name: str(data.name)
172
+ } : {}),
173
+ state: "pending",
174
+ at
175
+ }] : [];
176
+ case "session.step.ended":
177
+ return id ? [{
178
+ type: "step",
179
+ id,
180
+ at
181
+ }] : [];
182
+ case "session.tool.called":
183
+ if (obj(data.input).background === true && str(data.id)) background.add(str(data.id));
184
+ return id && str(data.id) ? [{
185
+ type: "tool",
186
+ id,
187
+ call: str(data.id),
188
+ state: "running",
189
+ input: obj(data.input),
190
+ started: at,
191
+ at
192
+ }] : [];
193
+ case "session.tool.progress":
194
+ {
195
+ const metadata = obj(data.metadata);
196
+ const output = str(metadata.output);
197
+ const out = [];
198
+ if (id && str(data.id) && output !== undefined) out.push({
199
+ type: "tool",
200
+ id,
201
+ call: str(data.id),
202
+ output,
203
+ at
204
+ });
205
+ /** A background subagent's call names its child as it starts. */
206
+ const child = str(metadata.sessionID);
207
+ if (id && child && background.has(str(data.id) ?? "")) out.push({
208
+ type: "session",
209
+ id: child,
210
+ parentID: id,
211
+ background: true,
212
+ at
213
+ });
214
+ return out;
215
+ }
216
+ case "session.tool.success":
217
+ return id && str(data.id) ? [{
218
+ type: "tool",
219
+ id,
220
+ call: str(data.id),
221
+ state: "completed",
222
+ output: contentText(data.content),
223
+ ...(summaryOf(undefined, obj(data.metadata)) ? {
224
+ summary: summaryOf(undefined, obj(data.metadata))
225
+ } : {}),
226
+ at
227
+ }] : [];
228
+ case "session.tool.failed":
229
+ {
230
+ const error = str(obj(data.error).message) ?? str(data.error) ?? contentText(data.content) ?? "failed";
231
+ return id && str(data.id) ? [{
232
+ type: "tool",
233
+ id,
234
+ call: str(data.id),
235
+ state: "failed",
236
+ error,
237
+ at
238
+ }] : [];
239
+ }
240
+ /** The input streaming in; `session.tool.called` carries it whole. */
241
+ case "session.tool.input.delta":
242
+ case "session.tool.input.ended":
243
+ return [];
244
+ case "session.usage.updated":
245
+ return id ? [{
246
+ type: "usage",
247
+ id,
248
+ tokens: tokenTotal(obj(data.tokens)),
249
+ cost: Number(data.cost) || 0,
250
+ at
251
+ }] : [];
252
+ case "session.status":
253
+ return id ? statusChange(id, data.status, at) : [];
254
+ default:
255
+ // Deltas of kinds we do not draw, bookkeeping, and everything not about a session.
256
+ if (/^(session\.(inbox|step|instructions|compaction|revert|usage|permissions|model|agent|moved|synthetic|shell|skill|metadata|message|forked|retry|diff)|shell\.|skill\.|catalog\.|mcp\.|lsp\.|file\.|vcs\.|project\.|pty\.)/.test(name)) return [];
257
+ unknown("event", {
258
+ name
259
+ });
260
+ return [];
261
+ }
262
+ },
263
+ history(id, messages, at = Date.now()) {
264
+ const out = [];
265
+ /** A message's tokens are its own; the session's total is their sum (live events send the total). */
266
+ let tokens = 0;
267
+ let cost = 0;
268
+ let counted = false;
269
+ for (const raw of messages) {
270
+ const message = obj(raw);
271
+ const when = Number(obj(message.time).created) || at;
272
+ const mid = str(message.id) ?? `${when}`;
273
+ if (message.type === "user" && str(message.text)) {
274
+ out.push({
275
+ type: "prompt",
276
+ id,
277
+ key: mid,
278
+ text: str(message.text),
279
+ at: when
280
+ });
281
+ continue;
282
+ }
283
+ if (message.type !== "assistant") continue;
284
+ out.push({
285
+ type: "step",
286
+ id,
287
+ at: when
288
+ });
289
+ if (str(obj(message.model).id)) out.push({
290
+ type: "session",
291
+ id,
292
+ model: str(obj(message.model).id),
293
+ at: when
294
+ });
295
+ const content = Array.isArray(message.content) ? message.content : [];
296
+ /** Live events number thinking and text blocks separately; the keys must match across a reload. */
297
+ let thought = 0;
298
+ let wrote = 0;
299
+ for (const partRaw of content) {
300
+ const part = obj(partRaw);
301
+ const time = obj(part.time);
302
+ const partAt = Number(time.created) || when;
303
+ if (part.type === "reasoning") {
304
+ out.push({
305
+ type: "thinking",
306
+ id,
307
+ key: `r:${mid}:${thought++}`,
308
+ text: str(part.text) ?? "",
309
+ done: true,
310
+ at: partAt
311
+ });
312
+ } else if (part.type === "text") {
313
+ out.push({
314
+ type: "reply",
315
+ id,
316
+ key: `t:${mid}:${wrote++}`,
317
+ text: str(part.text) ?? "",
318
+ done: true,
319
+ at: partAt
320
+ });
321
+ } else if (part.type === "tool" && str(part.id)) {
322
+ const state = obj(part.state);
323
+ const status = str(state.status);
324
+ const ended = status === "completed" ? "completed" : status === "failed" || status === "error" ? "failed" : undefined;
325
+ out.push({
326
+ type: "tool",
327
+ id,
328
+ call: str(part.id),
329
+ ...(str(part.name) ? {
330
+ name: str(part.name)
331
+ } : {}),
332
+ state: "running",
333
+ input: obj(state.input),
334
+ started: Number(time.ran) || partAt,
335
+ at: partAt
336
+ });
337
+ /** A second change carries the ending, so the call keeps when it started and when it ended. */
338
+ if (ended) {
339
+ out.push({
340
+ type: "tool",
341
+ id,
342
+ call: str(part.id),
343
+ state: ended,
344
+ output: contentText(state.content),
345
+ ...(str(obj(state.error).message) ? {
346
+ error: str(obj(state.error).message)
347
+ } : {}),
348
+ ...(summaryOf(str(part.name), obj(state.metadata)) ? {
349
+ summary: summaryOf(str(part.name), obj(state.metadata))
350
+ } : {}),
351
+ ended: Number(time.completed) || partAt,
352
+ at: Number(time.completed) || partAt
353
+ });
354
+ }
355
+ }
356
+ }
357
+ if (Object.keys(obj(message.tokens)).length > 0) {
358
+ tokens += tokenTotal(obj(message.tokens));
359
+ cost += Number(message.cost) || 0;
360
+ counted = true;
361
+ }
362
+ }
363
+ if (counted) out.push({
364
+ type: "usage",
365
+ id,
366
+ tokens,
367
+ cost,
368
+ at
369
+ });
370
+ return out;
371
+ },
372
+ session(info, at = Date.now()) {
373
+ return sessionInfo(obj(info), at);
374
+ },
375
+ status(id, status, at = Date.now()) {
376
+ return statusChange(id, status, at);
377
+ }
378
+ };
379
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * What a subagent did, in one vocabulary for both OpenCodes.
3
+ *
4
+ * OpenCode 1 says it as `message.part.updated` / `message.part.delta` / `session.status`; OpenCode 2
5
+ * as `session.tool.called` / `session.reasoning.delta` / `session.execution.started`. The two
6
+ * adapters in `tui/data/` translate each into these changes, and everything after them — the model,
7
+ * the rows — is written once. Shapes measured from real runs (test/fixtures, docs/opencode/agents.md).
8
+ *
9
+ * `id` is always the session the change is about; a subagent *is* a session with a parent.
10
+ */
11
+
12
+ /** OpenCode 2 starts every subagent's task with this line; the task is what follows it. */
13
+ export const SUBAGENT_PREAMBLE = "You are a subagent spawned by another session.";
14
+ export function taskText(text) {
15
+ const trimmed = text.startsWith(SUBAGENT_PREAMBLE) ? text.slice(SUBAGENT_PREAMBLE.length) : text;
16
+ return trimmed.trim();
17
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Subagents, built from changes. Pure: no OpenCode, no terminal — a test feeds it the changes a real
3
+ * run produced and checks what came out.
4
+ *
5
+ * Every session is kept, the conversation you are in included: a subagent is only "a subagent" in
6
+ * relation to the session it hangs under, and `subagentsOf` walks that from whichever conversation is
7
+ * on screen.
8
+ */
9
+
10
+ import { taskText } from "./changes.js";
11
+ export const emptyModel = () => ({
12
+ sessions: new Map()
13
+ });
14
+ function session(model, id, at) {
15
+ let found = model.sessions.get(id);
16
+ if (!found) {
17
+ found = {
18
+ id,
19
+ agent: "agent",
20
+ title: "",
21
+ status: "starting",
22
+ since: at,
23
+ started: at,
24
+ entries: [],
25
+ tokens: 0,
26
+ cost: 0,
27
+ denied: [],
28
+ steps: 0,
29
+ seen: at
30
+ };
31
+ model.sessions.set(id, found);
32
+ }
33
+ return found;
34
+ }
35
+ const findText = (s, kind, key) => s.entries.find(entry => entry.kind === kind && entry.key === key);
36
+ const findTool = (s, call) => s.entries.find(entry => entry.kind === "tool" && entry.call === call);
37
+ function applyStatus(s, change) {
38
+ if (change.status === "busy") {
39
+ s.status = "running";
40
+ delete s.ended;
41
+ delete s.error;
42
+ } else if (change.status === "waiting") {
43
+ s.status = "waiting";
44
+ } else if (change.status === "failed") {
45
+ s.status = "failed";
46
+ s.ended = change.at;
47
+ if (change.error) s.error = change.error;
48
+ settle(s, change.at);
49
+ } else if (s.status !== "failed") {
50
+ /** Idle before it ever worked is a session that has not started, not one that finished. */
51
+ s.status = s.status === "starting" && s.entries.length === 0 ? "starting" : "done";
52
+ s.ended = change.at;
53
+ settle(s, change.at);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * A run that ended has no call still running: one left so was cut off (a stop, an abort) and never
59
+ * told us — without this its spinner turned forever, and the sidebar said it was still at it.
60
+ */
61
+ function settle(s, at) {
62
+ for (const entry of s.entries) {
63
+ if (entry.kind === "tool" && (entry.state === "running" || entry.state === "pending")) {
64
+ entry.state = "failed";
65
+ entry.error ??= "stopped";
66
+ entry.ended = at;
67
+ }
68
+ if ((entry.kind === "thinking" || entry.kind === "reply") && !entry.done) entry.done = true;
69
+ }
70
+ }
71
+
72
+ /** Applies one change in place. Unknown sessions are created, so order never loses anything. */
73
+ export function apply(model, change) {
74
+ const s = session(model, change.id, change.at);
75
+ if (change.at > s.seen) s.seen = change.at;
76
+ switch (change.type) {
77
+ case "session":
78
+ if (change.parentID) s.parentID = change.parentID;
79
+ if (change.agent) s.agent = change.agent;
80
+ if (change.title) s.title = change.title;
81
+ if (change.model) s.model = change.model;
82
+ if (change.background) s.background = true;
83
+ if (change.denied) s.denied = change.denied;
84
+ /** A session's own creation time beats when we first heard of it. */
85
+ if (change.at < s.started) s.started = change.at;
86
+ return;
87
+ case "step":
88
+ s.steps++;
89
+ return;
90
+ case "status":
91
+ {
92
+ const before = s.status;
93
+ applyStatus(s, change);
94
+ if (s.status !== before) s.since = change.at;
95
+ return;
96
+ }
97
+ case "prompt":
98
+ {
99
+ if (s.entries.some(entry => entry.kind === "prompt" && entry.key === change.key)) return;
100
+ const first = !s.entries.some(entry => entry.kind === "prompt");
101
+ const text = first ? taskText(change.text) : change.text.trim();
102
+ if (first) s.task = text;
103
+ s.entries.push({
104
+ kind: "prompt",
105
+ key: change.key,
106
+ text,
107
+ at: change.at,
108
+ first
109
+ });
110
+ return;
111
+ }
112
+ case "thinking":
113
+ case "reply":
114
+ {
115
+ let entry = findText(s, change.type, change.key);
116
+ if (!entry) {
117
+ const created = {
118
+ kind: change.type,
119
+ key: change.key,
120
+ text: "",
121
+ done: false,
122
+ at: change.at
123
+ };
124
+ s.entries.push(created);
125
+ entry = created;
126
+ }
127
+ if (change.text !== undefined) entry.text = change.text;else if (change.delta) entry.text += change.delta;
128
+ if (change.done) entry.done = true;
129
+ if (s.status === "starting") s.status = "running";
130
+ return;
131
+ }
132
+ case "tool":
133
+ {
134
+ let entry = findTool(s, change.call);
135
+ if (!entry) {
136
+ entry = {
137
+ kind: "tool",
138
+ call: change.call,
139
+ name: change.name ?? "tool",
140
+ state: "pending",
141
+ input: {},
142
+ output: "",
143
+ at: change.started ?? change.at
144
+ };
145
+ s.entries.push(entry);
146
+ }
147
+ if (change.started !== undefined) entry.at = change.started;
148
+ if (change.summary) entry.summary = change.summary;
149
+ if (change.name) entry.name = change.name;
150
+ if (change.state) entry.state = change.state;
151
+ if (change.input && Object.keys(change.input).length > 0) entry.input = change.input;
152
+ if (change.output !== undefined) entry.output = change.output;
153
+ if (change.error) entry.error = change.error;
154
+ if (change.state === "completed" || change.state === "failed") entry.ended = change.ended ?? change.at;
155
+ if (s.status === "starting") s.status = "running";
156
+ return;
157
+ }
158
+ case "usage":
159
+ if (change.tokens !== undefined) s.tokens = change.tokens;
160
+ if (change.cost !== undefined) s.cost = change.cost;
161
+ return;
162
+ }
163
+ }
164
+ export function applyAll(model, changes) {
165
+ for (const change of changes) apply(model, change);
166
+ return model;
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------------------------------
170
+ // What the views ask
171
+
172
+ /** The subagents under `root`, depth first, oldest first at each level. */
173
+ export function subagentsOf(model, root) {
174
+ const children = new Map();
175
+ for (const s of model.sessions.values()) {
176
+ if (!s.parentID) continue;
177
+ const list = children.get(s.parentID) ?? [];
178
+ list.push(s);
179
+ children.set(s.parentID, list);
180
+ }
181
+ const out = [];
182
+ const seen = new Set();
183
+ const walk = (parent, depth) => {
184
+ const list = (children.get(parent) ?? []).sort((a, b) => a.started - b.started);
185
+ for (const s of list) {
186
+ if (seen.has(s.id)) continue; // a cycle in parentage would otherwise never end
187
+ seen.add(s.id);
188
+ out.push({
189
+ session: s,
190
+ depth
191
+ });
192
+ walk(s.id, depth + 1);
193
+ }
194
+ };
195
+ walk(root, 0);
196
+ return out;
197
+ }
198
+
199
+ /** The conversation a session belongs to: up its parents to the one with none. */
200
+ export function rootOf(model, id) {
201
+ let at = id;
202
+ for (let hop = 0; hop < 16; hop++) {
203
+ const parent = model.sessions.get(at)?.parentID;
204
+ if (!parent) return at;
205
+ at = parent;
206
+ }
207
+ return at;
208
+ }
209
+ const base = path => path.split("/").filter(Boolean).slice(-2).join("/");
210
+
211
+ /** What a tool call is *about*, in a few words: the file, the pattern, the command. */
212
+ export function toolTarget(name, input) {
213
+ const str = key => typeof input[key] === "string" ? input[key] : undefined;
214
+ const path = str("filePath") ?? str("path") ?? str("file");
215
+ switch (name) {
216
+ case "read":
217
+ case "write":
218
+ case "edit":
219
+ case "patch":
220
+ case "list":
221
+ case "ls":
222
+ return path ? base(path) : "";
223
+ case "grep":
224
+ return [str("pattern") ? `"${str("pattern")}"` : "", str("include") ?? (path ? base(path) : "")].filter(Boolean).join(" ");
225
+ case "glob":
226
+ return str("pattern") ?? "";
227
+ case "bash":
228
+ case "shell":
229
+ return (str("command") ?? "").replace(/\s+/g, " ").trim();
230
+ case "task":
231
+ case "subagent":
232
+ return str("description") ?? "";
233
+ case "webfetch":
234
+ return str("url") ?? "";
235
+ default:
236
+ {
237
+ const first = Object.values(input).find(value => typeof value === "string");
238
+ return first ? first.replace(/\s+/g, " ") : "";
239
+ }
240
+ }
241
+ }
242
+ /** What a subagent is doing right now — the line under its name in the sidebar. */
243
+ export function activityOf(s) {
244
+ if (s.status === "failed") return {
245
+ kind: "failed",
246
+ text: s.error ?? "failed",
247
+ since: s.ended ?? s.started
248
+ };
249
+ if (s.status === "waiting") return {
250
+ kind: "waiting",
251
+ text: "waiting for permission",
252
+ since: s.since
253
+ };
254
+ const tools = s.entries.filter(entry => entry.kind === "tool");
255
+ if (s.status === "done") {
256
+ return {
257
+ kind: "done",
258
+ text: `${tools.length} tool${tools.length === 1 ? "" : "s"}`,
259
+ since: s.ended ?? s.started
260
+ };
261
+ }
262
+ const last = s.entries.at(-1);
263
+ const running = tools.filter(tool => tool.state === "running" || tool.state === "pending").at(-1);
264
+ if (running) return {
265
+ kind: "tool",
266
+ tool: running.name,
267
+ text: toolTarget(running.name, running.input),
268
+ since: running.at
269
+ };
270
+ if (last?.kind === "thinking" && !last.done) return {
271
+ kind: "thinking",
272
+ text: "thinking",
273
+ since: last.at
274
+ };
275
+ if (last?.kind === "reply" && !last.done) return {
276
+ kind: "writing",
277
+ text: "writing its answer",
278
+ since: last.at
279
+ };
280
+ if (s.status === "starting") return {
281
+ kind: "starting",
282
+ text: "starting",
283
+ since: s.started
284
+ };
285
+ return {
286
+ kind: "thinking",
287
+ text: "thinking",
288
+ since: last?.at ?? s.started
289
+ };
290
+ }
291
+
292
+ /** Counts for the block's heading. */
293
+ export function countsOf(nodes) {
294
+ const of = test => nodes.filter(node => test(node.session)).length;
295
+ return {
296
+ total: nodes.length,
297
+ running: of(s => s.status === "running" || s.status === "starting" || s.status === "waiting"),
298
+ done: of(s => s.status === "done"),
299
+ failed: of(s => s.status === "failed")
300
+ };
301
+ }