@animalabs/connectome-host 0.3.10 → 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.
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;
@@ -91,7 +129,13 @@ interface TuiState {
91
129
  * peek-proc — peek into a child process's live event stream (new)
92
130
  */
93
131
  viewMode: 'chat' | 'fleet' | 'peek' | 'peek-proc';
132
+ /** Session-cumulative usage (usage:updated totals across all agents). */
94
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;
95
139
  peekTarget: string | null;
96
140
  /** Name of the child process being peeked at (peek-proc mode). */
97
141
  peekProcTarget: string | null;
@@ -182,6 +226,7 @@ export async function runTui(app: AppContext): Promise<void> {
182
226
  subagents: [],
183
227
  viewMode: 'chat',
184
228
  tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
229
+ ctxTokens: 0,
185
230
  peekTarget: null,
186
231
  peekProcTarget: null,
187
232
  peekProcAgent: null,
@@ -262,6 +307,16 @@ export async function runTui(app: AppContext): Promise<void> {
262
307
  });
263
308
  let fleetLineCounter = 0;
264
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
+
265
320
  const statusLeft = new TextRenderable(renderer, {
266
321
  id: 'status-left',
267
322
  content: formatStatusLeft(state),
@@ -307,7 +362,11 @@ export async function runTui(app: AppContext): Promise<void> {
307
362
  const INLINE_PASTE_THRESHOLD = 200;
308
363
  const pastedTexts: string[] = [];
309
364
  function formatPastePlaceholder(n: number, text: string): string {
310
- 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, '·');
311
370
  const lines = text.split(/\r?\n/).length;
312
371
  const sizeHint = lines > 1 ? `${text.length}ch, ${lines}L` : `${text.length}ch`;
313
372
  return `[paste #${n}: "${head}…" ${sizeHint}]`;
@@ -343,8 +402,13 @@ export async function runTui(app: AppContext): Promise<void> {
343
402
 
344
403
  // ── Agent observability maps ──────────────────────────────────────
345
404
 
346
- /** 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. */
347
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>();
348
412
 
349
413
  /** Parent tracking: child short name → parent full agent name. */
350
414
  const agentParent = new Map<string, string>();
@@ -356,22 +420,31 @@ export async function runTui(app: AppContext): Promise<void> {
356
420
  const summaryCache = new Map<string, string>();
357
421
  const summarySnapshotLen = new Map<string, number>();
358
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>();
359
428
 
360
429
  const SUMMARY_DELTA = 2000;
361
430
  const SUMMARY_WINDOW = 10_000;
431
+ const SUMMARY_FAILURE_BACKOFF_MS = 30_000;
362
432
 
363
433
  function appendTranscript(agent: string, text: string) {
364
- const prev = agentTranscripts.get(agent) ?? '';
365
- 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);
366
437
  }
367
438
 
368
439
  async function generateSummary(agentName: string) {
369
440
  if (summaryPending.has(agentName)) return;
441
+ if (Date.now() < (summaryBackoffUntil.get(agentName) ?? 0)) return;
370
442
  const transcript = agentTranscripts.get(agentName);
371
443
  if (!transcript || transcript.length < 50) return;
372
444
 
445
+ const totalLen = transcriptTotalLen.get(agentName) ?? 0;
373
446
  const lastLen = summarySnapshotLen.get(agentName) ?? 0;
374
- if (transcript.length - lastLen < SUMMARY_DELTA && summaryCache.has(agentName)) return;
447
+ if (totalLen - lastLen < SUMMARY_DELTA && summaryCache.has(agentName)) return;
375
448
 
376
449
  summaryPending.add(agentName);
377
450
  try {
@@ -389,10 +462,12 @@ export async function runTui(app: AppContext): Promise<void> {
389
462
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
390
463
  .map(b => b.text).join('').trim();
391
464
  summaryCache.set(agentName, text.length > 60 ? text.slice(0, 57) + '...' : text);
392
- summarySnapshotLen.set(agentName, transcript.length);
465
+ summarySnapshotLen.set(agentName, totalLen);
466
+ summaryBackoffUntil.delete(agentName);
393
467
  if (state.viewMode === 'fleet') updateFleetView();
394
468
  } catch {
395
- // best-effort
469
+ // Best-effort display — but never an unthrottled retry loop.
470
+ summaryBackoffUntil.set(agentName, Date.now() + SUMMARY_FAILURE_BACKOFF_MS);
396
471
  } finally {
397
472
  summaryPending.delete(agentName);
398
473
  }
@@ -402,12 +477,29 @@ export async function runTui(app: AppContext): Promise<void> {
402
477
 
403
478
  let messageCounter = 0;
404
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
+
405
496
  function addLine(text: string, color: string = WHITE) {
406
497
  scrollBox.add(new TextRenderable(renderer, {
407
498
  id: `msg-${++messageCounter}`,
408
499
  content: text,
409
500
  fg: color,
410
501
  }));
502
+ pruneScrollback();
411
503
  }
412
504
 
413
505
  function updateStatus() {
@@ -415,7 +507,7 @@ export async function runTui(app: AppContext): Promise<void> {
415
507
  // An active ops alert repaints the whole left segment — a one-cell glyph
416
508
  // in default gray is exactly the kind of signal that gets scrolled past.
417
509
  statusLeft.fg = opsAlerts.size > 0 ? RED : GRAY;
418
- statusRight.content = formatTokens(state.tokens, verboseChat) + formatMemStats(getRootCM());
510
+ statusRight.content = formatTokens(state.tokens, verboseChat, state.ctxTokens) + formatMemStats(getRootCM());
419
511
  }
420
512
 
421
513
  /** Best-effort handle to the root agent's ContextManager, for stats queries. */
@@ -440,6 +532,7 @@ export async function runTui(app: AppContext): Promise<void> {
440
532
  fg: WHITE,
441
533
  });
442
534
  scrollBox.add(currentStreamText);
535
+ pruneScrollback();
443
536
  streaming = true;
444
537
  }
445
538
 
@@ -479,6 +572,7 @@ export async function runTui(app: AppContext): Promise<void> {
479
572
  });
480
573
  }
481
574
  scrollBox.add(currentStreamText);
575
+ pruneScrollback();
482
576
  }
483
577
 
484
578
  function streamToken(text: string) {
@@ -538,17 +632,19 @@ export async function runTui(app: AppContext): Promise<void> {
538
632
  * Clears conversation, reloads messages, restores fleet tree from persisted subagent state.
539
633
  */
540
634
  function refreshFromStore() {
541
- // 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)
542
642
  const children = [...scrollBox.getChildren()];
543
643
  for (const child of children) {
544
644
  scrollBox.remove(child.id);
645
+ child.destroy();
545
646
  }
546
647
  messageCounter = 0;
547
-
548
- // Reset streaming state
549
- streaming = false;
550
- currentStreamText = null;
551
- currentStreamBuffer = '';
552
648
  state.status = 'idle';
553
649
  state.tool = null;
554
650
 
@@ -603,7 +699,8 @@ export async function runTui(app: AppContext): Promise<void> {
603
699
  // Index subagents by short name for tree building
604
700
  const byName = new Map<string, FleetNode>();
605
701
  for (const sa of state.subagents) {
606
- 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;
607
704
  const node: FleetNode = {
608
705
  name: sa.name,
609
706
  fullName,
@@ -619,8 +716,8 @@ export async function runTui(app: AppContext): Promise<void> {
619
716
  const parentFullName = agentParent.get(sa.name);
620
717
  if (parentFullName && parentFullName !== rootAgentName) {
621
718
  // Find the parent's short name
622
- const parentShort = [...byName.keys()].find(k => parentFullName.includes(k));
623
- if (parentShort && byName.has(parentShort)) {
719
+ const parentShort = byName.has(parentFullName) ? parentFullName : shortAgentName(parentFullName);
720
+ if (byName.has(parentShort)) {
624
721
  byName.get(parentShort)!.children.push(byName.get(sa.name)!);
625
722
  continue;
626
723
  }
@@ -887,8 +984,8 @@ export async function runTui(app: AppContext): Promise<void> {
887
984
  // Contextual key hints on the cursor line
888
985
  let hints = '';
889
986
  if (isCursor) {
890
- if (node.kind === 'subagent' && node.agent?.status === 'running') {
891
- 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';
892
989
  } else if (node.kind === 'fleet-child') {
893
990
  const fc = fleetMod?.getChildren().get(node.fleetChildName!);
894
991
  hints = fc?.status === 'ready' ? ' ⏎:fold p:peek Del:stop' : ' ⏎:fold';
@@ -953,7 +1050,7 @@ export async function runTui(app: AppContext): Promise<void> {
953
1050
  const fullName = node.kind === 'fleet-child' || node.kind === 'fleet-child-agent'
954
1051
  ? null
955
1052
  : node.kind === 'researcher' ? rootAgentName
956
- : [...agentTranscripts.keys()].find(k => k.includes(node.name));
1053
+ : [...agentTranscripts.keys()].find(k => k === node.name || shortAgentName(k) === node.name);
957
1054
  if (fullName) {
958
1055
  const summary = summaryCache.get(fullName);
959
1056
  if (summary) {
@@ -961,7 +1058,8 @@ export async function runTui(app: AppContext): Promise<void> {
961
1058
  } else if (summaryPending.has(fullName)) {
962
1059
  lines.push({ text: ` ${detail}┈ …`, color: DIM_GRAY });
963
1060
  }
964
- generateSummary(fullName);
1061
+ // Summary GENERATION is triggered from the poll tick, not here —
1062
+ // rendering must never originate an inference call.
965
1063
  }
966
1064
 
967
1065
  // Recurse into children
@@ -970,13 +1068,33 @@ export async function runTui(app: AppContext): Promise<void> {
970
1068
  }
971
1069
  }
972
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
+
973
1088
  function updateFleetView() {
974
1089
  const tree = buildFleetTree();
975
1090
  visibleNodeIds = [];
976
1091
  visibleNodes.clear();
977
1092
 
978
1093
  const lines: FleetLine[] = [];
979
- 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
+ }
980
1098
  lines.push({ text: '', color: GRAY });
981
1099
 
982
1100
  renderNode(tree, 0, lines);
@@ -989,9 +1107,7 @@ export async function runTui(app: AppContext): Promise<void> {
989
1107
  lines.push({ text: ' Tab: chat', color: DIM_GRAY });
990
1108
 
991
1109
  // Rebuild fleetBox children: clear old, add new per-line renderables
992
- for (const child of [...fleetBox.getChildren()]) {
993
- fleetBox.remove(child.id);
994
- }
1110
+ clearFleetBox();
995
1111
  for (const line of lines) {
996
1112
  fleetBox.add(new TextRenderable(renderer, {
997
1113
  id: `fleet-ln-${++fleetLineCounter}`,
@@ -1069,8 +1185,15 @@ export async function runTui(app: AppContext): Promise<void> {
1069
1185
  lines.push({ text: '', color: GRAY });
1070
1186
 
1071
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);
1072
1191
  if (log && log.length > 0) {
1073
- 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));
1074
1197
  const tail = log.slice(-maxLines);
1075
1198
  if (log.length > maxLines) {
1076
1199
  lines.push({ text: ` ┈ (${log.length - maxLines} lines above)`, color: DIM_GRAY });
@@ -1084,13 +1207,11 @@ export async function runTui(app: AppContext): Promise<void> {
1084
1207
  : ' (no events yet — child may be idle)', color: DIM_GRAY });
1085
1208
  }
1086
1209
 
1087
- // In-progress token line (not yet flushed to log) — shows live streaming output.
1088
- const pending = procPeekTokenLine.get(key);
1089
1210
  if (pending) {
1090
1211
  lines.push({ text: ` ${pending}`, color: WHITE });
1091
1212
  }
1092
1213
 
1093
- for (const boxChild of [...fleetBox.getChildren()]) fleetBox.remove(boxChild.id);
1214
+ clearFleetBox();
1094
1215
  for (const line of lines) {
1095
1216
  fleetBox.add(new TextRenderable(renderer, {
1096
1217
  id: `fleet-ln-${++fleetLineCounter}`,
@@ -1123,19 +1244,28 @@ export async function runTui(app: AppContext): Promise<void> {
1123
1244
  // doesn't have to wait for the next event to see history.
1124
1245
  if (!procPeekLogs.has(key)) procPeekLogs.set(key, []);
1125
1246
  const log = procPeekLogs.get(key)!;
1247
+ const lastEvt = child.events[child.events.length - 1];
1126
1248
  if (log.length === 0) {
1127
1249
  for (const evt of child.events) {
1128
1250
  if (!matches(evt)) continue;
1129
1251
  const formatted = formatWireEvent(evt);
1130
1252
  if (formatted) log.push(formatted);
1131
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);
1132
1260
  }
1261
+ if (lastEvt) procPeekLastEvent.set(key, lastEvt);
1133
1262
 
1134
1263
  // Subscribe for live updates. Handle token events with line merging so
1135
1264
  // streaming output shows up as a natural-looking line buffer rather
1136
1265
  // than one log entry per token.
1137
1266
  if (procPeekUnsub) { procPeekUnsub(); procPeekUnsub = null; }
1138
1267
  procPeekUnsub = fleetMod.onChildEvent(name, (_childName, evt) => {
1268
+ procPeekLastEvent.set(key, evt);
1139
1269
  if (!matches(evt)) return;
1140
1270
  const type = typeof evt.type === 'string' ? evt.type : '';
1141
1271
  const active = state.viewMode === 'peek-proc'
@@ -1200,7 +1330,11 @@ export async function runTui(app: AppContext): Promise<void> {
1200
1330
 
1201
1331
  function appendPeekLog(name: string, text: string, color: string) {
1202
1332
  if (!peekLogs.has(name)) peekLogs.set(name, []);
1203
- 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);
1204
1338
  }
1205
1339
 
1206
1340
  function cleanupPeek() {
@@ -1212,9 +1346,10 @@ export async function runTui(app: AppContext): Promise<void> {
1212
1346
  }
1213
1347
 
1214
1348
  function enterPeek(name: string) {
1215
- // 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.
1216
1351
  const sa = state.subagents.find(s => s.name === name);
1217
- if (!sa || sa.status !== 'running') return;
1352
+ if (!sa) return;
1218
1353
 
1219
1354
  state.viewMode = 'peek';
1220
1355
  state.peekTarget = name;
@@ -1261,7 +1396,10 @@ export async function runTui(app: AppContext): Promise<void> {
1261
1396
 
1262
1397
  const sa = state.subagents.find(s => s.name === name);
1263
1398
  if (sa) {
1264
- 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);
1265
1403
  const min = Math.floor(elapsed / 60);
1266
1404
  const sec = elapsed % 60;
1267
1405
  const timeStr = min > 0 ? `${min}m${sec}s` : `${sec}s`;
@@ -1281,9 +1419,14 @@ export async function runTui(app: AppContext): Promise<void> {
1281
1419
 
1282
1420
  lines.push({ text: '', color: GRAY });
1283
1421
 
1284
- // 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.
1285
1427
  if (log && log.length > 0) {
1286
- const maxLines = Math.max(10, renderer.terminalHeight - 8);
1428
+ const available = renderer.terminalHeight - 3;
1429
+ const maxLines = Math.max(5, available - lines.length - 1);
1287
1430
  const tail = log.slice(-maxLines);
1288
1431
  if (log.length > maxLines) {
1289
1432
  lines.push({ text: ` ┈ (${log.length - maxLines} lines above)`, color: DIM_GRAY });
@@ -1296,9 +1439,7 @@ export async function runTui(app: AppContext): Promise<void> {
1296
1439
  }
1297
1440
 
1298
1441
  // Rebuild fleetBox children
1299
- for (const child of [...fleetBox.getChildren()]) {
1300
- fleetBox.remove(child.id);
1301
- }
1442
+ clearFleetBox();
1302
1443
  for (const line of lines) {
1303
1444
  fleetBox.add(new TextRenderable(renderer, {
1304
1445
  id: `fleet-ln-${++fleetLineCounter}`,
@@ -1373,7 +1514,7 @@ export async function runTui(app: AppContext): Promise<void> {
1373
1514
  if (prev) {
1374
1515
  const delta = Math.ceil(content.length / 4);
1375
1516
  agentContextTokens.set(agent, prev + delta);
1376
- const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1517
+ const short = shortAgentName(agent);
1377
1518
  if (short !== agent) agentContextTokens.set(short, prev + delta);
1378
1519
  }
1379
1520
  }
@@ -1388,16 +1529,16 @@ export async function runTui(app: AppContext): Promise<void> {
1388
1529
  } | undefined;
1389
1530
  if (agent && roundUsage?.input) {
1390
1531
  agentContextTokens.set(agent, roundUsage.input);
1391
- const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1532
+ const short = shortAgentName(agent);
1392
1533
  if (short !== agent) agentContextTokens.set(short, roundUsage.input);
1393
1534
  if (state.viewMode === 'fleet') updateFleetView();
1394
1535
  }
1395
- // Update root-agent token state for status-line display.
1396
- if (agent === rootAgentName && roundUsage) {
1397
- if (roundUsage.input !== undefined) state.tokens.input = roundUsage.input;
1398
- if (roundUsage.output !== undefined) state.tokens.output = (state.tokens.output ?? 0) + (roundUsage.output ?? 0);
1399
- if (roundUsage.cacheRead !== undefined) state.tokens.cacheRead = roundUsage.cacheRead;
1400
- 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;
1401
1542
  updateStatus();
1402
1543
  }
1403
1544
  break;
@@ -1408,8 +1549,9 @@ export async function runTui(app: AppContext): Promise<void> {
1408
1549
  // Track context size per agent (store by both full and short name)
1409
1550
  if (usage && agent && usage.input) {
1410
1551
  agentContextTokens.set(agent, usage.input);
1411
- const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1552
+ const short = shortAgentName(agent);
1412
1553
  if (short !== agent) agentContextTokens.set(short, usage.input);
1554
+ if (agent === rootAgentName) state.ctxTokens = usage.input;
1413
1555
  }
1414
1556
 
1415
1557
  if (agent === rootAgentName) {
@@ -1462,7 +1604,7 @@ export async function runTui(app: AppContext): Promise<void> {
1462
1604
  updateStatus();
1463
1605
  } else {
1464
1606
  if (agent) {
1465
- const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1607
+ const short = shortAgentName(agent);
1466
1608
  subagentPhase.set(short, 'failed');
1467
1609
  }
1468
1610
  addLine(`[${agent}] Error: ${event.error}`, DIM_GRAY);
@@ -1498,9 +1640,9 @@ export async function runTui(app: AppContext): Promise<void> {
1498
1640
  if (streaming) endStream();
1499
1641
  if (!backgrounded) addLine(`[tools] ${names}`, YELLOW);
1500
1642
  } else {
1501
- const short = (agent ?? '').replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '');
1643
+ const short = shortAgentName(agent ?? '');
1502
1644
  addLine(` [${short}] ${names}`, DIM_GRAY);
1503
- const sa = state.subagents.find(s => (agent ?? '').includes(s.name));
1645
+ const sa = state.subagents.find(s => s.name === agent || s.name === short);
1504
1646
  if (sa) {
1505
1647
  sa.toolCallsCount += calls.length;
1506
1648
  sa.statusMessage = names.split('--').pop();
@@ -1542,7 +1684,7 @@ export async function runTui(app: AppContext): Promise<void> {
1542
1684
  // Show file operations in chat
1543
1685
  const toolInput = event.input as Record<string, unknown> | undefined;
1544
1686
  if (toolInput && (agent === rootAgentName || verboseChat)) {
1545
- const short = agent === rootAgentName ? '' : `[${(agent ?? '').replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '')}] `;
1687
+ const short = agent === rootAgentName ? '' : `[${shortAgentName(agent ?? '')}] `;
1546
1688
  if (tool === 'files:write' && toolInput.filePath) {
1547
1689
  const fp = String(toolInput.filePath);
1548
1690
  addLine(` ${short}write ${fp}`, DIM_GRAY);
@@ -1572,7 +1714,7 @@ export async function runTui(app: AppContext): Promise<void> {
1572
1714
  if (agent === rootAgentName) {
1573
1715
  addLine(`[tool error] ${tool}: ${error}`, RED);
1574
1716
  } else if (agent) {
1575
- const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1717
+ const short = shortAgentName(agent);
1576
1718
  addLine(` [${short}] tool error: ${tool}: ${error}`, RED);
1577
1719
  }
1578
1720
  break;
@@ -1608,19 +1750,27 @@ export async function runTui(app: AppContext): Promise<void> {
1608
1750
  // one. Drives the unified subagent-tree rendering: fleet children appear as
1609
1751
  // first-class nodes alongside in-process subagents, with the same readouts
1610
1752
  // (phase, context tokens, tool calls). See UNIFIED-TREE-PLAN.md.
1611
- const treeAggregator = fleetMod ? new FleetTreeAggregator(fleetMod) : null;
1612
- if (treeAggregator) {
1613
- // Register any fleet children that already exist (e.g. autoStart entries
1614
- // brought up before TUI init, or reattached survivors after parent restart).
1615
- for (const childName of fleetMod!.getChildren().keys()) {
1616
- treeAggregator.registerChild(childName);
1617
- }
1618
- // Re-render fleet view when any tracked child's tree changes — gives live
1619
- // updates without polling.
1620
- treeAggregator.onTreeUpdate(() => {
1621
- if (state.viewMode === 'fleet') updateFleetView();
1622
- });
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
+ }
1623
1772
  }
1773
+ initTreeAggregator();
1624
1774
 
1625
1775
  // Fleet-child ops alerts: a quarantine klaxon or refusal streak inside a
1626
1776
  // child process must be as loud here as a local one — the operator is
@@ -1651,6 +1801,9 @@ export async function runTui(app: AppContext): Promise<void> {
1651
1801
  }
1652
1802
  // In-progress token line buffer per child (flushed to log on newline / next event).
1653
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>();
1654
1807
  let procPeekUnsub: (() => void) | null = null;
1655
1808
 
1656
1809
  function appendProcPeekLog(name: string, text: string, color: string): void {
@@ -1782,11 +1935,12 @@ export async function runTui(app: AppContext): Promise<void> {
1782
1935
  agentContextTokens.set(name, event.lastInputTokens);
1783
1936
  }
1784
1937
 
1785
- // Verbose chat display
1786
- if (verboseChat) {
1787
- const chatTruncated = summary.length > 200 ? summary.slice(0, 197) + '...' : summary;
1788
- addLine(` ◀ [${name}] ${chatTruncated}`, CYAN);
1789
- }
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);
1790
1944
  break;
1791
1945
  }
1792
1946
  }
@@ -1799,6 +1953,42 @@ export async function runTui(app: AppContext): Promise<void> {
1799
1953
  });
1800
1954
  subagentStreamUnsubs.push(unsub);
1801
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
+
1802
1992
  const pollTimer = setInterval(() => {
1803
1993
  // Animate spinner when researcher is active (not just on token events)
1804
1994
  if (state.status !== 'idle' && state.status !== 'error') {
@@ -1826,6 +2016,16 @@ export async function runTui(app: AppContext): Promise<void> {
1826
2016
  }
1827
2017
  if (state.viewMode === 'peek-proc') updatePeekProcView();
1828
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
+ }
1829
2029
  }, 500);
1830
2030
 
1831
2031
  // ── Keyboard ───────────────────────────────────────────────────────
@@ -1855,6 +2055,10 @@ export async function runTui(app: AppContext): Promise<void> {
1855
2055
  return;
1856
2056
  }
1857
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;
1858
2062
  cleanup();
1859
2063
  return;
1860
2064
  }
@@ -1866,7 +2070,7 @@ export async function runTui(app: AppContext): Promise<void> {
1866
2070
  // Ctrl+B: push to background — detach any blocking sync subagents and/or
1867
2071
  // background the researcher's current inference (stop displaying tokens,
1868
2072
  // re-enable input; result appears as message when done)
1869
- if (key.ctrl && key.name === 'b' && state.viewMode === 'chat') {
2073
+ if (key.ctrl && key.name === 'b' && (state.viewMode === 'chat' || state.viewMode === 'fleet')) {
1870
2074
  let acted = false;
1871
2075
 
1872
2076
  // 1. Detach any blocking sync subagents
@@ -1939,7 +2143,10 @@ export async function runTui(app: AppContext): Promise<void> {
1939
2143
 
1940
2144
  // Fleet view navigation
1941
2145
  if (state.viewMode === 'fleet') {
1942
- if (key.name === 'up') {
2146
+ if (key.name === 'escape') {
2147
+ switchView('chat');
2148
+ updateStatus();
2149
+ } else if (key.name === 'up') {
1943
2150
  fleetCursor = Math.max(0, fleetCursor - 1);
1944
2151
  updateFleetView();
1945
2152
  } else if (key.name === 'down') {
@@ -1981,9 +2188,14 @@ export async function runTui(app: AppContext): Promise<void> {
1981
2188
  if (node && node.kind !== 'researcher') {
1982
2189
  if (node.kind === 'fleet-child' && fleetMod) {
1983
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.
1984
2193
  fleetMod.handleToolCall({ id: `tui-kill-${Date.now()}`, name: 'kill', input: { name: childName } })
1985
- .then(() => updateFleetView())
1986
- .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)}`));
1987
2199
  } else if (node.kind === 'subagent') {
1988
2200
  const sa = state.subagents.find(s => s.name === nodeId);
1989
2201
  if (sa?.status === 'running' && subMod) {
@@ -2001,8 +2213,11 @@ export async function runTui(app: AppContext): Promise<void> {
2001
2213
  if (node?.kind === 'fleet-child' && fleetMod) {
2002
2214
  const childName = node.fleetChildName!;
2003
2215
  fleetMod.handleToolCall({ id: `tui-restart-${Date.now()}`, name: 'restart', input: { name: childName } })
2004
- .then(() => updateFleetView())
2005
- .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)}`));
2006
2221
  }
2007
2222
  }
2008
2223
  }
@@ -2012,57 +2227,89 @@ export async function runTui(app: AppContext): Promise<void> {
2012
2227
 
2013
2228
  let resolveExit: (() => void) | null = null;
2014
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
+
2015
2253
  function handleSubmit() {
2016
2254
  const raw = ((input as any).plainText as string).trim();
2017
2255
  (input as any).clear();
2018
2256
 
2019
- if (!raw) { pastedTexts.length = 0; return; }
2020
-
2021
- // Expand paste placeholders
2022
- const text = pastedTexts.length > 0
2023
- ? raw.replace(/\[paste #(\d+):[^\]]*\]/g, (m, n) => pastedTexts[parseInt(n, 10) - 1] ?? m)
2024
- : raw;
2025
- pastedTexts.length = 0;
2026
-
2027
- // 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.
2028
2261
  if (state.pendingQuitConfirm) {
2029
2262
  state.pendingQuitConfirm = false;
2030
- const c = text.trim().toLowerCase();
2031
- if (c === 'n' || c === 'no' || c === 'cancel') {
2032
- addLine(' (quit cancelled)', GRAY);
2263
+ const action = resolveQuitConfirm(raw);
2264
+ if (action === 'kill') {
2265
+ addLine(' (stopping children and exiting...)', GRAY);
2266
+ cleanup();
2033
2267
  return;
2034
2268
  }
2035
- if (c === 'd' || c === 'detach') {
2269
+ if (action === 'detach') {
2036
2270
  fleetMod?.setDetachMode(true);
2037
2271
  addLine(' (detaching — children stay running; next parent will adopt them)', CYAN);
2038
2272
  cleanup();
2039
2273
  return;
2040
2274
  }
2041
- // default (y / empty / anything else) → graceful kill + exit
2042
- addLine(' (stopping children and exiting...)', GRAY);
2043
- 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
+ }
2044
2287
  return;
2045
2288
  }
2046
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
+
2047
2298
  if (text.startsWith('/')) {
2048
2299
  const result = handleCommand(text, app);
2049
2300
  if (result.quit) {
2050
- const running = fleetMod
2051
- ? [...fleetMod.getChildren().values()].filter((c) => c.status === 'ready' || c.status === 'starting')
2052
- : [];
2053
- if (running.length > 0) {
2054
- addLine(` ${running.length} child${running.length > 1 ? 'ren' : ''} still running: ${running.map(c => c.name).join(', ')}`, YELLOW);
2055
- addLine(' Stop them before exit? [Y/n/d] — Y=kill gracefully, n=cancel quit, d=detach and leave running', YELLOW);
2056
- state.pendingQuitConfirm = true;
2057
- return;
2058
- }
2301
+ if (promptQuitConfirmIfNeeded()) return;
2059
2302
  cleanup();
2060
2303
  return;
2061
2304
  }
2062
- 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();
2063
2309
  const children = [...scrollBox.getChildren()];
2064
2310
  for (const child of children) {
2065
2311
  scrollBox.remove(child.id);
2312
+ child.destroy();
2066
2313
  }
2067
2314
  } else {
2068
2315
  for (const l of result.lines) {
@@ -2103,12 +2350,14 @@ export async function runTui(app: AppContext): Promise<void> {
2103
2350
  subMod = app.framework.getAllModules().find(m => m.name === 'subagent') as SubagentModule | undefined;
2104
2351
  fleetMod = app.framework.getAllModules().find(m => m.name === 'fleet') as FleetModule | undefined;
2105
2352
  subscribeFleetOps();
2353
+ resetObservabilityState();
2106
2354
  app.framework.onTrace(onTrace as (e: unknown) => void);
2107
2355
 
2108
2356
  const session = app.sessionManager.getActiveSession();
2109
2357
  refreshFromStore();
2110
2358
  addLine(`Session: ${session?.name ?? 'unknown'}`, GRAY);
2111
2359
  state.tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
2360
+ state.ctxTokens = 0;
2112
2361
  // Alerts describe the OLD session's strategy/agents; the new
2113
2362
  // session's own klaxons re-fire within their alarm interval if the
2114
2363
  // condition still holds there. A stale alert with no reachable
@@ -2184,6 +2433,7 @@ export async function runTui(app: AppContext): Promise<void> {
2184
2433
  function cleanup() {
2185
2434
  cleanupPeek();
2186
2435
  cleanupPeekProc();
2436
+ if (fleetNoticeTimer) clearTimeout(fleetNoticeTimer);
2187
2437
  fleetOpsUnsub?.();
2188
2438
  treeAggregator?.dispose();
2189
2439
  for (const unsub of subagentStreamUnsubs) unsub();
@@ -2226,7 +2476,12 @@ function formatStatusLeft(
2226
2476
  bar += ` ${tokStr} tok`;
2227
2477
  }
2228
2478
  }
2229
- 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
+ }
2230
2485
  const running = state.subagents.filter(s => s.status === 'running').length;
2231
2486
  if (running > 0) {
2232
2487
  bar += ` | ${running} sub`;
@@ -2246,12 +2501,16 @@ function formatStatusLeft(
2246
2501
  return bar;
2247
2502
  }
2248
2503
 
2249
- function formatTokens(tokens: TokenUsage, verbose: boolean): string {
2504
+ function formatTokens(tokens: TokenUsage, verbose: boolean, ctxTokens = 0): string {
2250
2505
  const parts: string[] = [];
2251
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
+
2252
2511
  const total = tokens.input + tokens.output;
2253
2512
  if (total > 0) {
2254
- let s = `${fmtTokens(tokens.input)}in ${fmtTokens(tokens.output)}out`;
2513
+ let s = `Σ ${fmtTokens(tokens.input)}in ${fmtTokens(tokens.output)}out`;
2255
2514
  if (tokens.cacheRead > 0) s += ` ${fmtTokens(tokens.cacheRead)}hit`;
2256
2515
  if (tokens.cacheWrite > 0) s += ` ${fmtTokens(tokens.cacheWrite)}write`;
2257
2516
  if (tokens.cost && tokens.cost.total > 0) {