@animalabs/connectome-host 0.7.2 → 0.7.4

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 (63) hide show
  1. package/CHANGELOG.md +203 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +22 -11
  4. package/docs/AGENT-ONBOARDING.md +20 -1
  5. package/docs/debug-context-api.md +2 -2
  6. package/docs/retrieval-traces.md +173 -0
  7. package/docs/webui-deployment.md +2 -1
  8. package/package.json +3 -3
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/scripts/warmup-session.ts +17 -3
  11. package/src/codex-subscription-adapter.ts +13 -1
  12. package/src/framework-agent-config.ts +59 -4
  13. package/src/framework-strategy.ts +33 -3
  14. package/src/headless.ts +14 -0
  15. package/src/index.ts +95 -35
  16. package/src/logging-adapter.ts +13 -2
  17. package/src/mcpl-config.ts +8 -0
  18. package/src/modules/fleet-module.ts +60 -1
  19. package/src/modules/fleet-types.ts +30 -1
  20. package/src/modules/identity-module.ts +274 -0
  21. package/src/modules/mcpl-admin-module.ts +78 -5
  22. package/src/modules/observers-module.ts +12 -0
  23. package/src/modules/retrieval-module.ts +254 -52
  24. package/src/modules/retrieval-trace-page.ts +254 -0
  25. package/src/modules/retrieval-trace.ts +904 -0
  26. package/src/modules/settings-module.ts +28 -2
  27. package/src/modules/subscription-gc-module.ts +54 -1
  28. package/src/modules/tts-relay-module.ts +33 -18
  29. package/src/modules/web-ui-module.ts +445 -894
  30. package/src/recipe.ts +137 -12
  31. package/src/retrieval-config.ts +39 -0
  32. package/src/strategies/frontdesk-strategy.ts +34 -125
  33. package/src/tui.ts +325 -54
  34. package/src/web/panel-data.ts +1187 -0
  35. package/src/web/protocol.ts +75 -10
  36. package/test/audit-module-optins.test.ts +167 -0
  37. package/test/bedrock-prompt-caching.test.ts +170 -0
  38. package/test/fleet-panel-request.test.ts +90 -0
  39. package/test/framework-strategy-defaults.test.ts +110 -0
  40. package/test/frontdesk-strategy.test.ts +25 -37
  41. package/test/headless-panel-request.test.ts +201 -0
  42. package/test/identity-and-surfaces.test.ts +157 -0
  43. package/test/mcpl-admin-module.test.ts +23 -0
  44. package/test/mock-headless-child.ts +14 -0
  45. package/test/retrieval-auth-loopback.test.ts +49 -0
  46. package/test/retrieval-config.test.ts +74 -0
  47. package/test/retrieval-module.test.ts +821 -0
  48. package/test/subscription-gc-module.test.ts +152 -0
  49. package/test/tui-format.test.ts +106 -0
  50. package/test/web-ui-context-coverage.test.ts +1 -1
  51. package/test/web-ui-module.test.ts +189 -3
  52. package/test/web-ui-observers.test.ts +8 -5
  53. package/test/web-ui-protocol.test.ts +0 -0
  54. package/web/bun.lock +345 -0
  55. package/web/src/App.tsx +159 -44
  56. package/web/src/Context.tsx +35 -8
  57. package/web/src/ContextDocument.tsx +20 -5
  58. package/web/src/Files.tsx +2 -8
  59. package/web/src/Lessons.tsx +2 -38
  60. package/web/src/Mcpl.tsx +80 -14
  61. package/web/src/Pins.tsx +5 -0
  62. package/web/src/Settings.tsx +5 -0
  63. package/web/vite.config.ts +8 -2
package/src/tui.ts CHANGED
@@ -47,6 +47,64 @@ export function fmtTokens(n: number): string {
47
47
  return String(n);
48
48
  }
49
49
 
50
+ /** Format elapsed seconds for humans: 48s / 5m48s / 1h02m. */
51
+ export function fmtElapsed(seconds: number): string {
52
+ if (seconds < 60) return `${seconds}s`;
53
+ const min = Math.floor(seconds / 60);
54
+ if (min < 60) return `${min}m${String(seconds % 60).padStart(2, '0')}s`;
55
+ return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}m`;
56
+ }
57
+
58
+ /**
59
+ * How close a context is to its budget. Drives status-bar color escalation:
60
+ * the operator's real question is not "how many tokens" but "how close to
61
+ * compression/trouble" — a bare number can't answer that.
62
+ */
63
+ export function ctxSeverity(ctx: number, budget?: number): 'ok' | 'warn' | 'high' {
64
+ if (!budget || budget <= 0 || ctx <= 0) return 'ok';
65
+ const frac = ctx / budget;
66
+ return frac >= 0.9 ? 'high' : frac >= 0.75 ? 'warn' : 'ok';
67
+ }
68
+
69
+ /** Alert kinds that always win the status-bar label, in priority order. */
70
+ const ALERT_KIND_PRIORITY = ['compression-quarantine', 'inference-exhausted'];
71
+
72
+ /**
73
+ * Which alert kind the status bar should name when several are active.
74
+ * Priority kinds (quarantine, hard-down) win; otherwise the most recent
75
+ * (= last inserted, since upserts keep their original Map position but new
76
+ * kinds append).
77
+ */
78
+ export function pickTopAlert(kinds: string[]): string | null {
79
+ if (kinds.length === 0) return null;
80
+ for (const p of ALERT_KIND_PRIORITY) {
81
+ if (kinds.includes(p)) return p;
82
+ }
83
+ return kinds[kinds.length - 1] ?? null;
84
+ }
85
+
86
+ /**
87
+ * Viewport slice for a cursor-driven line list: which [start, end) range of
88
+ * `len` lines fits in `avail` rows while keeping `cursor` visible. Callers
89
+ * render a `┈ N above ┈` marker when start > 0 and `┈ N below ┈` when
90
+ * end < len — the marker rows are budgeted for here, which is why the body
91
+ * grows by one at either edge (that marker doesn't render).
92
+ */
93
+ export function sliceViewport(len: number, cursor: number, avail: number): { start: number; end: number } {
94
+ if (len <= avail) return { start: 0, end: len };
95
+ const body = Math.max(1, avail - 2);
96
+ const start = Math.max(0, Math.min(cursor - Math.floor(body / 2), len - body));
97
+ const end = start + body;
98
+ if (start === 0) return { start, end: Math.min(len, Math.max(1, avail - 1)) };
99
+ if (end >= len) return { start: Math.max(0, len - Math.max(1, avail - 1)), end: len };
100
+ return { start, end };
101
+ }
102
+
103
+ /** Local wall-clock HH:MM, for event-line timestamps. */
104
+ function hhmm(now = new Date()): string {
105
+ return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
106
+ }
107
+
50
108
  /**
51
109
  * What a submission at the armed /quit prompt means. Pure so the semantics
52
110
  * are pinned by tests: the default for arbitrary input is CANCEL — the
@@ -120,6 +178,10 @@ interface OpsAlertEntry {
120
178
  interface TuiState {
121
179
  status: string;
122
180
  tool: string | null;
181
+ /** When the currently-running root-agent tool started (epoch ms). Lets the
182
+ * status bar show a running elapsed on slow tools — the "is it stuck?"
183
+ * answer without switching views. */
184
+ toolStartedAt: number | null;
123
185
  subagents: ActiveSubagent[];
124
186
  /**
125
187
  * chat — conversation + stream
@@ -223,6 +285,7 @@ export async function runTui(app: AppContext): Promise<void> {
223
285
  const state: TuiState = {
224
286
  status: 'idle',
225
287
  tool: null,
288
+ toolStartedAt: null,
226
289
  subagents: [],
227
290
  viewMode: 'chat',
228
291
  tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -245,7 +308,7 @@ export async function runTui(app: AppContext): Promise<void> {
245
308
  if (kind.endsWith('-clear')) {
246
309
  const baseKey = `${agent}:${kind.slice(0, -'-clear'.length)}`;
247
310
  if (opsAlerts.delete(baseKey)) {
248
- addLine(`✓ [${agent}] ${kind}: ${message}`, CYAN);
311
+ addEvent(`✓ [${agent}] ${kind}: ${message}`, CYAN);
249
312
  updateStatus();
250
313
  }
251
314
  return;
@@ -254,7 +317,7 @@ export async function runTui(app: AppContext): Promise<void> {
254
317
  const existing = opsAlerts.get(key);
255
318
  opsAlerts.set(key, { kind, agent, message, count: (existing?.count ?? 0) + 1 });
256
319
  const times = existing ? ` (×${existing.count + 1})` : '';
257
- addLine(`⚠ [${agent}] ${kind}${times}: ${message}`, RED);
320
+ addEvent(`⚠ [${agent}] ${kind}${times}: ${message}`, RED);
258
321
  updateStatus();
259
322
  }
260
323
 
@@ -502,12 +565,50 @@ export async function runTui(app: AppContext): Promise<void> {
502
565
  pruneScrollback();
503
566
  }
504
567
 
568
+ /** addLine with an HH:MM prefix — for *event* lines (alerts, tool batches,
569
+ * subagent results, branch switches…): scrollback read an hour later is
570
+ * useless without knowing WHEN things happened. Streamed prose and
571
+ * immediate-feedback hints stay unstamped. */
572
+ function addEvent(text: string, color: string = WHITE) {
573
+ addLine(`${hhmm()} ${text}`, color);
574
+ }
575
+
576
+ /**
577
+ * Live context budget for an agent. Runtime overrides (persisted in the
578
+ * `framework/state` slot) win over the recipe — same seam the web module's
579
+ * /curve endpoint uses; reading only the recipe plots the wrong gauge on
580
+ * any agent whose budget was ever changed at runtime.
581
+ */
582
+ function getAgentBudget(name: string): number | undefined {
583
+ try {
584
+ const live = (app.framework as unknown as {
585
+ getAgentRuntimeSettings?: (n: string) => { contextBudgetTokens?: number } | undefined;
586
+ }).getAgentRuntimeSettings?.(name)?.contextBudgetTokens;
587
+ if (typeof live === 'number' && live > 0) return live;
588
+ } catch { /* fall through to recipe */ }
589
+ return name === rootAgentName ? app.recipe?.agent?.contextBudgetTokens : undefined;
590
+ }
591
+
505
592
  function updateStatus() {
506
- statusLeft.content = formatStatusLeft(state, SPINNER[spinnerFrame], streamOutputTokens, opsAlerts.size);
593
+ const topAlert = pickTopAlert([...opsAlerts.values()].map(a => a.kind));
594
+ let left = formatStatusLeft(state, SPINNER[spinnerFrame], streamOutputTokens, opsAlerts.size, topAlert);
507
595
  // An active ops alert repaints the whole left segment — a one-cell glyph
508
596
  // in default gray is exactly the kind of signal that gets scrolled past.
509
597
  statusLeft.fg = opsAlerts.size > 0 ? RED : GRAY;
510
- statusRight.content = formatTokens(state.tokens, verboseChat, state.ctxTokens) + formatMemStats(getRootCM());
598
+
599
+ const budget = getAgentBudget(rootAgentName);
600
+ const right = formatTokens(state.tokens, verboseChat, state.ctxTokens, budget) + formatMemStats(getRootCM());
601
+ // Same escalation as the ctx gauge itself: the right segment goes yellow
602
+ // at 75% of budget, red at 90% — visible even when the numbers aren't read.
603
+ const sev = ctxSeverity(state.ctxTokens, budget);
604
+ statusRight.fg = sev === 'high' ? RED : sev === 'warn' ? YELLOW : DIM_GRAY;
605
+ statusRight.content = right;
606
+
607
+ // Width budget: long tool names + peek target + alerts must lose to the
608
+ // tokens/mem segment, not shove it off the row.
609
+ const maxLeft = Math.max(12, renderer.terminalWidth - right.length - 4);
610
+ if (left.length > maxLeft) left = left.slice(0, maxLeft - 1) + '…';
611
+ statusLeft.content = left;
511
612
  }
512
613
 
513
614
  /** Best-effort handle to the root agent's ContextManager, for stats queries. */
@@ -523,6 +624,23 @@ export async function runTui(app: AppContext): Promise<void> {
523
624
 
524
625
  let currentStreamBlockType: StreamBlockType = 'text';
525
626
 
627
+ // Terse mode (Ctrl+V off) collapses live thinking into a single counting
628
+ // line instead of streaming the full monologue — the toggle's label always
629
+ // promised "showing agent thoughts" but thinking streamed regardless.
630
+ let thinkingCollapsed = false;
631
+ let thinkingCollapsedChars = 0;
632
+
633
+ /** Freeze a collapsed thinking line at its final size when the stream
634
+ * leaves the thinking lane (or ends). */
635
+ function finalizeCollapsedThinking() {
636
+ if (!thinkingCollapsed) return;
637
+ if (currentStreamText && currentStreamBlockType === 'thinking') {
638
+ currentStreamText.content = `${THINKING_PREFIX}(thought, ~${fmtTokens(Math.ceil(thinkingCollapsedChars / 4))} tok)`;
639
+ }
640
+ thinkingCollapsed = false;
641
+ thinkingCollapsedChars = 0;
642
+ }
643
+
526
644
  function beginStream() {
527
645
  currentStreamBuffer = '';
528
646
  currentStreamBlockType = 'text';
@@ -545,6 +663,7 @@ export async function runTui(app: AppContext): Promise<void> {
545
663
  */
546
664
  function switchStreamBlock(blockType: StreamBlockType) {
547
665
  if (currentStreamBlockType === blockType && currentStreamText) return;
666
+ if (blockType !== 'thinking') finalizeCollapsedThinking();
548
667
  // Tool blocks (tool_call / tool_result) aren't rendered as a stream lane
549
668
  // here — they're surfaced via the tool:* trace events instead. We still
550
669
  // need to update currentStreamBlockType so that when tokens swing back to
@@ -558,7 +677,11 @@ export async function runTui(app: AppContext): Promise<void> {
558
677
  currentStreamBuffer = '';
559
678
  currentStreamBlockType = blockType;
560
679
  if (blockType === 'thinking') {
561
- currentStreamBuffer = THINKING_PREFIX;
680
+ if (!verboseChat) {
681
+ thinkingCollapsed = true;
682
+ thinkingCollapsedChars = 0;
683
+ }
684
+ currentStreamBuffer = thinkingCollapsed ? `${THINKING_PREFIX}thinking…` : THINKING_PREFIX;
562
685
  currentStreamText = new TextRenderable(renderer, {
563
686
  id: `stream-thinking-${++messageCounter}`,
564
687
  content: currentStreamBuffer,
@@ -576,27 +699,42 @@ export async function runTui(app: AppContext): Promise<void> {
576
699
  }
577
700
 
578
701
  function streamToken(text: string) {
579
- if (currentStreamText) {
580
- currentStreamBuffer += text;
581
- currentStreamText.content = currentStreamBuffer;
702
+ if (!currentStreamText) return;
703
+ if (thinkingCollapsed && currentStreamBlockType === 'thinking') {
704
+ // Count instead of print: liveness without the wall of text.
705
+ thinkingCollapsedChars += text.length;
706
+ currentStreamText.content = `${THINKING_PREFIX}thinking… ~${fmtTokens(Math.ceil(thinkingCollapsedChars / 4))} tok`;
707
+ return;
582
708
  }
709
+ currentStreamBuffer += text;
710
+ currentStreamText.content = currentStreamBuffer;
583
711
  }
584
712
 
585
713
  function endStream() {
714
+ finalizeCollapsedThinking();
586
715
  streaming = false;
587
716
  currentStreamText = null;
588
717
  currentStreamBuffer = '';
589
718
  currentStreamBlockType = 'text';
590
719
  }
591
720
 
721
+ /** Replay at most this many messages on startup/branch-switch. A long
722
+ * session replayed in full floods scrollback with thousands of lines
723
+ * (thinking included) before the user can type; the full history is one
724
+ * /curve or web-UI visit away. */
725
+ const HISTORY_REPLAY_MAX = 50;
726
+
592
727
  function loadSessionHistory() {
593
728
  const agent = app.framework.getAgent(rootAgentName);
594
729
  if (!agent) return;
595
730
  const cm = agent.getContextManager();
596
- const messages = cm.getAllMessages();
597
- if (messages.length === 0) return;
731
+ const all = cm.getAllMessages();
732
+ if (all.length === 0) return;
733
+ const messages = all.length > HISTORY_REPLAY_MAX ? all.slice(-HISTORY_REPLAY_MAX) : all;
598
734
 
599
- addLine(`── session history (${messages.length} messages) ──`, DIM_GRAY);
735
+ addLine(messages.length < all.length
736
+ ? `── session history (last ${messages.length} of ${all.length} messages — full history in the web UI) ──`
737
+ : `── session history (${messages.length} messages) ──`, DIM_GRAY);
600
738
 
601
739
  for (const msg of messages) {
602
740
  const toolNames: string[] = [];
@@ -611,7 +749,10 @@ export async function runTui(app: AppContext): Promise<void> {
611
749
  } else if (block.type === 'thinking') {
612
750
  const t = (block as { thinking?: string }).thinking;
613
751
  if (t && t.trim()) {
614
- addLine(`${THINKING_PREFIX}${t}`, THINKING_DIM);
752
+ // Replayed thinking is context, not content — one truncated line
753
+ // per block keeps the flavor without re-flooding scrollback.
754
+ const line = t.trim().replace(/\s+/g, ' ');
755
+ addLine(`${THINKING_PREFIX}${line.length > 120 ? line.slice(0, 117) + '…' : line}`, THINKING_DIM);
615
756
  }
616
757
  } else if (block.type === 'tool_use') {
617
758
  toolNames.push((block as { name: string }).name);
@@ -637,6 +778,8 @@ export async function runTui(app: AppContext): Promise<void> {
637
778
  streaming = false;
638
779
  currentStreamText = null;
639
780
  currentStreamBuffer = '';
781
+ thinkingCollapsed = false;
782
+ thinkingCollapsedChars = 0;
640
783
 
641
784
  // Clear conversation display (destroy frees the native text buffers)
642
785
  const children = [...scrollBox.getChildren()];
@@ -681,6 +824,10 @@ export async function runTui(app: AppContext): Promise<void> {
681
824
  * child (a manual collapse afterward sticks). */
682
825
  const seenFleetHeaders = new Set<string>();
683
826
  let fleetCursor = 0;
827
+ /** Line index (within the tree-lines array) of the cursor's header row —
828
+ * set during renderNode, consumed by the viewport slice so the cursor can
829
+ * never walk below the fold into rows the terminal isn't showing. */
830
+ let fleetCursorLine = 0;
684
831
  /** Ordered list of node IDs in current rendering (for cursor navigation). */
685
832
  let visibleNodeIds: string[] = [];
686
833
  /** Maps node ID → FleetNode for the currently-rendered tree, so keypress
@@ -919,12 +1066,12 @@ export async function runTui(app: AppContext): Promise<void> {
919
1066
  // Postmortem 2026-05-28 P1 #4: 'cancelled' is a third terminal state
920
1067
  // (zombie reclaim, user cancel). Show it distinctly so the operator
921
1068
  // can tell which subagents ended on a benign cancel vs. a fault.
922
- statusTag = sa.status === 'completed' ? `done ${elapsed}s`
923
- : sa.status === 'cancelled' ? `cancelled ${elapsed}s`
924
- : `failed ${elapsed}s`;
1069
+ statusTag = sa.status === 'completed' ? `done ${fmtElapsed(elapsed)}`
1070
+ : sa.status === 'cancelled' ? `cancelled ${fmtElapsed(elapsed)}`
1071
+ : `failed ${fmtElapsed(elapsed)}`;
925
1072
  } else {
926
1073
  const phase = subagentPhase.get(sa.name) ?? 'sending';
927
- statusTag = `${phase} ${elapsed}s`;
1074
+ statusTag = `${phase} ${fmtElapsed(elapsed)}`;
928
1075
  }
929
1076
  } else if (node.kind === 'fleet-child') {
930
1077
  const fc = fleetMod?.getChildren().get(node.fleetChildName!);
@@ -935,15 +1082,15 @@ export async function runTui(app: AppContext): Promise<void> {
935
1082
  // any agent is busy, surface that phase instead so the header
936
1083
  // reflects what's actually happening.
937
1084
  const active = rollupActivePhase(treeAggregator?.getChildNodes(node.fleetChildName!) ?? []);
938
- statusTag = active ? `${active} ${elapsed}s` : `ready ${elapsed}s`;
1085
+ statusTag = active ? `${active} ${fmtElapsed(elapsed)}` : `ready ${fmtElapsed(elapsed)}`;
939
1086
  } else {
940
- statusTag = fc ? `${fc.status} ${elapsed}s` : 'unknown';
1087
+ statusTag = fc ? `${fc.status} ${fmtElapsed(elapsed)}` : 'unknown';
941
1088
  }
942
1089
  } else {
943
1090
  // fleet-child-agent
944
1091
  const rn = node.reducerNode!;
945
1092
  const elapsed = rn.startedAt ? Math.floor(((rn.completedAt ?? Date.now()) - rn.startedAt) / 1000) : 0;
946
- statusTag = `${rn.phase} ${elapsed}s`;
1093
+ statusTag = `${rn.phase} ${fmtElapsed(elapsed)}`;
947
1094
  }
948
1095
 
949
1096
  // Context size: local maps for researcher/subagent, reducer node for fleet-child-agent.
@@ -954,7 +1101,15 @@ export async function runTui(app: AppContext): Promise<void> {
954
1101
  } else if (node.kind !== 'fleet-child') {
955
1102
  ctxTokens = agentContextTokens.get(node.fullName) ?? agentContextTokens.get(node.name);
956
1103
  }
957
- const ctxStr = ctxTokens ? ` ${fmtK(ctxTokens)}ctx` : '';
1104
+ // Local agents get the gauge form (142k/180k) their budget is readable
1105
+ // from this process. Fleet-child agents live in another process whose
1106
+ // budgets we don't see over the IPC; a bare number is the honest display.
1107
+ const ctxBudget = ctxTokens && node.kind !== 'fleet-child-agent' && node.kind !== 'fleet-child'
1108
+ ? getAgentBudget(node.fullName)
1109
+ : undefined;
1110
+ const ctxStr = ctxTokens
1111
+ ? (ctxBudget ? ` ${fmtK(ctxTokens)}/${fmtK(ctxBudget)}ctx` : ` ${fmtK(ctxTokens)}ctx`)
1112
+ : '';
958
1113
 
959
1114
  // Compression stats (researcher only — we can access the strategy)
960
1115
  let compStr = '';
@@ -977,6 +1132,7 @@ export async function runTui(app: AppContext): Promise<void> {
977
1132
 
978
1133
  // Header line (this is a navigable node)
979
1134
  const isCursor = visibleNodeIds.length === fleetCursor;
1135
+ if (isCursor) fleetCursorLine = lines.length;
980
1136
  const cursor = isCursor ? '→' : ' ';
981
1137
  visibleNodeIds.push(node.name);
982
1138
  visibleNodes.set(node.name, node);
@@ -1081,30 +1237,97 @@ export async function runTui(app: AppContext): Promise<void> {
1081
1237
  if (state.viewMode === 'fleet') updateFleetView();
1082
1238
  }, 6000);
1083
1239
  // Also record it in scrollback so it survives after the notice fades.
1084
- addLine(` ${text}`, RED);
1240
+ addEvent(` ${text}`, RED);
1085
1241
  if (state.viewMode === 'fleet') updateFleetView();
1086
1242
  }
1087
1243
 
1244
+ /** One-line fleet totals: local subagent states, fleet-child processes,
1245
+ * session cost. The at-a-glance row the ops view opens with. */
1246
+ function fleetSummaryLine(): string {
1247
+ let running = 0, done = 0, failed = 0, cancelled = 0;
1248
+ for (const sa of state.subagents) {
1249
+ if (sa.status === 'running') running++;
1250
+ else if (sa.status === 'completed') done++;
1251
+ else if (sa.status === 'cancelled') cancelled++;
1252
+ else failed++;
1253
+ }
1254
+ // Fleet-child agents: count from the reducers, using the same phase
1255
+ // classification the tree paints with. 'idle' is neither running nor
1256
+ // done (a child's root agent idles between rounds), so it's not counted.
1257
+ if (treeAggregator && fleetMod) {
1258
+ for (const childName of fleetMod.getChildren().keys()) {
1259
+ for (const rn of treeAggregator.getChildNodes(childName)) {
1260
+ if (rn.status === 'failed') failed++;
1261
+ else if (rn.phase === 'done') done++;
1262
+ else if (rn.phase === 'cancelled') cancelled++;
1263
+ else if (PHASE_PRIORITY[rn.phase as SubagentPhase] !== undefined) running++;
1264
+ }
1265
+ }
1266
+ }
1267
+ const parts: string[] = [];
1268
+ const counts: string[] = [];
1269
+ if (running > 0) counts.push(`${running} running`);
1270
+ if (done > 0) counts.push(`${done} done`);
1271
+ if (failed > 0) counts.push(`${failed} failed`);
1272
+ if (cancelled > 0) counts.push(`${cancelled} cancelled`);
1273
+ parts.push(counts.length > 0 ? `agents: ${counts.join(' · ')}` : 'agents: none');
1274
+ if (fleetMod) {
1275
+ const children = [...fleetMod.getChildren().values()];
1276
+ if (children.length > 0) {
1277
+ const up = children.filter(c => c.status === 'ready' || c.status === 'starting').length;
1278
+ const crashed = children.filter(c => c.status === 'crashed').length;
1279
+ parts.push(`children: ${up}/${children.length} up${crashed > 0 ? ` (${crashed} crashed)` : ''}`);
1280
+ }
1281
+ }
1282
+ if (state.tokens.cost && state.tokens.cost.total > 0) {
1283
+ parts.push(`Σ $${state.tokens.cost.total.toFixed(state.tokens.cost.total < 1 ? 3 : 2)}`);
1284
+ }
1285
+ return ` ${parts.join(' ')}`;
1286
+ }
1287
+
1088
1288
  function updateFleetView() {
1089
1289
  const tree = buildFleetTree();
1090
1290
  visibleNodeIds = [];
1091
1291
  visibleNodes.clear();
1292
+ fleetCursorLine = 0;
1092
1293
 
1093
- const lines: FleetLine[] = [];
1094
- lines.push({ text: '─── Agent Fleet ─── ↑↓:nav ⏎/→:fold p:peek Del:stop r:restart Esc:chat ───', color: GRAY });
1294
+ // Header block pinned above the scrolling tree region.
1295
+ const header: FleetLine[] = [];
1296
+ header.push({ text: '─── Agent Fleet ─── ↑↓:nav ⏎/→:fold p:peek Del:stop r:restart Esc:chat ───', color: GRAY });
1095
1297
  if (fleetNotice) {
1096
- lines.push({ text: ` ⚠ ${fleetNotice}`, color: RED });
1298
+ header.push({ text: ` ⚠ ${fleetNotice}`, color: RED });
1097
1299
  }
1098
- lines.push({ text: '', color: GRAY });
1300
+ // Active ops alerts, in full — the status bar only has room for a count,
1301
+ // and the firing lines are somewhere back in chat scrollback. This is the
1302
+ // ops view; the ringing klaxons belong at the top of it.
1303
+ const alerts = [...opsAlerts.values()];
1304
+ for (const a of alerts.slice(0, 3)) {
1305
+ const msg = a.message.length > 70 ? a.message.slice(0, 67) + '…' : a.message;
1306
+ header.push({ text: ` ⚠ [${a.agent}] ${a.kind}${a.count > 1 ? ` ×${a.count}` : ''} — ${msg}`, color: RED });
1307
+ }
1308
+ if (alerts.length > 3) {
1309
+ header.push({ text: ` ⚠ … and ${alerts.length - 3} more`, color: RED });
1310
+ }
1311
+ header.push({ text: fleetSummaryLine(), color: GRAY });
1312
+ header.push({ text: '', color: GRAY });
1099
1313
 
1100
- renderNode(tree, 0, lines);
1314
+ const treeLines: FleetLine[] = [];
1315
+ renderNode(tree, 0, treeLines);
1101
1316
 
1102
1317
  // Clamp cursor
1103
1318
  if (fleetCursor >= visibleNodeIds.length) fleetCursor = visibleNodeIds.length - 1;
1104
1319
  if (fleetCursor < 0) fleetCursor = 0;
1105
1320
 
1106
- lines.push({ text: '', color: GRAY });
1107
- lines.push({ text: ' Tab: chat', color: DIM_GRAY });
1321
+ // Viewport: the tree region gets whatever rows the header leaves of the
1322
+ // fleetBox (terminalHeight - 3: status bar, input row, paddingTop). Without
1323
+ // this, a real fleet outgrows the terminal and the cursor walks below the
1324
+ // fold — navigating (and Del:stopping) rows the operator cannot see.
1325
+ const avail = Math.max(5, renderer.terminalHeight - 3 - header.length);
1326
+ const { start, end } = sliceViewport(treeLines.length, fleetCursorLine, avail);
1327
+ const lines: FleetLine[] = [...header];
1328
+ if (start > 0) lines.push({ text: ` ┈ ${start} lines above ┈`, color: DIM_GRAY });
1329
+ lines.push(...treeLines.slice(start, end));
1330
+ if (end < treeLines.length) lines.push({ text: ` ┈ ${treeLines.length - end} lines below ┈`, color: DIM_GRAY });
1108
1331
 
1109
1332
  // Rebuild fleetBox children: clear old, add new per-line renderables
1110
1333
  clearFleetBox();
@@ -1152,7 +1375,7 @@ export async function runTui(app: AppContext): Promise<void> {
1152
1375
  : rn.phase === 'idle' || rn.phase === 'done' || rn.phase === 'cancelled' ? DIM_GRAY
1153
1376
  : PHASE_COLOR[rn.phase as SubagentPhase] ?? CYAN;
1154
1377
  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 });
1378
+ lines.push({ text: ` ${rn.phase} ${fmtElapsed(elapsed)} ${rn.toolCallsCount} tool calls${ctx}`, color: phaseColor });
1156
1379
  if (rn.task) {
1157
1380
  const task = rn.task.length > 70 ? rn.task.slice(0, 67) + '...' : rn.task;
1158
1381
  lines.push({ text: ` task: ${task}`, color: GRAY });
@@ -1165,9 +1388,7 @@ export async function runTui(app: AppContext): Promise<void> {
1165
1388
  }
1166
1389
  } else if (child) {
1167
1390
  const elapsed = Math.floor((Date.now() - child.startedAt) / 1000);
1168
- const min = Math.floor(elapsed / 60);
1169
- const sec = elapsed % 60;
1170
- const timeStr = min > 0 ? `${min}m${sec}s` : `${sec}s`;
1391
+ const timeStr = fmtElapsed(elapsed);
1171
1392
  const statusColor =
1172
1393
  child.status === 'ready' ? CYAN :
1173
1394
  child.status === 'starting' ? YELLOW :
@@ -1400,9 +1621,7 @@ export async function runTui(app: AppContext): Promise<void> {
1400
1621
  // their final runtime, not a clock that keeps counting after done.
1401
1622
  const endTime = sa.completedAt ?? Date.now();
1402
1623
  const elapsed = Math.floor((endTime - sa.startedAt) / 1000);
1403
- const min = Math.floor(elapsed / 60);
1404
- const sec = elapsed % 60;
1405
- const timeStr = min > 0 ? `${min}m${sec}s` : `${sec}s`;
1624
+ const timeStr = fmtElapsed(elapsed);
1406
1625
  const statusColor = sa.status === 'running' ? CYAN : sa.status === 'failed' ? RED : DIM_GRAY;
1407
1626
  lines.push({ text: ` ${sa.status} ${timeStr} ${sa.toolCallsCount} tool calls`, color: statusColor });
1408
1627
 
@@ -1557,12 +1776,13 @@ export async function runTui(app: AppContext): Promise<void> {
1557
1776
  if (agent === rootAgentName) {
1558
1777
  state.status = 'idle';
1559
1778
  state.tool = null;
1779
+ state.toolStartedAt = null;
1560
1780
  if (backgrounded) {
1561
1781
  // Researcher returned from background — show accumulated output as a message
1562
1782
  if (backgroundBuffer.trim()) {
1563
1783
  addLine(backgroundBuffer, WHITE);
1564
1784
  }
1565
- addLine(' (researcher returned from background)', CYAN);
1785
+ addEvent(' (researcher returned from background)', CYAN);
1566
1786
  backgrounded = false;
1567
1787
  backgroundBuffer = '';
1568
1788
  }
@@ -1600,14 +1820,14 @@ export async function runTui(app: AppContext): Promise<void> {
1600
1820
  backgroundBuffer = '';
1601
1821
  }
1602
1822
  if (streaming) endStream();
1603
- addLine(`Error: ${event.error}`, RED);
1823
+ addEvent(`Error: ${event.error}`, RED);
1604
1824
  updateStatus();
1605
1825
  } else {
1606
1826
  if (agent) {
1607
1827
  const short = shortAgentName(agent);
1608
1828
  subagentPhase.set(short, 'failed');
1609
1829
  }
1610
- addLine(`[${agent}] Error: ${event.error}`, DIM_GRAY);
1830
+ addEvent(`[${agent}] Error: ${event.error}`, DIM_GRAY);
1611
1831
  }
1612
1832
  break;
1613
1833
  }
@@ -1638,7 +1858,7 @@ export async function runTui(app: AppContext): Promise<void> {
1638
1858
  state.status = backgrounded ? 'background' : 'tools';
1639
1859
  state.tool = names;
1640
1860
  if (streaming) endStream();
1641
- if (!backgrounded) addLine(`[tools] ${names}`, YELLOW);
1861
+ if (!backgrounded) addEvent(`[tools] ${names}`, YELLOW);
1642
1862
  } else {
1643
1863
  const short = shortAgentName(agent ?? '');
1644
1864
  addLine(` [${short}] ${names}`, DIM_GRAY);
@@ -1670,7 +1890,7 @@ export async function runTui(app: AppContext): Promise<void> {
1670
1890
  const summary = (pe.metadata.eventSummary as string) ?? '';
1671
1891
  const snippet = summary.length > 60 ? summary.slice(0, 57) + '...' : summary;
1672
1892
  const label = subs.join(', ');
1673
- addLine(`\u2691 wake triggered: ${label} \u2014 "${snippet}"`, YELLOW);
1893
+ addEvent(`\u2691 wake triggered: ${label} \u2014 "${snippet}"`, YELLOW);
1674
1894
  }
1675
1895
  break;
1676
1896
  }
@@ -1679,6 +1899,7 @@ export async function runTui(app: AppContext): Promise<void> {
1679
1899
  const tool = event.tool as string;
1680
1900
  if (agent === rootAgentName) {
1681
1901
  state.tool = tool;
1902
+ state.toolStartedAt = Date.now();
1682
1903
  updateStatus();
1683
1904
  }
1684
1905
  // Show file operations in chat
@@ -1705,17 +1926,30 @@ export async function runTui(app: AppContext): Promise<void> {
1705
1926
  break;
1706
1927
  }
1707
1928
 
1708
- case 'tool:completed':
1929
+ case 'tool:completed': {
1930
+ // Root-agent tools used to be fire-and-forget: after "[tools] x, y"
1931
+ // only failures ever printed, so success and stuck looked identical.
1932
+ // Verbose shows every completion; terse only the slow ones (≥2s).
1933
+ if (agent === rootAgentName) {
1934
+ state.toolStartedAt = null;
1935
+ const tool = event.tool as string;
1936
+ const durMs = typeof event.durationMs === 'number' ? event.durationMs : undefined;
1937
+ if (verboseChat || (durMs !== undefined && durMs >= 2000)) {
1938
+ addLine(` ✓ ${tool}${durMs !== undefined ? ` (${(durMs / 1000).toFixed(1)}s)` : ''}`, DIM_GRAY);
1939
+ }
1940
+ }
1709
1941
  break;
1942
+ }
1710
1943
 
1711
1944
  case 'tool:failed': {
1712
1945
  const tool = event.tool as string;
1713
1946
  const error = event.error as string;
1714
1947
  if (agent === rootAgentName) {
1715
- addLine(`[tool error] ${tool}: ${error}`, RED);
1948
+ state.toolStartedAt = null;
1949
+ addEvent(`[tool error] ${tool}: ${error}`, RED);
1716
1950
  } else if (agent) {
1717
1951
  const short = shortAgentName(agent);
1718
- addLine(` [${short}] tool error: ${tool}: ${error}`, RED);
1952
+ addEvent(` [${short}] tool error: ${tool}: ${error}`, RED);
1719
1953
  }
1720
1954
  break;
1721
1955
  }
@@ -1727,13 +1961,15 @@ export async function runTui(app: AppContext): Promise<void> {
1727
1961
  const source = event.source as string;
1728
1962
 
1729
1963
  if (branchEvent === 'switched') {
1730
- addLine(`Branch switched: ${previous ?? '?'} → ${branch} (via ${source})`, CYAN);
1731
1964
  resetBranchState(app.branchState);
1965
+ // Announce AFTER the refresh — refreshFromStore clears the
1966
+ // scrollbox, so a line printed first was destroyed unread.
1732
1967
  refreshFromStore();
1968
+ addEvent(`Branch switched: ${previous ?? '?'} → ${branch} (via ${source})`, CYAN);
1733
1969
  } else if (branchEvent === 'created') {
1734
- addLine(`Branch created: ${branch} (via ${source})`, CYAN);
1970
+ addEvent(`Branch created: ${branch} (via ${source})`, CYAN);
1735
1971
  } else if (branchEvent === 'deleted') {
1736
- addLine(`Branch deleted: ${branch} (via ${source})`, CYAN);
1972
+ addEvent(`Branch deleted: ${branch} (via ${source})`, CYAN);
1737
1973
  }
1738
1974
  updateStatus();
1739
1975
  break;
@@ -1833,6 +2069,22 @@ export async function runTui(app: AppContext): Promise<void> {
1833
2069
  // nicely is in peek-proc's dedicated renderer, not this formatter.
1834
2070
  return null;
1835
2071
  }
2072
+ case 'inference:content_block':
2073
+ case 'inference:usage':
2074
+ case 'usage:updated':
2075
+ // High-frequency bookkeeping events — a `· type` dot line per block/
2076
+ // round is pure noise between the lines that carry meaning.
2077
+ return null;
2078
+ case 'ops:alert': {
2079
+ // The single most important thing a child can say must not fall
2080
+ // through to the dim default-dot rendering.
2081
+ const kind = typeof get('kind') === 'string' ? get('kind') as string : 'unknown';
2082
+ const msg = typeof get('message') === 'string' ? get('message') as string : '';
2083
+ const who = typeof get('agentName') === 'string' ? ` [${get('agentName') as string}]` : '';
2084
+ return kind.endsWith('-clear')
2085
+ ? { text: `✓${who} ${kind}: ${msg}`, color: CYAN }
2086
+ : { text: `⚠${who} ${kind}: ${msg}`, color: RED };
2087
+ }
1836
2088
  case 'tool:started': return { text: ` ⟳ ${get('tool') as string}`, color: YELLOW };
1837
2089
  case 'tool:completed': return { text: ` ✓ ${get('tool') as string} (${get('durationMs') ?? '?'}ms)`, color: CYAN };
1838
2090
  case 'tool:failed': return { text: ` ✗ ${get('tool') as string}: ${get('error') as string}`, color: RED };
@@ -1940,7 +2192,7 @@ export async function runTui(app: AppContext): Promise<void> {
1940
2192
  // of the summary; terse shows a shorter, dimmer line.
1941
2193
  const limit = verboseChat ? 200 : 100;
1942
2194
  const chatTruncated = summary.length > limit ? summary.slice(0, limit - 3) + '...' : summary;
1943
- addLine(` ◀ [${name}] ${chatTruncated}`, verboseChat ? CYAN : DIM_GRAY);
2195
+ addEvent(` ◀ [${name}] ${chatTruncated}`, verboseChat ? CYAN : DIM_GRAY);
1944
2196
  break;
1945
2197
  }
1946
2198
  }
@@ -2001,10 +2253,12 @@ export async function runTui(app: AppContext): Promise<void> {
2001
2253
  for (const sa of state.subagents) {
2002
2254
  subscribeSubagentStream(sa.name);
2003
2255
  }
2004
- updateStatus();
2005
2256
  if (state.viewMode === 'fleet') updateFleetView();
2006
2257
  else if (state.viewMode === 'peek') updatePeekView();
2007
2258
  }
2259
+ // Unconditional: the spinner and the slow-tool elapsed readout repaint on
2260
+ // this tick even when no subagent module is loaded.
2261
+ updateStatus();
2008
2262
  if (fleetMod) {
2009
2263
  // Pick up fleet children that were launched after TUI init so the
2010
2264
  // aggregator can request describe + start folding their event stream.
@@ -2395,7 +2649,7 @@ export async function runTui(app: AppContext): Promise<void> {
2395
2649
  addLine(` (unknown child: ${route.childName})`, RED);
2396
2650
  return;
2397
2651
  }
2398
- addLine(`You → @${route.childName}: ${route.content}`, CYAN);
2652
+ addEvent(`You → @${route.childName}: ${route.content}`, CYAN);
2399
2653
  fleetMod.handleToolCall({
2400
2654
  id: `tui-route-${Date.now()}`,
2401
2655
  name: 'send',
@@ -2407,7 +2661,7 @@ export async function runTui(app: AppContext): Promise<void> {
2407
2661
  });
2408
2662
  return;
2409
2663
  }
2410
- addLine(`You: ${raw}`, GREEN);
2664
+ addEvent(`You: ${raw}`, GREEN);
2411
2665
  const agent = app.framework.getAgent(rootAgentName);
2412
2666
  const agentBusy = agent && (agent.state.status === 'streaming' || agent.state.status === 'inferring' || agent.state.status === 'waiting_for_tools');
2413
2667
  state.status = agentBusy ? 'queued' : 'thinking';
@@ -2465,10 +2719,16 @@ function formatStatusLeft(
2465
2719
  spinnerChar?: string,
2466
2720
  outputTokens?: number,
2467
2721
  alertCount = 0,
2722
+ topAlertKind: string | null = null,
2468
2723
  ): string {
2469
2724
  const sColor = state.status === 'idle' ? '✓' : state.status === 'error' ? '✗' : state.status === 'background' ? '↓' : state.status === 'queued' ? '⏳' : '…';
2470
2725
  let bar = `[${sColor} ${state.status}`;
2471
- if (alertCount > 0) bar += ` | ⚠ ${alertCount} alert${alertCount === 1 ? '' : 's'}`;
2726
+ if (alertCount > 0) {
2727
+ // Name the worst active alert — "⚠ 2 alerts" forces a trip back through
2728
+ // scrollback to learn WHICH klaxon is ringing.
2729
+ const kind = topAlertKind && topAlertKind.length > 26 ? topAlertKind.slice(0, 25) + '…' : topAlertKind;
2730
+ bar += ` | ⚠ ${alertCount}${kind ? ` · ${kind}` : ''}`;
2731
+ }
2472
2732
  if (spinnerChar !== undefined && state.status !== 'idle' && state.status !== 'error' && state.status !== 'background') {
2473
2733
  bar += ` ${spinnerChar}`;
2474
2734
  if (state.status === 'thinking' && outputTokens !== undefined && outputTokens > 0) {
@@ -2481,6 +2741,12 @@ function formatStatusLeft(
2481
2741
  // right status segment (tokens/cost/mem) clean off the row.
2482
2742
  const tool = state.tool.length > 40 ? state.tool.slice(0, 37) + '…' : state.tool;
2483
2743
  bar += ` | ${tool}`;
2744
+ // Slow tool running: show elapsed so "still executing" and "stuck" stop
2745
+ // looking identical. Repainted by the 500ms poll tick.
2746
+ if (state.toolStartedAt) {
2747
+ const secs = Math.floor((Date.now() - state.toolStartedAt) / 1000);
2748
+ if (secs >= 5) bar += ` ${fmtElapsed(secs)}`;
2749
+ }
2484
2750
  }
2485
2751
  const running = state.subagents.filter(s => s.status === 'running').length;
2486
2752
  if (running > 0) {
@@ -2501,12 +2767,17 @@ function formatStatusLeft(
2501
2767
  return bar;
2502
2768
  }
2503
2769
 
2504
- function formatTokens(tokens: TokenUsage, verbose: boolean, ctxTokens = 0): string {
2770
+ function formatTokens(tokens: TokenUsage, verbose: boolean, ctxTokens = 0, ctxBudget?: number): string {
2505
2771
  const parts: string[] = [];
2506
2772
 
2507
2773
  // Current context size first, session totals (Σ) after — two different
2508
- // quantities, two labels.
2509
- if (ctxTokens > 0) parts.push(`ctx:${fmtTokens(ctxTokens)}`);
2774
+ // quantities, two labels. With a known budget the readout is a gauge
2775
+ // (142k/180k), not a trivia number.
2776
+ if (ctxTokens > 0) {
2777
+ parts.push(ctxBudget && ctxBudget > 0
2778
+ ? `ctx:${fmtTokens(ctxTokens)}/${fmtTokens(ctxBudget)}`
2779
+ : `ctx:${fmtTokens(ctxTokens)}`);
2780
+ }
2510
2781
 
2511
2782
  const total = tokens.input + tokens.output;
2512
2783
  if (total > 0) {