@howarewoo/pi-subagents 0.0.0-stage → 0.1.1

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/src/hub.ts ADDED
@@ -0,0 +1,841 @@
1
+ import {
2
+ colorToOklch,
3
+ Input,
4
+ matchesKey,
5
+ oklchColor,
6
+ sliceByColumn,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ wrapTextWithAnsi,
10
+ type Component,
11
+ type Focusable,
12
+ type KeybindingsManager,
13
+ } from "@earendil-works/pi-tui";
14
+ import type { Theme } from "@earendil-works/pi-coding-agent";
15
+ import type { ChildRef, ChildSnapshot, DelegationCoordinator } from "./coordinator.ts";
16
+ import { createNativeThread, safe, type LiveTool, type ThreadDependencies } from "./thread.ts";
17
+ export { safe } from "./thread.ts";
18
+
19
+ type Palette = Pick<Theme, "fg" | "style"> & { colors: Pick<Theme["colors"], "customMessageBg"> };
20
+ const lineText = (text: string) => safe(text).replaceAll("\n", " ");
21
+ const assignmentHeadline = (child: ChildSnapshot) =>
22
+ safe(child.assignment)
23
+ .split("\n")
24
+ .map((line) => line.trim())
25
+ .find(Boolean)
26
+ ?.replace(/^#{1,6}\s+/, "") || "Assignment";
27
+ export const agentLabel = (child: ChildSnapshot) =>
28
+ `${assignmentHeadline(child)} · ${lineText(child.profile)}`;
29
+ const active = (child: ChildSnapshot) => !child.disposed;
30
+ const activity = (child: ChildSnapshot) =>
31
+ child.error ||
32
+ (active(child) ? child.activity || child.progress : child.result) ||
33
+ child.progress ||
34
+ "Waiting for SDK activity";
35
+ export const writerWarning = "Model-tool writer only: no conflicting manual !/!! or external edits";
36
+ const statuses = [
37
+ "queued",
38
+ "setup",
39
+ "running",
40
+ "draining",
41
+ "settled",
42
+ "failed",
43
+ "cancelled",
44
+ ] as const;
45
+ export function fleetSummary(children: ChildSnapshot[]): string {
46
+ return children.length
47
+ ? statuses
48
+ .map((status) => `${status} ${children.filter((child) => child.status === status).length}`)
49
+ .join(" · ")
50
+ : "No tasks · ask the model to delegate_task";
51
+ }
52
+
53
+ /** Event-driven, bounded current-agent tree; history and full metrics remain in the Hub. */
54
+ export function createChildWidget(
55
+ snapshot: () => ChildSnapshot[],
56
+ theme: Pick<Theme, "fg"> & Partial<Pick<Theme, "bold">>,
57
+ availableRows = () => 30,
58
+ ): Component {
59
+ return {
60
+ render(width) {
61
+ width = Math.max(0, width);
62
+ const children = snapshot();
63
+ if (!children.length) return [];
64
+ const queued = children.filter((child) => child.status === "queued").length;
65
+ const priority = (child: ChildSnapshot) =>
66
+ child.status === "draining"
67
+ ? 0
68
+ : child.status === "running"
69
+ ? 1
70
+ : child.status === "setup"
71
+ ? 2
72
+ : 3;
73
+ const live = children
74
+ .filter(
75
+ (child) =>
76
+ !child.disposed &&
77
+ (child.status === "setup" || child.status === "running" || child.status === "draining"),
78
+ )
79
+ .sort((a, b) => priority(a) - priority(b));
80
+ const writer = children.some((child) => child.writerLease);
81
+ const budget = Math.max(1, Math.min(5, Math.floor(availableRows() / 4)));
82
+ const counts =
83
+ width < 60
84
+ ? `${live.length} active · ${queued} queued`
85
+ : statuses
86
+ .flatMap((status) => {
87
+ const count = children.filter((child) => child.status === status).length;
88
+ return count ? [`${count} ${status}`] : [];
89
+ })
90
+ .join(" · ");
91
+ const rows = [
92
+ theme.fg("accent", `● Subagents ${children.length}`) + theme.fg("muted", ` · ${counts}`),
93
+ ];
94
+ if (writer && budget === 1)
95
+ rows[0] = theme.fg(
96
+ "warning",
97
+ `● Subagents ${children.length} · Model writer: !/!! + external unsafe · ${queued} queued`,
98
+ );
99
+ const room = Math.max(0, budget - 1 - (writer && budget > 1 ? 1 : 0));
100
+ const activityRows = live.length && room >= (live.length > 1 ? 3 : 2) ? 1 : 0;
101
+ const slots = room - activityRows;
102
+ const overflow = live.length > slots;
103
+ const shown = live.slice(0, Math.max(0, slots - (overflow ? 1 : 0)));
104
+ shown.forEach((child, index) => {
105
+ const last = index === shown.length - 1 && shown.length === live.length;
106
+ const color = child.status === "draining" ? "warning" : "accent";
107
+ const role = lineText(child.profile);
108
+ const indent = " ".repeat(Math.min(3, Math.max(0, child.depth - 1)));
109
+ const prefix =
110
+ theme.fg("muted", `${indent}${last ? "└─" : "├─"} `) +
111
+ theme.fg(color, "● ") +
112
+ (theme.bold?.(role) ?? role) +
113
+ " ";
114
+ const elapsed = Math.max(
115
+ 0,
116
+ Math.floor(((child.endedAt ?? Date.now()) - child.createdAt) / 1000),
117
+ );
118
+ const stats = [
119
+ child.status,
120
+ `${elapsed}s`,
121
+ `${child.toolCalls} tools`,
122
+ ...(child.telemetry.requests === null ? [] : [`${child.telemetry.requests} req`]),
123
+ ...(child.telemetry.tokens ? [`${child.telemetry.tokens.total} tok`] : []),
124
+ ...(child.telemetry.cost === null ? [] : [`$${child.telemetry.cost.toFixed(3)}`]),
125
+ ].join(" · ");
126
+ const metric = truncateToWidth(stats, Math.max(8, Math.floor(width / 2)));
127
+ const headline = assignmentHeadline(child);
128
+ rows.push(
129
+ prefix +
130
+ theme.fg(
131
+ "text",
132
+ truncateToWidth(
133
+ headline,
134
+ Math.max(4, width - visibleWidth(prefix) - visibleWidth(metric) - 3),
135
+ ),
136
+ ) +
137
+ theme.fg("muted", " · " + metric),
138
+ );
139
+ if (!index && activityRows) {
140
+ const prefix = `${indent}${last ? " " : "│ "} └ `;
141
+ const text = lineText(activity(child));
142
+ const columns = Math.max(0, width - visibleWidth(prefix));
143
+ const length = visibleWidth(text);
144
+ // Progress is an accumulated tail; keep its newest text, not a frozen opening paragraph.
145
+ const preview =
146
+ length > columns
147
+ ? "…" +
148
+ sliceByColumn(text, length - Math.max(0, columns - 1), Math.max(0, columns - 1))
149
+ : text;
150
+ rows.push(theme.fg("muted", prefix + preview));
151
+ }
152
+ });
153
+ if (shown.length < live.length && room)
154
+ rows.push(theme.fg("muted", `└─ ${live.length - shown.length} more · /subagent-hub`));
155
+ if (writer && budget > 1)
156
+ rows.push(
157
+ theme.fg("warning", width < 75 ? "Model writer: !/!! + external unsafe" : writerWarning),
158
+ );
159
+ return rows.slice(0, budget).map((line) => truncateToWidth(line, width));
160
+ },
161
+ invalidate() {},
162
+ };
163
+ }
164
+
165
+ export function taskCard(child: ChildSnapshot | undefined, expanded = false): string[] {
166
+ if (!child)
167
+ return [
168
+ "Task identity unavailable",
169
+ "See tool error; /subagent-hub lists current runtime tasks",
170
+ ];
171
+ return [
172
+ `${truncateToWidth(agentLabel(child), 240, "… [preview truncated]")} #${child.generation} · ${child.status}`,
173
+ `Identity ${child.id} #${child.generation}`,
174
+ `${child.model.provider}/${child.model.id} · effort ${child.effort ?? "unavailable"}`,
175
+ truncateToWidth(lineText(activity(child)), 240, "… [preview truncated]"),
176
+ `Prewalk ${child.prewalk ? JSON.stringify(child.prewalk) : "off"} · advisor unavailable`,
177
+ `Parent ${child.parentId}${child.parentGeneration ? ` #${child.parentGeneration}` : ""} · depth ${child.depth} · writer lease ${child.writerLease}`,
178
+ ...(child.outcome
179
+ ? [
180
+ `Structured ${child.outcome.validation} · ${child.outcome.finalized ? "finalized" : "provisional"} · attempts ${child.outcome.attempts}`,
181
+ ]
182
+ : []),
183
+ ...(child.limitReason ? [`Limit: ${child.limitReason}`] : []),
184
+ ...(child.artifacts ? [`Artifacts: ${JSON.stringify(child.artifacts)}`] : []),
185
+ ...(expanded
186
+ ? [
187
+ "Assignment: " +
188
+ truncateToWidth(lineText(child.assignment), 480, "… [preview truncated]"),
189
+ "Result: " +
190
+ truncateToWidth(
191
+ lineText(child.result || child.error || "Not available yet"),
192
+ 480,
193
+ "… [preview truncated]",
194
+ ),
195
+ ]
196
+ : []),
197
+ child.transcript
198
+ ? `Full transcript: ${child.transcript} · /subagent-hub`
199
+ : "Transcript unavailable (setup) · /subagent-hub",
200
+ ].map(lineText);
201
+ }
202
+
203
+ /** Read-only content projection: never dumps provider/auth objects, image payloads or opaque signatures. */
204
+ export function transcriptLines(
205
+ messages: ReturnType<DelegationCoordinator["messages"]>,
206
+ toolsOnly = false,
207
+ ): string[] {
208
+ const lines: string[] = [];
209
+ for (const message of messages) {
210
+ if (message.role === "system") continue;
211
+ const start = lines.length;
212
+ const blocks = "content" in message ? message.content : undefined;
213
+ if (!toolsOnly || message.role === "toolResult") {
214
+ lines.push(
215
+ `[${message.role}${message.role === "toolResult" ? ` ${message.toolName} ${message.toolCallId} ${message.isError ? "ERROR" : "result"}` : ""}]`,
216
+ );
217
+ if (typeof blocks === "string") lines.push(safe(blocks));
218
+ }
219
+ if (Array.isArray(blocks))
220
+ for (const block of blocks) {
221
+ if (block.type === "toolCall")
222
+ lines.push(
223
+ `Tool ${block.name} ${block.id}\nArguments: ${safe(JSON.stringify(block.arguments, null, 2))}`,
224
+ );
225
+ else if (!toolsOnly || message.role === "toolResult") {
226
+ if (block.type === "text") lines.push(safe(block.text));
227
+ else if (block.type === "thinking") lines.push("Thinking: " + safe(block.thinking));
228
+ else if (block.type === "image")
229
+ lines.push(`[image ${safe(block.mimeType)}; payload omitted]`);
230
+ }
231
+ }
232
+ if (message.role === "assistant" && message.errorMessage)
233
+ lines.push("Error: " + safe(message.errorMessage));
234
+ if (start && lines.length > start) lines.splice(start, 0, "");
235
+ }
236
+ return lines.length
237
+ ? lines.map(safe)
238
+ : [toolsOnly ? "No tool calls yet" : "Waiting for SDK messages"];
239
+ }
240
+
241
+ /** Observation only: closing or disposing never touches child execution. */
242
+ export function createHub(
243
+ coordinator: DelegationCoordinator,
244
+ theme: Palette,
245
+ keybindings: Pick<KeybindingsManager, "matches" | "getKeys">,
246
+ requestRender: () => void,
247
+ done: () => void,
248
+ dependencies: ThreadDependencies,
249
+ availableRows = () => 24,
250
+ ): Component & Focusable & { dispose(): void } {
251
+ let selected: string | undefined;
252
+ let query = "";
253
+ let interactionTarget: ChildRef | undefined;
254
+ let interactionVisibleAtHeight: number | undefined;
255
+ let mode: "browse" | "search" | "steer" | "message" | "stop" = "browse";
256
+ let tab: "thread" | "details" | "tools" = "thread";
257
+ let offset = 0;
258
+ let following = true;
259
+ let maxOffset = 0;
260
+ let threadBounds: { x: number; y: number; width: number; height: number } | undefined;
261
+ let viewport = 1;
262
+ let notice = "";
263
+ let disposed = false;
264
+ const input = new Input();
265
+ const nativeThread = createNativeThread(dependencies);
266
+ const refresh = () => {
267
+ if (!disposed) requestRender();
268
+ };
269
+ // In-flight projections only; finalized messages remain in the authoritative registry.
270
+ const streaming = new Map<string, ReturnType<DelegationCoordinator["messages"]>[number]>();
271
+ const liveTools = new Map<string, LiveTool>();
272
+ const unsubscribe = coordinator.subscribe((event) => {
273
+ if (event.event?.type === "message_update")
274
+ streaming.set(identity(event.child), event.event.message);
275
+ if (event.event?.type === "message_end" || event.child.disposed)
276
+ streaming.delete(identity(event.child));
277
+ const sdk = event.event;
278
+ if (sdk?.type === "tool_execution_start" || sdk?.type === "tool_execution_update") {
279
+ liveTools.set(identity(event.child), {
280
+ toolName: sdk.toolName,
281
+ toolCallId: sdk.toolCallId,
282
+ args: sdk.args,
283
+ ...(sdk.type === "tool_execution_update" ? { content: sdk.partialResult.content } : {}),
284
+ });
285
+ }
286
+ if (sdk?.type === "tool_execution_end" || event.child.disposed)
287
+ liveTools.delete(identity(event.child));
288
+ refresh();
289
+ });
290
+ const identity = (ref: ChildRef) => `${ref.id}/${ref.generation}`;
291
+ const roster = () =>
292
+ coordinator
293
+ .list()
294
+ .flatMap((child) => coordinator.generations(child.id))
295
+ .filter((child) =>
296
+ lineText(
297
+ [
298
+ child.id,
299
+ child.parentId,
300
+ child.profile,
301
+ child.status,
302
+ child.assignment,
303
+ child.progress,
304
+ child.result,
305
+ child.error,
306
+ child.model.id,
307
+ ].join(" "),
308
+ )
309
+ .toLowerCase()
310
+ .includes(query.toLowerCase()),
311
+ );
312
+ const selection = () => {
313
+ const children = roster();
314
+ if (!children.some((child) => identity(child) === selected)) {
315
+ selected = children[0] && identity(children[0]);
316
+ offset = 0;
317
+ following = tab === "thread";
318
+ }
319
+ return children.find((child) => identity(child) === selected);
320
+ };
321
+ const scrollBy = (delta: number) => {
322
+ offset = Math.max(0, Math.min(maxOffset, offset + delta));
323
+ if (tab === "thread") following = delta > 0 && offset === maxOffset;
324
+ refresh();
325
+ };
326
+ const control = async (target: ChildRef, text?: string, message = false) => {
327
+ notice =
328
+ text === undefined
329
+ ? "Stopping and draining…"
330
+ : message
331
+ ? "Sending inbox message…"
332
+ : "Sending steering…";
333
+ refresh();
334
+ try {
335
+ const child = coordinator.snapshot(target);
336
+ if (
337
+ !child ||
338
+ child.disposed ||
339
+ child.status === "draining" ||
340
+ (text !== undefined && child.status !== "running")
341
+ )
342
+ throw new Error(
343
+ `Target ${target.id} #${target.generation} is no longer running; no action sent`,
344
+ );
345
+ if (text === undefined) {
346
+ await coordinator.stop(target);
347
+ notice = "Stopped and drained";
348
+ } else if (message) {
349
+ const receipt = await coordinator.message(undefined, target, text);
350
+ notice = `Message ${receipt.id} · ${receipt.status} · NOT model acknowledgment${receipt.error ? ` · ${receipt.error}` : ""}`;
351
+ } else notice = `Steering ${await coordinator.steer(target, text)}`;
352
+ } catch (error) {
353
+ notice = String(error);
354
+ }
355
+ refresh();
356
+ };
357
+ const observeControl = async (target: ChildRef, action: "wait" | "park") => {
358
+ try {
359
+ if (action === "park") {
360
+ coordinator.park(target);
361
+ notice = "Parked (session already disposed)";
362
+ } else {
363
+ notice = "Waiting up to 1000ms (not model acknowledgment)…";
364
+ refresh();
365
+ notice = JSON.stringify(await coordinator.coordinateWait(undefined, target, 1000));
366
+ }
367
+ } catch (error) {
368
+ notice = String(error);
369
+ }
370
+ refresh();
371
+ };
372
+ const canSubmitInteraction = () => {
373
+ if (interactionVisibleAtHeight === Math.max(1, availableRows())) return true;
374
+ notice = "Resize for controls · Esc cancels; no action sent";
375
+ refresh();
376
+ return false;
377
+ };
378
+ const finishInput = () => {
379
+ if (interactionTarget && !canSubmitInteraction()) return;
380
+ if (mode === "search") {
381
+ query = lineText(input.getValue());
382
+ offset = 0;
383
+ following = tab === "thread";
384
+ } else if ((mode === "steer" || mode === "message") && interactionTarget)
385
+ void control(interactionTarget, input.getValue(), mode === "message");
386
+ interactionTarget = undefined;
387
+ mode = "browse";
388
+ input.setValue("");
389
+ refresh();
390
+ };
391
+ return {
392
+ focused: false,
393
+ render(width) {
394
+ interactionVisibleAtHeight = undefined;
395
+ threadBounds = undefined;
396
+ width = Math.max(0, width);
397
+ const height = Math.max(1, availableRows());
398
+ const child = selection();
399
+ const panelBg = oklchColor(colorToOklch(theme.colors.customMessageBg).l, 0, 0);
400
+ const paint = (text: string) => {
401
+ const clipped = truncateToWidth(text, width);
402
+ const padded = clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
403
+ const restoreBg = theme.style("", { bg: panelBg }).replace(/\x1b\[49m$/, "");
404
+ return padded
405
+ .split("\x1b[0m")
406
+ .map((part) => theme.style(part.replaceAll("\x1b[49m", restoreBg), { bg: panelBg }))
407
+ .join("\x1b[0m");
408
+ };
409
+ if (width < 4 || height < 3)
410
+ return [
411
+ paint(
412
+ interactionTarget
413
+ ? "Resize · Esc cancels"
414
+ : "Subagent Hub " + coordinator.list().length,
415
+ ),
416
+ ];
417
+ const inner = Math.max(0, width - 4);
418
+ const row = (text: string) => {
419
+ const clipped = truncateToWidth(text, inner);
420
+ return paint(
421
+ theme.fg("border", "│") +
422
+ " " +
423
+ clipped +
424
+ " ".repeat(Math.max(1, width - 3 - visibleWidth(clipped))) +
425
+ theme.fg("border", "│"),
426
+ );
427
+ };
428
+ const frame = (left: string, title: string, right: string) => {
429
+ const text = truncateToWidth(title, width - 2);
430
+ return paint(
431
+ theme.fg("border", left) +
432
+ theme.fg("accent", text) +
433
+ theme.fg("border", "─".repeat(Math.max(0, width - 2 - visibleWidth(text))) + right),
434
+ );
435
+ };
436
+ const children = roster();
437
+ const current = coordinator.list();
438
+ const historical = (item: ChildRef) =>
439
+ !current.some((live) => identity(live) === identity(item));
440
+ const index = children.findIndex((item) => identity(item) === selected);
441
+ const wide = width >= 76;
442
+ const leftWidth = wide ? Math.min(32, Math.max(24, Math.floor(inner / 3))) : 0;
443
+ const threadWidth = wide ? inner - leftWidth - 3 : inner;
444
+ const close = [
445
+ ...new Set([
446
+ ...keybindings.getKeys("tui.select.cancel"),
447
+ ...keybindings.getKeys("tui.select.confirm"),
448
+ ]),
449
+ ].join("/");
450
+ const footer: string[] = [];
451
+ if (query)
452
+ footer.push(
453
+ theme.fg("muted", `Filter: ${lineText(query)} · ${children.length} generations`),
454
+ );
455
+ if (current.some((item) => item.writerLease))
456
+ footer.push(theme.fg("warning", "WRITER: model tools only; no manual/external writes"));
457
+ if (notice) {
458
+ const lines = wrapTextWithAnsi(lineText(notice), Math.max(1, inner));
459
+ footer.push(
460
+ ...lines
461
+ .slice(0, 3)
462
+ .map((line, index) =>
463
+ theme.fg(
464
+ "warning",
465
+ index === 2 && lines.length > 3
466
+ ? truncateToWidth(line, Math.max(0, inner - 19)) + " [notice truncated]"
467
+ : line,
468
+ ),
469
+ ),
470
+ );
471
+ }
472
+ const targetLines = interactionTarget
473
+ ? wrapTextWithAnsi(
474
+ `Target: ${interactionTarget.id} #${interactionTarget.generation}`,
475
+ Math.max(1, inner),
476
+ )
477
+ : [];
478
+ if (interactionTarget && (inner < 26 || height < targetLines.length + 6)) {
479
+ return [
480
+ frame("╭", " Resize / Esc cancel ", "╮"),
481
+ ...targetLines.slice(0, height - 3).map(row),
482
+ row("Resize for controls · Esc cancels"),
483
+ frame("╰", "", "╯"),
484
+ ];
485
+ }
486
+ footer.push(...targetLines.map((line) => theme.fg("accent", line)));
487
+ if (mode === "search" || mode === "steer" || mode === "message") {
488
+ input.focused = this.focused;
489
+ const label = mode === "search" ? "Filter: " : mode === "message" ? "Inbox: " : "Steer: ";
490
+ footer.push(
491
+ label + input.render(Math.max(1, inner - visibleWidth(label)))[0],
492
+ "Enter submit · Esc back (no action)",
493
+ );
494
+ } else if (mode === "stop")
495
+ footer.push("Stop target? y confirms · Esc back", "Enter does not confirm stop");
496
+ else
497
+ footer.push(
498
+ ...(wide
499
+ ? [
500
+ "↑↓ agents · j/k line · Ctrl+u/d or PgUp/Dn page · Home/End",
501
+ `${close}: close · / filter Tab view · s steer m inbox x stop · w wait p park`,
502
+ ]
503
+ : [
504
+ "↑↓ agent · j/k scroll · / Tab",
505
+ `End live · s/m/x · ${(keybindings.getKeys("tui.select.cancel")[0] ?? "Esc").replaceAll("escape", "Esc")}: close`,
506
+ ]),
507
+ );
508
+ // A pinned target and its controls are indivisible; optional hints must yield first.
509
+ const requiredFooter = interactionTarget ? targetLines.length + 2 : 0;
510
+ const shownFooter =
511
+ requiredFooter || height >= 8 ? footer.slice(-Math.max(2, height - 7, requiredFooter)) : [];
512
+ if (interactionTarget) interactionVisibleAtHeight = height;
513
+ const separatorRows = shownFooter.length ? 1 : 0;
514
+ const paneHeight = Math.max(1, height - 2 - shownFooter.length - separatorRows);
515
+ viewport = Math.max(1, paneHeight - 3);
516
+ let detail: string[] = [];
517
+ let nativeLines: string[] | undefined;
518
+ if (child) {
519
+ if (tab === "details") {
520
+ const metric = (value: unknown) =>
521
+ value === null || value === undefined ? "unavailable" : JSON.stringify(value);
522
+ detail = [
523
+ `Identity ${child.id} #${child.generation}`,
524
+ "",
525
+ "Assignment",
526
+ safe(child.assignment),
527
+ "",
528
+ "Result",
529
+ safe(child.result || "Not available yet"),
530
+ "",
531
+ ...(child.error ? ["Error", safe(child.error), ""] : []),
532
+ ...(child.outcome
533
+ ? ["Structured outcome", JSON.stringify(child.outcome, null, 2), ""]
534
+ : []),
535
+ ...(child.artifacts
536
+ ? ["Saved artifacts", JSON.stringify(child.artifacts, null, 2), ""]
537
+ : []),
538
+ `Workspace: ${safe(child.cwd ?? "parent workspace")}`,
539
+ `Effective builtin tools: ${child.tools.join(", ")}`,
540
+ `${child.model.provider}/${child.model.id} · effort ${child.effort ?? "unavailable"}`,
541
+ `Prewalk ${child.prewalk ? JSON.stringify(child.prewalk) : "off"} · advisor unavailable`,
542
+ `Presence ${coordinator.agents().find((agent) => agent.id === child.id && agent.generation === child.generation)?.presence ?? "historical"} · resources ${child.disposed ? "disposed" : "live"}`,
543
+ `Elapsed ${Math.max(0, Math.floor(((child.endedAt ?? Date.now()) - child.createdAt) / 1000))}s`,
544
+ `Direct requests ${metric(child.telemetry.requests)} · cost ${metric(child.telemetry.cost)}`,
545
+ `Direct tokens ${metric(child.telemetry.tokens)}`,
546
+ `Context ${metric(child.telemetry.context)}`,
547
+ `Parent ${child.parentId} #${child.parentGeneration ?? "root"} · depth ${child.depth} · direct generation only`,
548
+ `Limits ${JSON.stringify(child.limits)} · calls ${child.toolCalls} · reason ${child.limitReason ?? "none"}`,
549
+ "Activity: " + safe(activity(child)),
550
+ "Full transcript: " + safe(child.transcript ?? "unavailable (queued/setup)"),
551
+ "Followup/revive: native delegate_coordinate only (Hub has no tool dispatcher)",
552
+ ...(child.writerLease ? [writerWarning] : []),
553
+ ];
554
+ } else {
555
+ try {
556
+ const messages = coordinator.messages(child);
557
+ const partial = streaming.get(identity(child));
558
+ if (
559
+ tab === "thread" &&
560
+ (messages.length || partial || liveTools.has(identity(child)))
561
+ ) {
562
+ nativeLines = nativeThread.render(
563
+ identity(child),
564
+ messages,
565
+ partial,
566
+ liveTools.get(identity(child)),
567
+ threadWidth,
568
+ );
569
+ } else
570
+ detail =
571
+ messages.length || partial
572
+ ? [
573
+ ...transcriptLines(partial ? [...messages, partial] : messages, true),
574
+ ...(liveTools.get(identity(child))
575
+ ? [
576
+ "Live tool: " + liveTools.get(identity(child))!.toolName,
577
+ "Arguments: " +
578
+ JSON.stringify(liveTools.get(identity(child))!.args, null, 2),
579
+ ...(liveTools.get(identity(child))!.content ?? []).flatMap((block) =>
580
+ block.type === "text" ? [block.text ?? ""] : [],
581
+ ),
582
+ ]
583
+ : []),
584
+ ]
585
+ : [
586
+ child.error ||
587
+ child.result ||
588
+ (child.status === "queued"
589
+ ? "Queued · no SDK messages yet"
590
+ : "Waiting for SDK messages"),
591
+ ];
592
+ } catch (error) {
593
+ detail = ["Transcript unavailable: " + safe(String(error))];
594
+ }
595
+ }
596
+ } else
597
+ detail = [
598
+ query
599
+ ? "No matching tasks · / to change filter"
600
+ : "No tasks · ask the model to delegate_task",
601
+ ];
602
+ const wrapped =
603
+ nativeLines ??
604
+ detail.flatMap((text) =>
605
+ safe(text)
606
+ .split("\n")
607
+ .flatMap((line) => wrapTextWithAnsi(line, Math.max(1, threadWidth))),
608
+ );
609
+ maxOffset = Math.max(0, wrapped.length - viewport);
610
+ offset = tab === "thread" && following ? maxOffset : Math.max(0, Math.min(offset, maxOffset));
611
+ threadBounds = {
612
+ x: wide ? 2 + leftWidth + 3 : 2,
613
+ y: 3,
614
+ width: threadWidth,
615
+ height: Math.max(0, paneHeight - 3),
616
+ };
617
+ const content = wrapped
618
+ .slice(offset, offset + viewport)
619
+ .map((line) => (nativeLines ? line : theme.fg("text", line)));
620
+ const statusColor = (item: ChildSnapshot): "error" | "warning" | "success" | "accent" =>
621
+ item.status === "failed"
622
+ ? "error"
623
+ : item.status === "cancelled"
624
+ ? "warning"
625
+ : item.status === "settled"
626
+ ? "success"
627
+ : "accent";
628
+ const selectedLabel = child
629
+ ? `${agentLabel(child)} #${child.generation} · ${child.status}${historical(child) ? " · history" : ""}`
630
+ : "No selection";
631
+ const viewLabel = `${tab.toUpperCase()} · Tab: ${tab === "thread" ? "details" : tab === "details" ? "tools" : "thread"}`;
632
+ const position = `${tab === "thread" ? (following ? "LIVE · " : "READING · ") : ""}${offset + 1}–${Math.min(wrapped.length, offset + viewport)}/${wrapped.length} lines${tab === "thread" ? (following ? " · k/PgUp read" : " · End: live") : offset < maxOffset ? " · more ↓" : ""}`;
633
+ const pane: string[] = [];
634
+ if (wide) {
635
+ const slots = Math.max(1, Math.floor((paneHeight - 2) / 3));
636
+ const start = Math.max(0, index - slots + 1);
637
+ const left = [
638
+ theme.fg("accent", `SUBAGENTS · ${children.length} generations`),
639
+ ...children
640
+ .slice(start, start + slots)
641
+ .flatMap((item) => [
642
+ theme.fg(
643
+ identity(item) === selected ? "accent" : "text",
644
+ `${identity(item) === selected ? ">" : " "} ${" ".repeat(Math.min(4, Math.max(0, item.depth - 1)))}└ ${agentLabel(item)} #${item.generation}${historical(item) ? " · history" : ""}`,
645
+ ),
646
+ theme.fg(statusColor(item), ` ${item.profile} · ${item.status}`),
647
+ theme.fg("muted", ` #${item.generation}${historical(item) ? " · history" : ""}`),
648
+ ]),
649
+ ];
650
+ const right = [
651
+ theme.fg("accent", lineText(selectedLabel)),
652
+ theme.fg("muted", viewLabel),
653
+ ...content,
654
+ ];
655
+ const cell = (text: string, columns: number) => {
656
+ const clipped = truncateToWidth(text, columns);
657
+ return clipped + " ".repeat(Math.max(0, columns - visibleWidth(clipped)));
658
+ };
659
+ for (let index = 0; index < paneHeight; index++)
660
+ pane.push(
661
+ cell(
662
+ index === paneHeight - 1
663
+ ? theme.fg(
664
+ "muted",
665
+ `${children.length ? start + 1 : 0}–${Math.min(children.length, start + slots)}/${children.length} · ↑↓ select`,
666
+ )
667
+ : (left[index] ?? ""),
668
+ leftWidth,
669
+ ) +
670
+ theme.fg("border", " │ ") +
671
+ cell(
672
+ index === paneHeight - 1 ? theme.fg("muted", position) : (right[index] ?? ""),
673
+ threadWidth,
674
+ ),
675
+ );
676
+ } else {
677
+ const suffix = child ? ` #${child.generation} · ${index + 1}/${children.length}` : "";
678
+ pane.push(
679
+ theme.fg(
680
+ "accent",
681
+ child
682
+ ? `> └ ${truncateToWidth(agentLabel(child), Math.max(0, inner - 4 - visibleWidth(suffix)))}${suffix}`
683
+ : "No selection",
684
+ ),
685
+ );
686
+ if (paneHeight > 1)
687
+ pane.push(
688
+ theme.fg(
689
+ child ? statusColor(child) : "muted",
690
+ `${tab.toUpperCase()}${child ? ` · ${child.profile} · ${child.status}` : ""}`,
691
+ ),
692
+ );
693
+ if (paneHeight > 2)
694
+ pane.push(
695
+ ...content.slice(0, paneHeight - 3),
696
+ ...Array(Math.max(0, paneHeight - 3 - content.length)).fill(""),
697
+ theme.fg("muted", position),
698
+ );
699
+ }
700
+ return [
701
+ frame("╭", " Subagent Hub ", "╮"),
702
+ ...pane.slice(0, height - 2).map(row),
703
+ ...(separatorRows ? [paint(theme.fg("border", "├" + "─".repeat(width - 2) + "┤"))] : []),
704
+ ...shownFooter.map(row),
705
+ frame("╰", "", "╯"),
706
+ ];
707
+ },
708
+ handleInput(data) {
709
+ if (keybindings.matches(data, "tui.select.cancel")) {
710
+ if (mode !== "browse") {
711
+ mode = "browse";
712
+ interactionTarget = undefined;
713
+ input.setValue("");
714
+ refresh();
715
+ } else done();
716
+ return;
717
+ }
718
+ if (mode === "search" || mode === "steer" || mode === "message") {
719
+ if (keybindings.matches(data, "tui.select.confirm")) finishInput();
720
+ else {
721
+ input.handleInput(data);
722
+ const cleaned = lineText(input.getValue());
723
+ if (cleaned !== input.getValue()) input.setValue(cleaned);
724
+ refresh();
725
+ }
726
+ return;
727
+ }
728
+ if (mode === "stop") {
729
+ if (data === "y") {
730
+ if (interactionTarget && !canSubmitInteraction()) return;
731
+ if (interactionTarget) void control(interactionTarget);
732
+ interactionTarget = undefined;
733
+ mode = "browse";
734
+ }
735
+ refresh();
736
+ return;
737
+ }
738
+ if (keybindings.matches(data, "tui.select.confirm")) {
739
+ done();
740
+ return;
741
+ }
742
+ if (
743
+ keybindings.matches(data, "tui.select.up") ||
744
+ keybindings.matches(data, "tui.select.down")
745
+ ) {
746
+ const children = roster();
747
+ const index = children.findIndex((child) => identity(child) === selected);
748
+ const next =
749
+ children[
750
+ Math.max(
751
+ 0,
752
+ Math.min(
753
+ children.length - 1,
754
+ index + (keybindings.matches(data, "tui.select.up") ? -1 : 1),
755
+ ),
756
+ )
757
+ ];
758
+ selected = next && identity(next);
759
+ offset = 0;
760
+ following = tab === "thread";
761
+ notice = "";
762
+ } else if (keybindings.matches(data, "tui.select.pageUp") || matchesKey(data, "ctrl+u"))
763
+ scrollBy(-viewport);
764
+ else if (keybindings.matches(data, "tui.select.pageDown") || matchesKey(data, "ctrl+d"))
765
+ scrollBy(viewport);
766
+ else if (data === "k") scrollBy(-1);
767
+ else if (data === "j") scrollBy(1);
768
+ else if (matchesKey(data, "home")) {
769
+ offset = 0;
770
+ following = false;
771
+ } else if (matchesKey(data, "end")) {
772
+ offset = maxOffset;
773
+ following = tab === "thread";
774
+ } else if (matchesKey(data, "tab")) {
775
+ tab = tab === "thread" ? "details" : tab === "details" ? "tools" : "thread";
776
+ offset = 0;
777
+ following = tab === "thread";
778
+ } else if (data === "/") {
779
+ mode = "search";
780
+ input.setValue(query);
781
+ // Native Input.setValue preserves its old cursor; a saved filter starts editing at its end.
782
+ input.handleInput("\x1b[F");
783
+ } else if (["w", "p", "f", "r"].includes(data)) {
784
+ const child = selection();
785
+ if (!child) notice = "Select an identity first";
786
+ else if (data === "w" || data === "p")
787
+ void observeControl(
788
+ { id: child.id, generation: child.generation },
789
+ data === "w" ? "wait" : "park",
790
+ );
791
+ else {
792
+ notice =
793
+ "Followup/revive: native model-issued delegate_coordinate only. Hub command context has no permission-preserving tool dispatcher; no generation started.";
794
+ }
795
+ } else if (data === "s" || data === "x" || data === "m") {
796
+ const child = selection();
797
+ if (
798
+ child &&
799
+ !child.disposed &&
800
+ child.status !== "draining" &&
801
+ (data === "x" || child.status === "running")
802
+ ) {
803
+ mode = data === "s" ? "steer" : data === "m" ? "message" : "stop";
804
+ interactionTarget = { id: child.id, generation: child.generation };
805
+ interactionVisibleAtHeight = undefined;
806
+ input.setValue("");
807
+ } else
808
+ notice =
809
+ "Controls unavailable: historical/disposed target, or task not running (stop accepts queued/setup/running)";
810
+ }
811
+ refresh();
812
+ },
813
+ handleMouse(event) {
814
+ if (event.type !== "wheel" || disposed) return;
815
+ // Overlay wheels outside the reading viewport must not fall through to the main chat.
816
+ if (
817
+ mode === "browse" &&
818
+ threadBounds &&
819
+ event.x >= threadBounds.x &&
820
+ event.x < threadBounds.x + threadBounds.width &&
821
+ event.y >= threadBounds.y &&
822
+ event.y < threadBounds.y + threadBounds.height &&
823
+ event.wheelDelta
824
+ )
825
+ scrollBy(event.wheelDelta);
826
+ return { handled: true };
827
+ },
828
+ invalidate() {
829
+ input.invalidate();
830
+ nativeThread.invalidate();
831
+ },
832
+ dispose() {
833
+ disposed = true;
834
+ interactionTarget = undefined;
835
+ unsubscribe();
836
+ streaming.clear();
837
+ liveTools.clear();
838
+ nativeThread.clear();
839
+ },
840
+ };
841
+ }