@animalabs/connectome-host 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.env.example +7 -0
  2. package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
  3. package/.github/workflows/changelog.yml +32 -0
  4. package/.github/workflows/publish.yml +46 -0
  5. package/CHANGELOG.md +140 -0
  6. package/CONTRIBUTING.md +126 -0
  7. package/HEADLESS-FLEET-PLAN.md +1 -1
  8. package/README.md +52 -5
  9. package/UNIFIED-TREE-PLAN.md +2 -0
  10. package/docs/AGENT-ONBOARDING.md +9 -8
  11. package/docs/DEPLOYMENTS.md +2 -2
  12. package/docs/DEV-ENVIRONMENT.md +28 -26
  13. package/docs/LOCUS-ROUTING-DESIGN.md +8 -2
  14. package/package.json +5 -4
  15. package/scripts/release-changelog.ts +32 -0
  16. package/src/codex-subscription-adapter.ts +938 -0
  17. package/src/commands.ts +95 -4
  18. package/src/extensions.ts +201 -0
  19. package/src/framework-agent-config.ts +20 -9
  20. package/src/framework-strategy.ts +24 -0
  21. package/src/index.ts +111 -19
  22. package/src/logging-bedrock-adapter.ts +145 -0
  23. package/src/modules/channel-mode-module.ts +9 -6
  24. package/src/modules/subscription-gc-module.ts +163 -34
  25. package/src/modules/tts-relay-module.ts +481 -0
  26. package/src/modules/web-ui-module.ts +45 -1
  27. package/src/recipe.ts +196 -10
  28. package/src/tui.ts +527 -137
  29. package/src/web/protocol.ts +35 -0
  30. package/test/codex-subscription-adapter.test.ts +226 -0
  31. package/test/commands-checkpoint-restore.test.ts +118 -0
  32. package/test/fast-command.test.ts +39 -0
  33. package/test/recipe-extensions.test.ts +295 -0
  34. package/test/recipe-provider.test.ts +14 -1
  35. package/test/subscription-gc-module.test.ts +156 -1
  36. package/test/tui-quit-confirm.test.ts +42 -0
  37. package/test/tui-short-agent-name.test.ts +36 -0
  38. package/test/web-ui-observers.test.ts +14 -0
  39. package/test/web-ui-protocol.test.ts +0 -0
  40. package/web/src/App.tsx +235 -20
  41. package/web/src/Branches.tsx +150 -0
  42. package/web/src/Context.tsx +21 -13
  43. package/web/src/Health.tsx +274 -0
  44. package/web/src/Lessons.tsx +2 -1
package/src/tui.ts CHANGED
@@ -47,6 +47,44 @@ export function fmtTokens(n: number): string {
47
47
  return String(n);
48
48
  }
49
49
 
50
+ /**
51
+ * What a submission at the armed /quit prompt means. Pure so the semantics
52
+ * are pinned by tests: the default for arbitrary input is CANCEL — the
53
+ * pre-fix behavior ("anything else → kill everything") meant a user who
54
+ * forgot the prompt and typed a normal chat message killed the fleet.
55
+ * `cancel-keep-input` = the input looks like a real message; the caller
56
+ * must restore it rather than discard it.
57
+ */
58
+ export type QuitConfirmAction = 'kill' | 'detach' | 'cancel' | 'cancel-keep-input';
59
+ export function resolveQuitConfirm(raw: string): QuitConfirmAction {
60
+ const c = raw.trim().toLowerCase();
61
+ // Re-typing the quit command at the prompt is a confirmation, not a cancellation.
62
+ if (c === 'y' || c === 'yes' || c === 'q' || c === 'quit' || c === '/q' || c === '/quit') return 'kill';
63
+ if (c === 'd' || c === 'detach') return 'detach';
64
+ if (c === '' || c === 'n' || c === 'no' || c === 'cancel') return 'cancel';
65
+ return 'cancel-keep-input';
66
+ }
67
+
68
+ /**
69
+ * Canonical short name for a subagent's full framework name. The single
70
+ * source of truth for full↔short resolution — ad-hoc `.includes()` matching
71
+ * here used to cross-wire agents whose names were substrings of each other
72
+ * (e.g. `web` / `websearch`).
73
+ *
74
+ * Naming schemes (see subagent-module.ts spawn/fork paths):
75
+ * spawn: `spawn-{name}-{ts}`
76
+ * fork: `{name}-d{depth}-{ts}` / `{name}-d{depth}-retry{n}-{ts}` — no
77
+ * fork- prefix at all; the -d{depth} suffix must be stripped too,
78
+ * or every fork resolves to e.g. `web-d1` and matches nothing.
79
+ */
80
+ export function shortAgentName(full: string): string {
81
+ return full
82
+ .replace(/^(spawn|fork)-/, '')
83
+ .replace(/-d\d+(-retry\d+)?-\d+$/, '')
84
+ .replace(/-\d+$/, '')
85
+ .replace(/-retry\d+$/, '');
86
+ }
87
+
50
88
  interface AppContext {
51
89
  framework: AgentFramework;
52
90
  membrane: Membrane;
@@ -66,6 +104,17 @@ interface TokenUsage {
66
104
  output: number;
67
105
  cacheRead: number;
68
106
  cacheWrite: number;
107
+ /** Session cost estimate from the framework's UsageTracker, when priced. */
108
+ cost?: { total: number; currency: string };
109
+ }
110
+
111
+ /** One active operator alert from the ops:alert pipeline, keyed
112
+ * `${agent}:${kind}` (fleet-child alerts prefix the child name). */
113
+ interface OpsAlertEntry {
114
+ kind: string;
115
+ agent: string;
116
+ message: string;
117
+ count: number;
69
118
  }
70
119
 
71
120
  interface TuiState {
@@ -80,10 +129,20 @@ interface TuiState {
80
129
  * peek-proc — peek into a child process's live event stream (new)
81
130
  */
82
131
  viewMode: 'chat' | 'fleet' | 'peek' | 'peek-proc';
132
+ /** Session-cumulative usage (usage:updated totals across all agents). */
83
133
  tokens: TokenUsage;
134
+ /** Root agent's CURRENT context size (per-round input from inference
135
+ * usage events). Deliberately separate from tokens.input — conflating
136
+ * them made the status line oscillate between two different quantities
137
+ * under the same label. */
138
+ ctxTokens: number;
84
139
  peekTarget: string | null;
85
140
  /** Name of the child process being peeked at (peek-proc mode). */
86
141
  peekProcTarget: string | null;
142
+ /** When set, peek-proc is filtered to this one agent inside the child —
143
+ * the honest per-agent view for agents and sub-subagents living in a
144
+ * fleet child. Null = whole-process stream. */
145
+ peekProcAgent: string | null;
87
146
  /** True while we're waiting for the user to resolve a pending /quit with children still running. */
88
147
  pendingQuitConfirm: boolean;
89
148
  }
@@ -167,11 +226,38 @@ export async function runTui(app: AppContext): Promise<void> {
167
226
  subagents: [],
168
227
  viewMode: 'chat',
169
228
  tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
229
+ ctxTokens: 0,
170
230
  peekTarget: null,
171
231
  peekProcTarget: null,
232
+ peekProcAgent: null,
172
233
  pendingQuitConfirm: false,
173
234
  };
174
235
 
236
+ /** Active ops alerts (quarantine klaxon, refusal streaks, …), keyed
237
+ * `${agent}:${kind}`. Drives the status-bar ⚠ segment; each firing also
238
+ * prints a chat line so the alert exists in scrollback. */
239
+ const opsAlerts = new Map<string, OpsAlertEntry>();
240
+
241
+ function handleOpsAlert(kind: string, agent: string, message: string): void {
242
+ // All-clears travel as a distinct `<kind>-clear` kind (the alarm kind's
243
+ // ops cooldown must never swallow the stand-down). Remove the base alert
244
+ // and announce the recovery instead of adding a row.
245
+ if (kind.endsWith('-clear')) {
246
+ const baseKey = `${agent}:${kind.slice(0, -'-clear'.length)}`;
247
+ if (opsAlerts.delete(baseKey)) {
248
+ addLine(`✓ [${agent}] ${kind}: ${message}`, CYAN);
249
+ updateStatus();
250
+ }
251
+ return;
252
+ }
253
+ const key = `${agent}:${kind}`;
254
+ const existing = opsAlerts.get(key);
255
+ opsAlerts.set(key, { kind, agent, message, count: (existing?.count ?? 0) + 1 });
256
+ const times = existing ? ` (×${existing.count + 1})` : '';
257
+ addLine(`⚠ [${agent}] ${kind}${times}: ${message}`, RED);
258
+ updateStatus();
259
+ }
260
+
175
261
  let streaming = false;
176
262
  let currentStreamText: TextRenderable | null = null;
177
263
  let backgrounded = false; // researcher pushed to background via Ctrl+B
@@ -221,6 +307,16 @@ export async function runTui(app: AppContext): Promise<void> {
221
307
  });
222
308
  let fleetLineCounter = 0;
223
309
 
310
+ /** Detach AND destroy all fleetBox lines. These views rebuild on every
311
+ * poll tick — remove() without destroy() leaked one native text buffer
312
+ * per line per repaint. */
313
+ function clearFleetBox(): void {
314
+ for (const child of [...fleetBox.getChildren()]) {
315
+ fleetBox.remove(child.id);
316
+ child.destroy();
317
+ }
318
+ }
319
+
224
320
  const statusLeft = new TextRenderable(renderer, {
225
321
  id: 'status-left',
226
322
  content: formatStatusLeft(state),
@@ -266,7 +362,11 @@ export async function runTui(app: AppContext): Promise<void> {
266
362
  const INLINE_PASTE_THRESHOLD = 200;
267
363
  const pastedTexts: string[] = [];
268
364
  function formatPastePlaceholder(n: number, text: string): string {
269
- const head = text.replace(/\s+/g, ' ').trim().slice(0, 30);
365
+ // Square brackets in the head would terminate the `[^\]]*` expansion
366
+ // regex early on submit, mangling the message (pasted JSON/code/links
367
+ // all contain `]`). Substitute them in the *display* head only — the
368
+ // stored paste text is untouched.
369
+ const head = text.replace(/\s+/g, ' ').trim().slice(0, 30).replace(/[[\]]/g, '·');
270
370
  const lines = text.split(/\r?\n/).length;
271
371
  const sizeHint = lines > 1 ? `${text.length}ch, ${lines}L` : `${text.length}ch`;
272
372
  return `[paste #${n}: "${head}…" ${sizeHint}]`;
@@ -302,8 +402,13 @@ export async function runTui(app: AppContext): Promise<void> {
302
402
 
303
403
  // ── Agent observability maps ──────────────────────────────────────
304
404
 
305
- /** Accumulated transcript per agent (text output + tool calls). */
405
+ /** Accumulated transcript per agent (text output + tool calls). Retention
406
+ * is capped; the synesthete summarizer only ever reads the last 10k. */
306
407
  const agentTranscripts = new Map<string, string>();
408
+ const TRANSCRIPT_CAP = 30_000;
409
+ /** Cumulative appended chars per agent — drives the "enough new text to
410
+ * re-summarize" delta, which transcript.length can't once it hits the cap. */
411
+ const transcriptTotalLen = new Map<string, number>();
307
412
 
308
413
  /** Parent tracking: child short name → parent full agent name. */
309
414
  const agentParent = new Map<string, string>();
@@ -315,22 +420,31 @@ export async function runTui(app: AppContext): Promise<void> {
315
420
  const summaryCache = new Map<string, string>();
316
421
  const summarySnapshotLen = new Map<string, number>();
317
422
  const summaryPending = new Set<string>();
423
+ /** Earliest next attempt per agent after a FAILED summary call. Without
424
+ * this, a failing provider (outage, 429 storm) meets the 500ms poll tick
425
+ * and becomes a 2 Hz per-agent inference retry hose — summaryPending only
426
+ * guards concurrency, not the gap between a fast failure and the next tick. */
427
+ const summaryBackoffUntil = new Map<string, number>();
318
428
 
319
429
  const SUMMARY_DELTA = 2000;
320
430
  const SUMMARY_WINDOW = 10_000;
431
+ const SUMMARY_FAILURE_BACKOFF_MS = 30_000;
321
432
 
322
433
  function appendTranscript(agent: string, text: string) {
323
- const prev = agentTranscripts.get(agent) ?? '';
324
- agentTranscripts.set(agent, prev + text);
434
+ const next = (agentTranscripts.get(agent) ?? '') + text;
435
+ agentTranscripts.set(agent, next.length > TRANSCRIPT_CAP ? next.slice(-TRANSCRIPT_CAP) : next);
436
+ transcriptTotalLen.set(agent, (transcriptTotalLen.get(agent) ?? 0) + text.length);
325
437
  }
326
438
 
327
439
  async function generateSummary(agentName: string) {
328
440
  if (summaryPending.has(agentName)) return;
441
+ if (Date.now() < (summaryBackoffUntil.get(agentName) ?? 0)) return;
329
442
  const transcript = agentTranscripts.get(agentName);
330
443
  if (!transcript || transcript.length < 50) return;
331
444
 
445
+ const totalLen = transcriptTotalLen.get(agentName) ?? 0;
332
446
  const lastLen = summarySnapshotLen.get(agentName) ?? 0;
333
- if (transcript.length - lastLen < SUMMARY_DELTA && summaryCache.has(agentName)) return;
447
+ if (totalLen - lastLen < SUMMARY_DELTA && summaryCache.has(agentName)) return;
334
448
 
335
449
  summaryPending.add(agentName);
336
450
  try {
@@ -348,10 +462,12 @@ export async function runTui(app: AppContext): Promise<void> {
348
462
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
349
463
  .map(b => b.text).join('').trim();
350
464
  summaryCache.set(agentName, text.length > 60 ? text.slice(0, 57) + '...' : text);
351
- summarySnapshotLen.set(agentName, transcript.length);
465
+ summarySnapshotLen.set(agentName, totalLen);
466
+ summaryBackoffUntil.delete(agentName);
352
467
  if (state.viewMode === 'fleet') updateFleetView();
353
468
  } catch {
354
- // best-effort
469
+ // Best-effort display — but never an unthrottled retry loop.
470
+ summaryBackoffUntil.set(agentName, Date.now() + SUMMARY_FAILURE_BACKOFF_MS);
355
471
  } finally {
356
472
  summaryPending.delete(agentName);
357
473
  }
@@ -361,17 +477,37 @@ export async function runTui(app: AppContext): Promise<void> {
361
477
 
362
478
  let messageCounter = 0;
363
479
 
480
+ /** Scrollback retention: one TextRenderable per line accretes forever
481
+ * otherwise (render cost degrades long before memory hurts). */
482
+ const SCROLLBACK_CAP = 2000;
483
+ function pruneScrollback() {
484
+ const children = scrollBox.getChildren();
485
+ if (children.length <= SCROLLBACK_CAP) return;
486
+ for (const child of children.slice(0, children.length - SCROLLBACK_CAP)) {
487
+ if (child === currentStreamText) continue; // never destroy the live stream element
488
+ // remove() only detaches; destroy() is what frees the native text
489
+ // buffer. Without it the "cap" bounds render cost but still leaks
490
+ // one native buffer per line.
491
+ scrollBox.remove(child.id);
492
+ child.destroy();
493
+ }
494
+ }
495
+
364
496
  function addLine(text: string, color: string = WHITE) {
365
497
  scrollBox.add(new TextRenderable(renderer, {
366
498
  id: `msg-${++messageCounter}`,
367
499
  content: text,
368
500
  fg: color,
369
501
  }));
502
+ pruneScrollback();
370
503
  }
371
504
 
372
505
  function updateStatus() {
373
- statusLeft.content = formatStatusLeft(state, SPINNER[spinnerFrame], streamOutputTokens);
374
- statusRight.content = formatTokens(state.tokens, verboseChat) + formatMemStats(getRootCM());
506
+ statusLeft.content = formatStatusLeft(state, SPINNER[spinnerFrame], streamOutputTokens, opsAlerts.size);
507
+ // An active ops alert repaints the whole left segment — a one-cell glyph
508
+ // in default gray is exactly the kind of signal that gets scrolled past.
509
+ statusLeft.fg = opsAlerts.size > 0 ? RED : GRAY;
510
+ statusRight.content = formatTokens(state.tokens, verboseChat, state.ctxTokens) + formatMemStats(getRootCM());
375
511
  }
376
512
 
377
513
  /** Best-effort handle to the root agent's ContextManager, for stats queries. */
@@ -396,6 +532,7 @@ export async function runTui(app: AppContext): Promise<void> {
396
532
  fg: WHITE,
397
533
  });
398
534
  scrollBox.add(currentStreamText);
535
+ pruneScrollback();
399
536
  streaming = true;
400
537
  }
401
538
 
@@ -435,6 +572,7 @@ export async function runTui(app: AppContext): Promise<void> {
435
572
  });
436
573
  }
437
574
  scrollBox.add(currentStreamText);
575
+ pruneScrollback();
438
576
  }
439
577
 
440
578
  function streamToken(text: string) {
@@ -494,17 +632,19 @@ export async function runTui(app: AppContext): Promise<void> {
494
632
  * Clears conversation, reloads messages, restores fleet tree from persisted subagent state.
495
633
  */
496
634
  function refreshFromStore() {
497
- // Clear conversation display
635
+ // Reset streaming state BEFORE destroying renderables so nothing can
636
+ // touch a freed native buffer through currentStreamText.
637
+ streaming = false;
638
+ currentStreamText = null;
639
+ currentStreamBuffer = '';
640
+
641
+ // Clear conversation display (destroy frees the native text buffers)
498
642
  const children = [...scrollBox.getChildren()];
499
643
  for (const child of children) {
500
644
  scrollBox.remove(child.id);
645
+ child.destroy();
501
646
  }
502
647
  messageCounter = 0;
503
-
504
- // Reset streaming state
505
- streaming = false;
506
- currentStreamText = null;
507
- currentStreamBuffer = '';
508
648
  state.status = 'idle';
509
649
  state.tool = null;
510
650
 
@@ -559,7 +699,8 @@ export async function runTui(app: AppContext): Promise<void> {
559
699
  // Index subagents by short name for tree building
560
700
  const byName = new Map<string, FleetNode>();
561
701
  for (const sa of state.subagents) {
562
- const fullName = [...(subMod?.activeSubagents.keys() ?? [])].find(k => k.includes(sa.name)) ?? sa.name;
702
+ const fullName = [...(subMod?.activeSubagents.keys() ?? [])]
703
+ .find(k => k === sa.name || shortAgentName(k) === sa.name) ?? sa.name;
563
704
  const node: FleetNode = {
564
705
  name: sa.name,
565
706
  fullName,
@@ -575,8 +716,8 @@ export async function runTui(app: AppContext): Promise<void> {
575
716
  const parentFullName = agentParent.get(sa.name);
576
717
  if (parentFullName && parentFullName !== rootAgentName) {
577
718
  // Find the parent's short name
578
- const parentShort = [...byName.keys()].find(k => parentFullName.includes(k));
579
- if (parentShort && byName.has(parentShort)) {
719
+ const parentShort = byName.has(parentFullName) ? parentFullName : shortAgentName(parentFullName);
720
+ if (byName.has(parentShort)) {
580
721
  byName.get(parentShort)!.children.push(byName.get(sa.name)!);
581
722
  continue;
582
723
  }
@@ -843,15 +984,13 @@ export async function runTui(app: AppContext): Promise<void> {
843
984
  // Contextual key hints on the cursor line
844
985
  let hints = '';
845
986
  if (isCursor) {
846
- if (node.kind === 'subagent' && node.agent?.status === 'running') {
847
- hints = ' ⏎:fold p:peek Del:stop';
987
+ if (node.kind === 'subagent') {
988
+ hints = node.agent?.status === 'running' ? ' ⏎:fold p:peek Del:stop' : ' ⏎:fold p:peek';
848
989
  } else if (node.kind === 'fleet-child') {
849
990
  const fc = fleetMod?.getChildren().get(node.fleetChildName!);
850
991
  hints = fc?.status === 'ready' ? ' ⏎:fold p:peek Del:stop' : ' ⏎:fold';
851
992
  } else if (node.kind === 'fleet-child-agent') {
852
- // Per-agent peek isn't separately supported yet; peek opens the
853
- // owning child process's stream, which still contains this agent's events.
854
- hints = ' ⏎:fold p:peek-proc';
993
+ hints = ' ⏎:fold p:peek';
855
994
  } else {
856
995
  hints = ' ⏎:fold';
857
996
  }
@@ -911,7 +1050,7 @@ export async function runTui(app: AppContext): Promise<void> {
911
1050
  const fullName = node.kind === 'fleet-child' || node.kind === 'fleet-child-agent'
912
1051
  ? null
913
1052
  : node.kind === 'researcher' ? rootAgentName
914
- : [...agentTranscripts.keys()].find(k => k.includes(node.name));
1053
+ : [...agentTranscripts.keys()].find(k => k === node.name || shortAgentName(k) === node.name);
915
1054
  if (fullName) {
916
1055
  const summary = summaryCache.get(fullName);
917
1056
  if (summary) {
@@ -919,7 +1058,8 @@ export async function runTui(app: AppContext): Promise<void> {
919
1058
  } else if (summaryPending.has(fullName)) {
920
1059
  lines.push({ text: ` ${detail}┈ …`, color: DIM_GRAY });
921
1060
  }
922
- generateSummary(fullName);
1061
+ // Summary GENERATION is triggered from the poll tick, not here —
1062
+ // rendering must never originate an inference call.
923
1063
  }
924
1064
 
925
1065
  // Recurse into children
@@ -928,13 +1068,33 @@ export async function runTui(app: AppContext): Promise<void> {
928
1068
  }
929
1069
  }
930
1070
 
1071
+ /** Transient error notice shown inside the fleet view — the operator is
1072
+ * looking at THIS view when a kill/restart fails, not the chat scrollback. */
1073
+ let fleetNotice: string | null = null;
1074
+ let fleetNoticeTimer: ReturnType<typeof setTimeout> | null = null;
1075
+ function showFleetNotice(text: string): void {
1076
+ fleetNotice = text;
1077
+ if (fleetNoticeTimer) clearTimeout(fleetNoticeTimer);
1078
+ fleetNoticeTimer = setTimeout(() => {
1079
+ fleetNotice = null;
1080
+ fleetNoticeTimer = null;
1081
+ if (state.viewMode === 'fleet') updateFleetView();
1082
+ }, 6000);
1083
+ // Also record it in scrollback so it survives after the notice fades.
1084
+ addLine(` ${text}`, RED);
1085
+ if (state.viewMode === 'fleet') updateFleetView();
1086
+ }
1087
+
931
1088
  function updateFleetView() {
932
1089
  const tree = buildFleetTree();
933
1090
  visibleNodeIds = [];
934
1091
  visibleNodes.clear();
935
1092
 
936
1093
  const lines: FleetLine[] = [];
937
- lines.push({ text: '─── Agent Fleet ─── ↑↓:nav ⏎/→:fold p:peek Del:stop r:restart ───', color: GRAY });
1094
+ lines.push({ text: '─── Agent Fleet ─── ↑↓:nav ⏎/→:fold p:peek Del:stop r:restart Esc:chat ───', color: GRAY });
1095
+ if (fleetNotice) {
1096
+ lines.push({ text: ` ⚠ ${fleetNotice}`, color: RED });
1097
+ }
938
1098
  lines.push({ text: '', color: GRAY });
939
1099
 
940
1100
  renderNode(tree, 0, lines);
@@ -947,9 +1107,7 @@ export async function runTui(app: AppContext): Promise<void> {
947
1107
  lines.push({ text: ' Tab: chat', color: DIM_GRAY });
948
1108
 
949
1109
  // Rebuild fleetBox children: clear old, add new per-line renderables
950
- for (const child of [...fleetBox.getChildren()]) {
951
- fleetBox.remove(child.id);
952
- }
1110
+ clearFleetBox();
953
1111
  for (const line of lines) {
954
1112
  fleetBox.add(new TextRenderable(renderer, {
955
1113
  id: `fleet-ln-${++fleetLineCounter}`,
@@ -976,12 +1134,36 @@ export async function runTui(app: AppContext): Promise<void> {
976
1134
  const name = state.peekProcTarget;
977
1135
  if (!name || !fleetMod) return;
978
1136
  const child = fleetMod.getChildren().get(name);
1137
+ const agentFilter = state.peekProcAgent ?? undefined;
1138
+ const key = procLogKey(name, agentFilter);
979
1139
 
980
1140
  const lines: FleetLine[] = [];
981
- lines.push({ text: `─── Peek proc: ${name} ──────────────── Esc:back ───`, color: GRAY });
1141
+ const title = agentFilter ? `Peek: ${name} ${agentFilter}` : `Peek proc: ${name}`;
1142
+ lines.push({ text: `─── ${title} ──────────────── Esc:back ───`, color: GRAY });
982
1143
  lines.push({ text: '', color: GRAY });
983
1144
 
984
- if (child) {
1145
+ if (child && agentFilter) {
1146
+ // Per-agent header: the reducer node carries the honest phase/tokens/
1147
+ // task for this one agent inside the child.
1148
+ const rn = treeAggregator?.getChildNodes(name).find(n => n.name === agentFilter);
1149
+ if (rn) {
1150
+ const elapsed = rn.startedAt ? Math.floor(((rn.completedAt ?? Date.now()) - rn.startedAt) / 1000) : 0;
1151
+ const phaseColor = rn.status === 'failed' ? RED
1152
+ : rn.phase === 'idle' || rn.phase === 'done' || rn.phase === 'cancelled' ? DIM_GRAY
1153
+ : PHASE_COLOR[rn.phase as SubagentPhase] ?? CYAN;
1154
+ const ctx = rn.tokens.input > 0 ? ` ${fmtK(rn.tokens.input)}ctx` : '';
1155
+ lines.push({ text: ` ${rn.phase} ${elapsed}s ${rn.toolCallsCount} tool calls${ctx}`, color: phaseColor });
1156
+ if (rn.task) {
1157
+ const task = rn.task.length > 70 ? rn.task.slice(0, 67) + '...' : rn.task;
1158
+ lines.push({ text: ` task: ${task}`, color: GRAY });
1159
+ }
1160
+ if (rn.parent) {
1161
+ lines.push({ text: ` parent: ${rn.parent} (in ${name})`, color: DIM_GRAY });
1162
+ }
1163
+ } else {
1164
+ lines.push({ text: ` (agent not currently tracked in ${name} — events stream as they arrive)`, color: DIM_GRAY });
1165
+ }
1166
+ } else if (child) {
985
1167
  const elapsed = Math.floor((Date.now() - child.startedAt) / 1000);
986
1168
  const min = Math.floor(elapsed / 60);
987
1169
  const sec = elapsed % 60;
@@ -1002,9 +1184,16 @@ export async function runTui(app: AppContext): Promise<void> {
1002
1184
 
1003
1185
  lines.push({ text: '', color: GRAY });
1004
1186
 
1005
- const log = procPeekLogs.get(name);
1187
+ const log = procPeekLogs.get(key);
1188
+ // In-progress token line (not yet flushed to log) — appended after the
1189
+ // tail below; fetched first so the tail budget accounts for its row.
1190
+ const pending = procPeekTokenLine.get(key);
1006
1191
  if (log && log.length > 0) {
1007
- const maxLines = Math.max(10, renderer.terminalHeight - 10);
1192
+ // Same viewport accounting as updatePeekView: header lines already in
1193
+ // `lines`, the "N lines above" marker, and the pending token line all
1194
+ // occupy rows of the terminalHeight - 3 the fleetBox actually has.
1195
+ const available = renderer.terminalHeight - 3;
1196
+ const maxLines = Math.max(5, available - lines.length - 1 - (pending ? 1 : 0));
1008
1197
  const tail = log.slice(-maxLines);
1009
1198
  if (log.length > maxLines) {
1010
1199
  lines.push({ text: ` ┈ (${log.length - maxLines} lines above)`, color: DIM_GRAY });
@@ -1013,16 +1202,16 @@ export async function runTui(app: AppContext): Promise<void> {
1013
1202
  lines.push({ text: ` ${entry.text}`, color: entry.color });
1014
1203
  }
1015
1204
  } else {
1016
- lines.push({ text: ' (no events yet — child may be idle)', color: DIM_GRAY });
1205
+ lines.push({ text: agentFilter
1206
+ ? ' (no events from this agent yet)'
1207
+ : ' (no events yet — child may be idle)', color: DIM_GRAY });
1017
1208
  }
1018
1209
 
1019
- // In-progress token line (not yet flushed to log) — shows live streaming output.
1020
- const pending = procPeekTokenLine.get(name);
1021
1210
  if (pending) {
1022
1211
  lines.push({ text: ` ${pending}`, color: WHITE });
1023
1212
  }
1024
1213
 
1025
- for (const boxChild of [...fleetBox.getChildren()]) fleetBox.remove(boxChild.id);
1214
+ clearFleetBox();
1026
1215
  for (const line of lines) {
1027
1216
  fleetBox.add(new TextRenderable(renderer, {
1028
1217
  id: `fleet-ln-${++fleetLineCounter}`,
@@ -1032,62 +1221,84 @@ export async function runTui(app: AppContext): Promise<void> {
1032
1221
  }
1033
1222
  }
1034
1223
 
1035
- function enterPeekProc(name: string): void {
1224
+ /**
1225
+ * Peek a fleet child's live event stream. With `agentFilter` set, the view
1226
+ * narrows to that one agent inside the child — the honest per-agent peek
1227
+ * for fleet-child agents and their subagents (sub-subagents from the
1228
+ * parent's point of view). Events on the fleet IPC carry `agentName`
1229
+ * verbatim from the child's framework traces, so the filter sees exactly
1230
+ * what a local peek of that agent would.
1231
+ */
1232
+ function enterPeekProc(name: string, agentFilter?: string): void {
1036
1233
  if (!fleetMod) return;
1037
1234
  const child = fleetMod.getChildren().get(name);
1038
1235
  if (!child) return;
1039
1236
 
1040
1237
  state.peekProcTarget = name;
1238
+ state.peekProcAgent = agentFilter ?? null;
1239
+ const key = procLogKey(name, agentFilter);
1240
+ const matches = (evt: WireEvent): boolean =>
1241
+ !agentFilter || (evt as { agentName?: string }).agentName === agentFilter;
1041
1242
 
1042
1243
  // Seed the log from the child's existing event buffer so the user
1043
1244
  // doesn't have to wait for the next event to see history.
1044
- if (!procPeekLogs.has(name)) procPeekLogs.set(name, []);
1045
- const log = procPeekLogs.get(name)!;
1245
+ if (!procPeekLogs.has(key)) procPeekLogs.set(key, []);
1246
+ const log = procPeekLogs.get(key)!;
1247
+ const lastEvt = child.events[child.events.length - 1];
1046
1248
  if (log.length === 0) {
1047
1249
  for (const evt of child.events) {
1250
+ if (!matches(evt)) continue;
1048
1251
  const formatted = formatWireEvent(evt);
1049
1252
  if (formatted) log.push(formatted);
1050
1253
  }
1254
+ } else if (lastEvt && procPeekLastEvent.get(key) !== lastEvt) {
1255
+ // The subscription was torn down when the user left this peek; events
1256
+ // the child emitted since then are absent from this log. Mark the gap
1257
+ // instead of silently resuming from "now". (Object identity on the
1258
+ // child's ring buffer is the tell — same process, same array.)
1259
+ appendProcPeekLog(key, '┈ re-attached — events emitted while detached are not shown ┈', DIM_GRAY);
1051
1260
  }
1261
+ if (lastEvt) procPeekLastEvent.set(key, lastEvt);
1052
1262
 
1053
1263
  // Subscribe for live updates. Handle token events with line merging so
1054
1264
  // streaming output shows up as a natural-looking line buffer rather
1055
1265
  // than one log entry per token.
1056
1266
  if (procPeekUnsub) { procPeekUnsub(); procPeekUnsub = null; }
1057
1267
  procPeekUnsub = fleetMod.onChildEvent(name, (_childName, evt) => {
1268
+ procPeekLastEvent.set(key, evt);
1269
+ if (!matches(evt)) return;
1058
1270
  const type = typeof evt.type === 'string' ? evt.type : '';
1271
+ const active = state.viewMode === 'peek-proc'
1272
+ && state.peekProcTarget === name
1273
+ && state.peekProcAgent === (agentFilter ?? null);
1059
1274
 
1060
1275
  if (type === 'inference:tokens') {
1061
1276
  const content = (evt as { content?: string }).content ?? '';
1062
1277
  if (!content) return;
1063
- const prev = procPeekTokenLine.get(name) ?? '';
1278
+ const prev = procPeekTokenLine.get(key) ?? '';
1064
1279
  const merged = prev + content;
1065
1280
  const parts = merged.split('\n');
1066
1281
  // Flush completed lines (everything except the last segment).
1067
1282
  for (let i = 0; i < parts.length - 1; i++) {
1068
- if (parts[i]!.trim()) appendProcPeekLog(name, parts[i]!, WHITE);
1069
- }
1070
- procPeekTokenLine.set(name, parts[parts.length - 1]!);
1071
- if (state.viewMode === 'peek-proc' && state.peekProcTarget === name) {
1072
- updatePeekProcView();
1283
+ if (parts[i]!.trim()) appendProcPeekLog(key, parts[i]!, WHITE);
1073
1284
  }
1285
+ procPeekTokenLine.set(key, parts[parts.length - 1]!);
1286
+ if (active) updatePeekProcView();
1074
1287
  return;
1075
1288
  }
1076
1289
 
1077
1290
  // Non-token event: flush any pending token line first so its text
1078
1291
  // doesn't get visually chopped by subsequent log entries.
1079
- const pending = procPeekTokenLine.get(name);
1292
+ const pending = procPeekTokenLine.get(key);
1080
1293
  if (pending?.trim()) {
1081
- appendProcPeekLog(name, pending, WHITE);
1294
+ appendProcPeekLog(key, pending, WHITE);
1082
1295
  }
1083
- procPeekTokenLine.delete(name);
1296
+ procPeekTokenLine.delete(key);
1084
1297
 
1085
1298
  const formatted = formatWireEvent(evt);
1086
1299
  if (!formatted) return;
1087
- appendProcPeekLog(name, formatted.text, formatted.color);
1088
- if (state.viewMode === 'peek-proc' && state.peekProcTarget === name) {
1089
- updatePeekProcView();
1090
- }
1300
+ appendProcPeekLog(key, formatted.text, formatted.color);
1301
+ if (active) updatePeekProcView();
1091
1302
  });
1092
1303
 
1093
1304
  switchView('peek-proc');
@@ -1098,13 +1309,15 @@ export async function runTui(app: AppContext): Promise<void> {
1098
1309
  // Flush any in-progress token line into the log before clearing it so
1099
1310
  // re-entering peek-proc doesn't lose the last few tokens mid-stream.
1100
1311
  if (state.peekProcTarget) {
1101
- const pending = procPeekTokenLine.get(state.peekProcTarget);
1312
+ const key = procLogKey(state.peekProcTarget, state.peekProcAgent ?? undefined);
1313
+ const pending = procPeekTokenLine.get(key);
1102
1314
  if (pending?.trim()) {
1103
- appendProcPeekLog(state.peekProcTarget, pending, WHITE);
1315
+ appendProcPeekLog(key, pending, WHITE);
1104
1316
  }
1105
- procPeekTokenLine.delete(state.peekProcTarget);
1317
+ procPeekTokenLine.delete(key);
1106
1318
  }
1107
1319
  state.peekProcTarget = null;
1320
+ state.peekProcAgent = null;
1108
1321
  }
1109
1322
 
1110
1323
  // ── Peek view ────────────────────────────────────────────────────────
@@ -1117,7 +1330,11 @@ export async function runTui(app: AppContext): Promise<void> {
1117
1330
 
1118
1331
  function appendPeekLog(name: string, text: string, color: string) {
1119
1332
  if (!peekLogs.has(name)) peekLogs.set(name, []);
1120
- peekLogs.get(name)!.push({ text, color });
1333
+ const log = peekLogs.get(name)!;
1334
+ log.push({ text, color });
1335
+ // Cap to match appendProcPeekLog — these accumulate for EVERY subagent
1336
+ // via the global stream subscription, whether anyone ever peeks or not.
1337
+ if (log.length > 500) log.splice(0, log.length - 500);
1121
1338
  }
1122
1339
 
1123
1340
  function cleanupPeek() {
@@ -1129,9 +1346,10 @@ export async function runTui(app: AppContext): Promise<void> {
1129
1346
  }
1130
1347
 
1131
1348
  function enterPeek(name: string) {
1132
- // Only peek at running subagents
1349
+ // Peek any known subagent — finished ones included: their captured log
1350
+ // is exactly what "what did that fork actually do?" needs post-hoc.
1133
1351
  const sa = state.subagents.find(s => s.name === name);
1134
- if (!sa || sa.status !== 'running') return;
1352
+ if (!sa) return;
1135
1353
 
1136
1354
  state.viewMode = 'peek';
1137
1355
  state.peekTarget = name;
@@ -1178,7 +1396,10 @@ export async function runTui(app: AppContext): Promise<void> {
1178
1396
 
1179
1397
  const sa = state.subagents.find(s => s.name === name);
1180
1398
  if (sa) {
1181
- const elapsed = Math.floor((Date.now() - sa.startedAt) / 1000);
1399
+ // Finished subagents (peekable since the enterPeek relaxation) show
1400
+ // their final runtime, not a clock that keeps counting after done.
1401
+ const endTime = sa.completedAt ?? Date.now();
1402
+ const elapsed = Math.floor((endTime - sa.startedAt) / 1000);
1182
1403
  const min = Math.floor(elapsed / 60);
1183
1404
  const sec = elapsed % 60;
1184
1405
  const timeStr = min > 0 ? `${min}m${sec}s` : `${sec}s`;
@@ -1198,9 +1419,14 @@ export async function runTui(app: AppContext): Promise<void> {
1198
1419
 
1199
1420
  lines.push({ text: '', color: GRAY });
1200
1421
 
1201
- // Accumulated event log — show last N lines
1422
+ // Accumulated event log — show last N lines. Tail budget: fleetBox shows
1423
+ // terminalHeight - 3 rows (status bar, input row, box paddingTop), and
1424
+ // everything already in `lines` plus the "N lines above" marker must fit
1425
+ // too — otherwise the NEWEST lines, the whole point of a tail-follow
1426
+ // view, get clipped off the bottom of the box.
1202
1427
  if (log && log.length > 0) {
1203
- const maxLines = Math.max(10, renderer.terminalHeight - 8);
1428
+ const available = renderer.terminalHeight - 3;
1429
+ const maxLines = Math.max(5, available - lines.length - 1);
1204
1430
  const tail = log.slice(-maxLines);
1205
1431
  if (log.length > maxLines) {
1206
1432
  lines.push({ text: ` ┈ (${log.length - maxLines} lines above)`, color: DIM_GRAY });
@@ -1213,9 +1439,7 @@ export async function runTui(app: AppContext): Promise<void> {
1213
1439
  }
1214
1440
 
1215
1441
  // Rebuild fleetBox children
1216
- for (const child of [...fleetBox.getChildren()]) {
1217
- fleetBox.remove(child.id);
1218
- }
1442
+ clearFleetBox();
1219
1443
  for (const line of lines) {
1220
1444
  fleetBox.add(new TextRenderable(renderer, {
1221
1445
  id: `fleet-ln-${++fleetLineCounter}`,
@@ -1290,7 +1514,7 @@ export async function runTui(app: AppContext): Promise<void> {
1290
1514
  if (prev) {
1291
1515
  const delta = Math.ceil(content.length / 4);
1292
1516
  agentContextTokens.set(agent, prev + delta);
1293
- const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1517
+ const short = shortAgentName(agent);
1294
1518
  if (short !== agent) agentContextTokens.set(short, prev + delta);
1295
1519
  }
1296
1520
  }
@@ -1305,16 +1529,16 @@ export async function runTui(app: AppContext): Promise<void> {
1305
1529
  } | undefined;
1306
1530
  if (agent && roundUsage?.input) {
1307
1531
  agentContextTokens.set(agent, roundUsage.input);
1308
- const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1532
+ const short = shortAgentName(agent);
1309
1533
  if (short !== agent) agentContextTokens.set(short, roundUsage.input);
1310
1534
  if (state.viewMode === 'fleet') updateFleetView();
1311
1535
  }
1312
- // Update root-agent token state for status-line display.
1313
- if (agent === rootAgentName && roundUsage) {
1314
- if (roundUsage.input !== undefined) state.tokens.input = roundUsage.input;
1315
- if (roundUsage.output !== undefined) state.tokens.output = (state.tokens.output ?? 0) + (roundUsage.output ?? 0);
1316
- if (roundUsage.cacheRead !== undefined) state.tokens.cacheRead = roundUsage.cacheRead;
1317
- if (roundUsage.cacheCreation !== undefined) state.tokens.cacheWrite = roundUsage.cacheCreation;
1536
+ // Update the root agent's context-size readout. Only ctxTokens
1537
+ // per-round numbers must not overwrite the session totals that
1538
+ // usage:updated owns (cache fields included: per-round cacheRead is
1539
+ // this round's hit, not the cumulative the Σ segment displays).
1540
+ if (agent === rootAgentName && roundUsage?.input !== undefined) {
1541
+ state.ctxTokens = roundUsage.input;
1318
1542
  updateStatus();
1319
1543
  }
1320
1544
  break;
@@ -1325,8 +1549,9 @@ export async function runTui(app: AppContext): Promise<void> {
1325
1549
  // Track context size per agent (store by both full and short name)
1326
1550
  if (usage && agent && usage.input) {
1327
1551
  agentContextTokens.set(agent, usage.input);
1328
- const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1552
+ const short = shortAgentName(agent);
1329
1553
  if (short !== agent) agentContextTokens.set(short, usage.input);
1554
+ if (agent === rootAgentName) state.ctxTokens = usage.input;
1330
1555
  }
1331
1556
 
1332
1557
  if (agent === rootAgentName) {
@@ -1353,10 +1578,20 @@ export async function runTui(app: AppContext): Promise<void> {
1353
1578
  state.tokens.output = totals.outputTokens;
1354
1579
  state.tokens.cacheRead = totals.cacheReadTokens;
1355
1580
  state.tokens.cacheWrite = totals.cacheCreationTokens;
1581
+ if (totals.estimatedCost) state.tokens.cost = totals.estimatedCost;
1356
1582
  updateStatus();
1357
1583
  break;
1358
1584
  }
1359
1585
 
1586
+ case 'ops:alert': {
1587
+ handleOpsAlert(
1588
+ typeof event.kind === 'string' ? event.kind : 'unknown',
1589
+ typeof event.agentName === 'string' ? event.agentName : '?',
1590
+ typeof event.message === 'string' ? event.message : '',
1591
+ );
1592
+ break;
1593
+ }
1594
+
1360
1595
  case 'inference:failed': {
1361
1596
  if (agent === rootAgentName) {
1362
1597
  state.status = 'error';
@@ -1369,7 +1604,7 @@ export async function runTui(app: AppContext): Promise<void> {
1369
1604
  updateStatus();
1370
1605
  } else {
1371
1606
  if (agent) {
1372
- const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1607
+ const short = shortAgentName(agent);
1373
1608
  subagentPhase.set(short, 'failed');
1374
1609
  }
1375
1610
  addLine(`[${agent}] Error: ${event.error}`, DIM_GRAY);
@@ -1405,9 +1640,9 @@ export async function runTui(app: AppContext): Promise<void> {
1405
1640
  if (streaming) endStream();
1406
1641
  if (!backgrounded) addLine(`[tools] ${names}`, YELLOW);
1407
1642
  } else {
1408
- const short = (agent ?? '').replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '');
1643
+ const short = shortAgentName(agent ?? '');
1409
1644
  addLine(` [${short}] ${names}`, DIM_GRAY);
1410
- const sa = state.subagents.find(s => (agent ?? '').includes(s.name));
1645
+ const sa = state.subagents.find(s => s.name === agent || s.name === short);
1411
1646
  if (sa) {
1412
1647
  sa.toolCallsCount += calls.length;
1413
1648
  sa.statusMessage = names.split('--').pop();
@@ -1449,7 +1684,7 @@ export async function runTui(app: AppContext): Promise<void> {
1449
1684
  // Show file operations in chat
1450
1685
  const toolInput = event.input as Record<string, unknown> | undefined;
1451
1686
  if (toolInput && (agent === rootAgentName || verboseChat)) {
1452
- const short = agent === rootAgentName ? '' : `[${(agent ?? '').replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '')}] `;
1687
+ const short = agent === rootAgentName ? '' : `[${shortAgentName(agent ?? '')}] `;
1453
1688
  if (tool === 'files:write' && toolInput.filePath) {
1454
1689
  const fp = String(toolInput.filePath);
1455
1690
  addLine(` ${short}write ${fp}`, DIM_GRAY);
@@ -1479,7 +1714,7 @@ export async function runTui(app: AppContext): Promise<void> {
1479
1714
  if (agent === rootAgentName) {
1480
1715
  addLine(`[tool error] ${tool}: ${error}`, RED);
1481
1716
  } else if (agent) {
1482
- const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1717
+ const short = shortAgentName(agent);
1483
1718
  addLine(` [${short}] tool error: ${tool}: ${error}`, RED);
1484
1719
  }
1485
1720
  break;
@@ -1515,24 +1750,60 @@ export async function runTui(app: AppContext): Promise<void> {
1515
1750
  // one. Drives the unified subagent-tree rendering: fleet children appear as
1516
1751
  // first-class nodes alongside in-process subagents, with the same readouts
1517
1752
  // (phase, context tokens, tool calls). See UNIFIED-TREE-PLAN.md.
1518
- const treeAggregator = fleetMod ? new FleetTreeAggregator(fleetMod) : null;
1519
- if (treeAggregator) {
1520
- // Register any fleet children that already exist (e.g. autoStart entries
1521
- // brought up before TUI init, or reattached survivors after parent restart).
1522
- for (const childName of fleetMod!.getChildren().keys()) {
1523
- treeAggregator.registerChild(childName);
1524
- }
1525
- // Re-render fleet view when any tracked child's tree changes — gives live
1526
- // updates without polling.
1527
- treeAggregator.onTreeUpdate(() => {
1528
- if (state.viewMode === 'fleet') updateFleetView();
1529
- });
1753
+ // Rebuilt (not just re-scanned) on session switch: its IPC subscriptions
1754
+ // live on the fleetMod it was constructed with, so an aggregator from the
1755
+ // old session silently stops receiving events.
1756
+ let treeAggregator: FleetTreeAggregator | null = null;
1757
+ function initTreeAggregator(): void {
1758
+ treeAggregator?.dispose();
1759
+ treeAggregator = fleetMod ? new FleetTreeAggregator(fleetMod) : null;
1760
+ if (treeAggregator && fleetMod) {
1761
+ // Register any fleet children that already exist (e.g. autoStart entries
1762
+ // brought up before TUI init, or reattached survivors after parent restart).
1763
+ for (const childName of fleetMod.getChildren().keys()) {
1764
+ treeAggregator.registerChild(childName);
1765
+ }
1766
+ // Re-render fleet view when any tracked child's tree changes — gives live
1767
+ // updates without polling.
1768
+ treeAggregator.onTreeUpdate(() => {
1769
+ if (state.viewMode === 'fleet') updateFleetView();
1770
+ });
1771
+ }
1772
+ }
1773
+ initTreeAggregator();
1774
+
1775
+ // Fleet-child ops alerts: a quarantine klaxon or refusal streak inside a
1776
+ // child process must be as loud here as a local one — the operator is
1777
+ // looking at THIS terminal, not the child's log. Default subscription is
1778
+ // '*' so ops:alert events flow over the IPC with agentName intact.
1779
+ // Re-bound on session switch alongside fleetMod itself.
1780
+ let fleetOpsUnsub: (() => void) | null = null;
1781
+ function subscribeFleetOps(): void {
1782
+ fleetOpsUnsub?.();
1783
+ fleetOpsUnsub = fleetMod?.onChildEvent('*', (childName, evt) => {
1784
+ if (evt.type !== 'ops:alert') return;
1785
+ const e = evt as Record<string, unknown>;
1786
+ handleOpsAlert(
1787
+ typeof e.kind === 'string' ? e.kind : 'unknown',
1788
+ `${childName}/${typeof e.agentName === 'string' ? e.agentName : '?'}`,
1789
+ typeof e.message === 'string' ? e.message : '',
1790
+ );
1791
+ }) ?? null;
1530
1792
  }
1793
+ subscribeFleetOps();
1531
1794
 
1532
1795
  // Per-child event log (analogue of peekLogs but for child processes).
1796
+ // Keys come from procLogKey: bare child name for the whole-process stream,
1797
+ // `child#agent` for an agent-filtered stream — the two never mix.
1533
1798
  const procPeekLogs = new Map<string, FleetLine[]>();
1799
+ function procLogKey(child: string, agentFilter?: string): string {
1800
+ return agentFilter ? `${child}#${agentFilter}` : child;
1801
+ }
1534
1802
  // In-progress token line buffer per child (flushed to log on newline / next event).
1535
1803
  const procPeekTokenLine = new Map<string, string>();
1804
+ /** Last event (by object identity) each peek key has processed — detects
1805
+ * "events arrived while detached" on re-entry so the gap can be marked. */
1806
+ const procPeekLastEvent = new Map<string, WireEvent>();
1536
1807
  let procPeekUnsub: (() => void) | null = null;
1537
1808
 
1538
1809
  function appendProcPeekLog(name: string, text: string, color: string): void {
@@ -1664,11 +1935,12 @@ export async function runTui(app: AppContext): Promise<void> {
1664
1935
  agentContextTokens.set(name, event.lastInputTokens);
1665
1936
  }
1666
1937
 
1667
- // Verbose chat display
1668
- if (verboseChat) {
1669
- const chatTruncated = summary.length > 200 ? summary.slice(0, 197) + '...' : summary;
1670
- addLine(` ◀ [${name}] ${chatTruncated}`, CYAN);
1671
- }
1938
+ // Surface the result in chat — a fork that completes with no
1939
+ // visible line looks like it never returned. Verbose shows more
1940
+ // of the summary; terse shows a shorter, dimmer line.
1941
+ const limit = verboseChat ? 200 : 100;
1942
+ const chatTruncated = summary.length > limit ? summary.slice(0, limit - 3) + '...' : summary;
1943
+ addLine(` ◀ [${name}] ${chatTruncated}`, verboseChat ? CYAN : DIM_GRAY);
1672
1944
  break;
1673
1945
  }
1674
1946
  }
@@ -1681,6 +1953,42 @@ export async function runTui(app: AppContext): Promise<void> {
1681
1953
  });
1682
1954
  subagentStreamUnsubs.push(unsub);
1683
1955
  }
1956
+
1957
+ /**
1958
+ * Per-session observability reset. Everything here accumulates from trace
1959
+ * and IPC subscriptions bound to the CURRENT framework; after a session
1960
+ * switch the old subscriptions feed dead modules and the caches describe
1961
+ * agents that no longer exist. Worse, a new session's subagent with a
1962
+ * previously-seen name would never re-subscribe (subscribeSubagentStream
1963
+ * early-returns on the subscribedSubagents guard).
1964
+ */
1965
+ function resetObservabilityState(): void {
1966
+ for (const unsub of subagentStreamUnsubs) unsub();
1967
+ subagentStreamUnsubs.length = 0;
1968
+ subscribedSubagents.clear();
1969
+ peekLogs.clear();
1970
+ peekCurrentTool.clear();
1971
+ peekTokenLine.clear();
1972
+ procPeekLogs.clear();
1973
+ procPeekTokenLine.clear();
1974
+ procPeekLastEvent.clear();
1975
+ agentTranscripts.clear();
1976
+ transcriptTotalLen.clear();
1977
+ agentContextTokens.clear();
1978
+ agentParent.clear();
1979
+ summaryCache.clear();
1980
+ summarySnapshotLen.clear();
1981
+ summaryPending.clear();
1982
+ summaryBackoffUntil.clear();
1983
+ subagentPhase.clear();
1984
+ state.subagents = [];
1985
+ seenFleetHeaders.clear();
1986
+ expandedNodes.clear();
1987
+ expandedNodes.add(rootAgentName);
1988
+ fleetCursor = 0;
1989
+ initTreeAggregator();
1990
+ }
1991
+
1684
1992
  const pollTimer = setInterval(() => {
1685
1993
  // Animate spinner when researcher is active (not just on token events)
1686
1994
  if (state.status !== 'idle' && state.status !== 'error') {
@@ -1708,6 +2016,16 @@ export async function runTui(app: AppContext): Promise<void> {
1708
2016
  }
1709
2017
  if (state.viewMode === 'peek-proc') updatePeekProcView();
1710
2018
  }
2019
+ // Synesthete summaries for the fleet view. Triggered here — NOT from the
2020
+ // render path — so a repaint can never originate a Haiku call.
2021
+ // generateSummary self-throttles (pending guard + 2k-char delta).
2022
+ if (state.viewMode === 'fleet') {
2023
+ generateSummary(rootAgentName);
2024
+ for (const sa of state.subagents) {
2025
+ const full = [...agentTranscripts.keys()].find(k => k === sa.name || shortAgentName(k) === sa.name);
2026
+ if (full) generateSummary(full);
2027
+ }
2028
+ }
1711
2029
  }, 500);
1712
2030
 
1713
2031
  // ── Keyboard ───────────────────────────────────────────────────────
@@ -1737,6 +2055,10 @@ export async function runTui(app: AppContext): Promise<void> {
1737
2055
  return;
1738
2056
  }
1739
2057
  if (key.ctrl && key.name === 'c') {
2058
+ // Same semantics as /quit: children running → confirm first. A second
2059
+ // Ctrl+C while the prompt is pending force-quits (kills children) —
2060
+ // preserves "mash Ctrl+C to really exit" muscle memory.
2061
+ if (!state.pendingQuitConfirm && promptQuitConfirmIfNeeded()) return;
1740
2062
  cleanup();
1741
2063
  return;
1742
2064
  }
@@ -1748,7 +2070,7 @@ export async function runTui(app: AppContext): Promise<void> {
1748
2070
  // Ctrl+B: push to background — detach any blocking sync subagents and/or
1749
2071
  // background the researcher's current inference (stop displaying tokens,
1750
2072
  // re-enable input; result appears as message when done)
1751
- if (key.ctrl && key.name === 'b' && state.viewMode === 'chat') {
2073
+ if (key.ctrl && key.name === 'b' && (state.viewMode === 'chat' || state.viewMode === 'fleet')) {
1752
2074
  let acted = false;
1753
2075
 
1754
2076
  // 1. Detach any blocking sync subagents
@@ -1821,7 +2143,10 @@ export async function runTui(app: AppContext): Promise<void> {
1821
2143
 
1822
2144
  // Fleet view navigation
1823
2145
  if (state.viewMode === 'fleet') {
1824
- if (key.name === 'up') {
2146
+ if (key.name === 'escape') {
2147
+ switchView('chat');
2148
+ updateStatus();
2149
+ } else if (key.name === 'up') {
1825
2150
  fleetCursor = Math.max(0, fleetCursor - 1);
1826
2151
  updateFleetView();
1827
2152
  } else if (key.name === 'down') {
@@ -1847,12 +2172,11 @@ export async function runTui(app: AppContext): Promise<void> {
1847
2172
  // Dispatch by node kind, not string-prefix surgery on the ID.
1848
2173
  if (node.kind === 'fleet-child') {
1849
2174
  enterPeekProc(node.fleetChildName!);
1850
- switchView('peek-proc');
1851
2175
  } else if (node.kind === 'fleet-child-agent') {
1852
- // Per-agent peek isn't separately supported yet; peek the
1853
- // owning process its event stream still includes this agent.
1854
- enterPeekProc(node.fleetChildName!);
1855
- switchView('peek-proc');
2176
+ // Honest per-agent peek: the child's event stream filtered to
2177
+ // this one agent (works for the child's root agent and for its
2178
+ // subagents — sub-subagents from where we stand).
2179
+ enterPeekProc(node.fleetChildName!, node.fullName);
1856
2180
  } else {
1857
2181
  // node.kind === 'subagent' — local in-process peek.
1858
2182
  enterPeek(nodeId!);
@@ -1864,9 +2188,14 @@ export async function runTui(app: AppContext): Promise<void> {
1864
2188
  if (node && node.kind !== 'researcher') {
1865
2189
  if (node.kind === 'fleet-child' && fleetMod) {
1866
2190
  const childName = node.fleetChildName!;
2191
+ // handleToolCall resolves with {success:false, error} rather than
2192
+ // throwing — a bare .catch() here used to swallow every failure.
1867
2193
  fleetMod.handleToolCall({ id: `tui-kill-${Date.now()}`, name: 'kill', input: { name: childName } })
1868
- .then(() => updateFleetView())
1869
- .catch(() => { /* error surfaces in status */ });
2194
+ .then((res) => {
2195
+ if (!res.success) showFleetNotice(`stop ${childName} failed: ${res.error ?? 'unknown'}`);
2196
+ updateFleetView();
2197
+ })
2198
+ .catch((err: unknown) => showFleetNotice(`stop ${childName} failed: ${String(err)}`));
1870
2199
  } else if (node.kind === 'subagent') {
1871
2200
  const sa = state.subagents.find(s => s.name === nodeId);
1872
2201
  if (sa?.status === 'running' && subMod) {
@@ -1884,8 +2213,11 @@ export async function runTui(app: AppContext): Promise<void> {
1884
2213
  if (node?.kind === 'fleet-child' && fleetMod) {
1885
2214
  const childName = node.fleetChildName!;
1886
2215
  fleetMod.handleToolCall({ id: `tui-restart-${Date.now()}`, name: 'restart', input: { name: childName } })
1887
- .then(() => updateFleetView())
1888
- .catch(() => { /* error surfaces in status */ });
2216
+ .then((res) => {
2217
+ if (!res.success) showFleetNotice(`restart ${childName} failed: ${res.error ?? 'unknown'}`);
2218
+ updateFleetView();
2219
+ })
2220
+ .catch((err: unknown) => showFleetNotice(`restart ${childName} failed: ${String(err)}`));
1889
2221
  }
1890
2222
  }
1891
2223
  }
@@ -1895,57 +2227,89 @@ export async function runTui(app: AppContext): Promise<void> {
1895
2227
 
1896
2228
  let resolveExit: (() => void) | null = null;
1897
2229
 
2230
+ /**
2231
+ * If fleet children are alive, print the quit-confirm prompt, arm
2232
+ * pendingQuitConfirm, and return true (caller should NOT exit yet).
2233
+ * Returns false when nothing is running — safe to exit immediately.
2234
+ * Shared by /quit and Ctrl+C so both exit paths have the same semantics.
2235
+ */
2236
+ function promptQuitConfirmIfNeeded(): boolean {
2237
+ const running = fleetMod
2238
+ ? [...fleetMod.getChildren().values()].filter((c) => c.status === 'ready' || c.status === 'starting')
2239
+ : [];
2240
+ if (running.length === 0) return false;
2241
+ if (state.viewMode !== 'chat') {
2242
+ cleanupPeek();
2243
+ cleanupPeekProc();
2244
+ switchView('chat');
2245
+ }
2246
+ addLine(` ${running.length} child${running.length > 1 ? 'ren' : ''} still running: ${running.map(c => c.name).join(', ')}`, YELLOW);
2247
+ addLine(' Stop them before exit? [y/N/d] — y=kill gracefully, d=detach and leave running, anything else cancels', YELLOW);
2248
+ state.pendingQuitConfirm = true;
2249
+ updateStatus();
2250
+ return true;
2251
+ }
2252
+
1898
2253
  function handleSubmit() {
1899
2254
  const raw = ((input as any).plainText as string).trim();
1900
2255
  (input as any).clear();
1901
2256
 
1902
- if (!raw) { pastedTexts.length = 0; return; }
1903
-
1904
- // Expand paste placeholders
1905
- const text = pastedTexts.length > 0
1906
- ? raw.replace(/\[paste #(\d+):[^\]]*\]/g, (m, n) => pastedTexts[parseInt(n, 10) - 1] ?? m)
1907
- : raw;
1908
- pastedTexts.length = 0;
1909
-
1910
- // Resolve a pending /quit confirmation prompt first.
2257
+ // Resolve a pending /quit confirmation prompt first — BEFORE the
2258
+ // empty-input early return, or plain Enter (the advertised [y/N/d]
2259
+ // default: cancel) would silently do nothing and leave the prompt
2260
+ // armed to swallow the user's next real message.
1911
2261
  if (state.pendingQuitConfirm) {
1912
2262
  state.pendingQuitConfirm = false;
1913
- const c = text.trim().toLowerCase();
1914
- if (c === 'n' || c === 'no' || c === 'cancel') {
1915
- addLine(' (quit cancelled)', GRAY);
2263
+ const action = resolveQuitConfirm(raw);
2264
+ if (action === 'kill') {
2265
+ addLine(' (stopping children and exiting...)', GRAY);
2266
+ cleanup();
1916
2267
  return;
1917
2268
  }
1918
- if (c === 'd' || c === 'detach') {
2269
+ if (action === 'detach') {
1919
2270
  fleetMod?.setDetachMode(true);
1920
2271
  addLine(' (detaching — children stay running; next parent will adopt them)', CYAN);
1921
2272
  cleanup();
1922
2273
  return;
1923
2274
  }
1924
- // default (y / empty / anything else) → graceful kill + exit
1925
- addLine(' (stopping children and exiting...)', GRAY);
1926
- cleanup();
2275
+ if (action === 'cancel-keep-input') {
2276
+ // The user typed a real message at the prompt. Cancel the quit and
2277
+ // put the message BACK — clearing it (with its paste referents)
2278
+ // while advising "type it again" would destroy a paste the user
2279
+ // cannot re-type. pastedTexts is deliberately left intact so the
2280
+ // restored placeholders still expand on the next submit.
2281
+ (input as any).insertText(raw);
2282
+ addLine(' (quit cancelled — your message was restored to the input; press Enter to send)', GRAY);
2283
+ } else {
2284
+ pastedTexts.length = 0;
2285
+ addLine(' (quit cancelled)', GRAY);
2286
+ }
1927
2287
  return;
1928
2288
  }
1929
2289
 
2290
+ if (!raw) { pastedTexts.length = 0; return; }
2291
+
2292
+ // Expand paste placeholders
2293
+ const text = pastedTexts.length > 0
2294
+ ? raw.replace(/\[paste #(\d+):[^\]]*\]/g, (m, n) => pastedTexts[parseInt(n, 10) - 1] ?? m)
2295
+ : raw;
2296
+ pastedTexts.length = 0;
2297
+
1930
2298
  if (text.startsWith('/')) {
1931
2299
  const result = handleCommand(text, app);
1932
2300
  if (result.quit) {
1933
- const running = fleetMod
1934
- ? [...fleetMod.getChildren().values()].filter((c) => c.status === 'ready' || c.status === 'starting')
1935
- : [];
1936
- if (running.length > 0) {
1937
- addLine(` ${running.length} child${running.length > 1 ? 'ren' : ''} still running: ${running.map(c => c.name).join(', ')}`, YELLOW);
1938
- addLine(' Stop them before exit? [Y/n/d] — Y=kill gracefully, n=cancel quit, d=detach and leave running', YELLOW);
1939
- state.pendingQuitConfirm = true;
1940
- return;
1941
- }
2301
+ if (promptQuitConfirmIfNeeded()) return;
1942
2302
  cleanup();
1943
2303
  return;
1944
2304
  }
1945
- if (text === '/clear') {
2305
+ if (text === '/clear' || text.startsWith('/clear ')) {
2306
+ // End any live stream first — its renderable is about to be
2307
+ // destroyed, and streamToken must not write to a freed buffer.
2308
+ if (streaming) endStream();
1946
2309
  const children = [...scrollBox.getChildren()];
1947
2310
  for (const child of children) {
1948
2311
  scrollBox.remove(child.id);
2312
+ child.destroy();
1949
2313
  }
1950
2314
  } else {
1951
2315
  for (const l of result.lines) {
@@ -1985,12 +2349,20 @@ export async function runTui(app: AppContext): Promise<void> {
1985
2349
  // Rebind to new framework
1986
2350
  subMod = app.framework.getAllModules().find(m => m.name === 'subagent') as SubagentModule | undefined;
1987
2351
  fleetMod = app.framework.getAllModules().find(m => m.name === 'fleet') as FleetModule | undefined;
2352
+ subscribeFleetOps();
2353
+ resetObservabilityState();
1988
2354
  app.framework.onTrace(onTrace as (e: unknown) => void);
1989
2355
 
1990
2356
  const session = app.sessionManager.getActiveSession();
1991
2357
  refreshFromStore();
1992
2358
  addLine(`Session: ${session?.name ?? 'unknown'}`, GRAY);
1993
2359
  state.tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
2360
+ state.ctxTokens = 0;
2361
+ // Alerts describe the OLD session's strategy/agents; the new
2362
+ // session's own klaxons re-fire within their alarm interval if the
2363
+ // condition still holds there. A stale alert with no reachable
2364
+ // all-clear would otherwise pin the status bar red forever.
2365
+ opsAlerts.clear();
1994
2366
  updateStatus();
1995
2367
  }).catch(err => {
1996
2368
  addLine(`Session switch failed: ${err}`, RED);
@@ -2061,6 +2433,8 @@ export async function runTui(app: AppContext): Promise<void> {
2061
2433
  function cleanup() {
2062
2434
  cleanupPeek();
2063
2435
  cleanupPeekProc();
2436
+ if (fleetNoticeTimer) clearTimeout(fleetNoticeTimer);
2437
+ fleetOpsUnsub?.();
2064
2438
  treeAggregator?.dispose();
2065
2439
  for (const unsub of subagentStreamUnsubs) unsub();
2066
2440
  clearInterval(pollTimer);
@@ -2090,9 +2464,11 @@ function formatStatusLeft(
2090
2464
  state: TuiState,
2091
2465
  spinnerChar?: string,
2092
2466
  outputTokens?: number,
2467
+ alertCount = 0,
2093
2468
  ): string {
2094
2469
  const sColor = state.status === 'idle' ? '✓' : state.status === 'error' ? '✗' : state.status === 'background' ? '↓' : state.status === 'queued' ? '⏳' : '…';
2095
2470
  let bar = `[${sColor} ${state.status}`;
2471
+ if (alertCount > 0) bar += ` | ⚠ ${alertCount} alert${alertCount === 1 ? '' : 's'}`;
2096
2472
  if (spinnerChar !== undefined && state.status !== 'idle' && state.status !== 'error' && state.status !== 'background') {
2097
2473
  bar += ` ${spinnerChar}`;
2098
2474
  if (state.status === 'thinking' && outputTokens !== undefined && outputTokens > 0) {
@@ -2100,7 +2476,12 @@ function formatStatusLeft(
2100
2476
  bar += ` ${tokStr} tok`;
2101
2477
  }
2102
2478
  }
2103
- if (state.tool) bar += ` | ${state.tool}`;
2479
+ if (state.tool) {
2480
+ // A parallel batch of long MCPL-prefixed names would otherwise push the
2481
+ // right status segment (tokens/cost/mem) clean off the row.
2482
+ const tool = state.tool.length > 40 ? state.tool.slice(0, 37) + '…' : state.tool;
2483
+ bar += ` | ${tool}`;
2484
+ }
2104
2485
  const running = state.subagents.filter(s => s.status === 'running').length;
2105
2486
  if (running > 0) {
2106
2487
  bar += ` | ${running} sub`;
@@ -2108,7 +2489,9 @@ function formatStatusLeft(
2108
2489
  if (state.viewMode === 'fleet' || state.viewMode === 'peek') {
2109
2490
  bar += state.viewMode === 'peek' ? ` | peek: ${state.peekTarget}` : ' | fleet view';
2110
2491
  } else if (state.viewMode === 'peek-proc') {
2111
- bar += ` | peek-proc: ${state.peekProcTarget}`;
2492
+ bar += state.peekProcAgent
2493
+ ? ` | peek: ${state.peekProcTarget}›${state.peekProcAgent}`
2494
+ : ` | peek-proc: ${state.peekProcTarget}`;
2112
2495
  } else if (state.viewMode === 'chat') {
2113
2496
  if (state.status === 'background') bar += ' Esc:stop';
2114
2497
  else if (state.status !== 'idle' && state.status !== 'error') bar += ' Ctrl+B:bg Esc:stop';
@@ -2118,14 +2501,21 @@ function formatStatusLeft(
2118
2501
  return bar;
2119
2502
  }
2120
2503
 
2121
- function formatTokens(tokens: TokenUsage, verbose: boolean): string {
2504
+ function formatTokens(tokens: TokenUsage, verbose: boolean, ctxTokens = 0): string {
2122
2505
  const parts: string[] = [];
2123
2506
 
2507
+ // Current context size first, session totals (Σ) after — two different
2508
+ // quantities, two labels.
2509
+ if (ctxTokens > 0) parts.push(`ctx:${fmtTokens(ctxTokens)}`);
2510
+
2124
2511
  const total = tokens.input + tokens.output;
2125
2512
  if (total > 0) {
2126
- let s = `${fmtTokens(tokens.input)}in ${fmtTokens(tokens.output)}out`;
2513
+ let s = `Σ ${fmtTokens(tokens.input)}in ${fmtTokens(tokens.output)}out`;
2127
2514
  if (tokens.cacheRead > 0) s += ` ${fmtTokens(tokens.cacheRead)}hit`;
2128
2515
  if (tokens.cacheWrite > 0) s += ` ${fmtTokens(tokens.cacheWrite)}write`;
2516
+ if (tokens.cost && tokens.cost.total > 0) {
2517
+ s += ` $${tokens.cost.total.toFixed(tokens.cost.total < 1 ? 3 : 2)}`;
2518
+ }
2129
2519
  parts.push(s);
2130
2520
  }
2131
2521