@animalabs/connectome-host 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
package/src/tui.ts ADDED
@@ -0,0 +1,2200 @@
1
+ /**
2
+ * OpenTUI-based terminal interface.
3
+ *
4
+ * Layout (top to bottom):
5
+ * ┌─────────────────────────────┐
6
+ * │ ScrollBox (conversation) │ ← flexGrow, stickyScroll
7
+ * │ └─ TextRenderable per msg │
8
+ * ├─────────────────────────────┤
9
+ * │ Status bar (1 row) │ ← [status | tool | N sub]
10
+ * ├─────────────────────────────┤
11
+ * │ TextareaRenderable │ ← user input (Enter submits, Alt+Enter newline)
12
+ * └─────────────────────────────┘
13
+ *
14
+ * Tab toggles between conversation and agent fleet tree view.
15
+ * Fleet view: interactive tree with expand/collapse (↑↓ navigate, ⏎ toggle).
16
+ */
17
+
18
+ import {
19
+ createCliRenderer,
20
+ type CliRenderer,
21
+ BoxRenderable,
22
+ TextRenderable,
23
+ TextareaRenderable,
24
+ ScrollBoxRenderable,
25
+ bold,
26
+ dim,
27
+ fg,
28
+ decodePasteBytes,
29
+ stripAnsiSequences,
30
+ } from '@opentui/core';
31
+ import { createWriteStream, mkdirSync } from 'node:fs';
32
+ import type { AgentFramework, SessionUsage } from '@animalabs/agent-framework';
33
+ import type { AutobiographicalStrategy } from '@animalabs/context-manager';
34
+ import type { Membrane, NormalizedRequest } from '@animalabs/membrane';
35
+ import type { SubagentModule, ActiveSubagent } from './modules/subagent-module.js';
36
+ import { FleetTreeAggregator } from './state/fleet-tree-aggregator.js';
37
+ import type { AgentNode } from './state/agent-tree-reducer.js';
38
+ import { type FleetModule } from './modules/fleet-module.js';
39
+ import type { WireEvent } from './modules/fleet-types.js';
40
+ import { parseFleetRoute } from './modules/fleet-types.js';
41
+ import { handleCommand, resetBranchState } from './commands.js';
42
+
43
+ /** Format a token count compactly: 1.2M / 3.5k / 42. */
44
+ export function fmtTokens(n: number): string {
45
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
46
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
47
+ return String(n);
48
+ }
49
+
50
+ interface AppContext {
51
+ framework: AgentFramework;
52
+ membrane: Membrane;
53
+ sessionManager: import('./session-manager.js').SessionManager;
54
+ recipe: import('./recipe.js').Recipe;
55
+ branchState: import('./commands.js').BranchState;
56
+ userMessageCount: number;
57
+ switchSession(id: string): Promise<void>;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // State
62
+ // ---------------------------------------------------------------------------
63
+
64
+ interface TokenUsage {
65
+ input: number;
66
+ output: number;
67
+ cacheRead: number;
68
+ cacheWrite: number;
69
+ }
70
+
71
+ interface TuiState {
72
+ status: string;
73
+ tool: string | null;
74
+ subagents: ActiveSubagent[];
75
+ /**
76
+ * chat — conversation + stream
77
+ * fleet — in-process subagent tree (existing)
78
+ * peek — peek into a subagent's live stream (existing)
79
+ * processes — cross-process child fleet (new, FleetModule-backed)
80
+ * peek-proc — peek into a child process's live event stream (new)
81
+ */
82
+ viewMode: 'chat' | 'fleet' | 'peek' | 'peek-proc';
83
+ tokens: TokenUsage;
84
+ peekTarget: string | null;
85
+ /** Name of the child process being peeked at (peek-proc mode). */
86
+ peekProcTarget: string | null;
87
+ /** True while we're waiting for the user to resolve a pending /quit with children still running. */
88
+ pendingQuitConfirm: boolean;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Fleet tree types
93
+ // ---------------------------------------------------------------------------
94
+
95
+ type FleetNodeKind = 'researcher' | 'subagent' | 'fleet-child' | 'fleet-child-agent';
96
+
97
+ interface FleetNode {
98
+ /** Short display name (used as key in expandedNodes / visibleNodeIds). */
99
+ name: string;
100
+ /** Full agent name (for lookups in transcript/token maps). */
101
+ fullName: string;
102
+ /** What this node represents — drives renderer behavior. */
103
+ kind: FleetNodeKind;
104
+ /** ActiveSubagent data — only set for kind='subagent'. */
105
+ agent?: ActiveSubagent;
106
+ /** Reducer node from FleetTreeAggregator — set for kind='fleet-child-agent'. */
107
+ reducerNode?: AgentNode;
108
+ /** Fleet child name — set for kind='fleet-child' (the process header) and inherited
109
+ * by descendants of that header so peek/stop know which child they target. */
110
+ fleetChildName?: string;
111
+ children: FleetNode[];
112
+ }
113
+
114
+ /** A single line in the fleet view with its color. */
115
+ interface FleetLine {
116
+ text: string;
117
+ color: string;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Colours (hex strings for OpenTUI)
122
+ // ---------------------------------------------------------------------------
123
+
124
+ const GREEN = '#00cc00';
125
+ const YELLOW = '#cccc00';
126
+ const CYAN = '#00cccc';
127
+ const MAGENTA = '#cc00cc';
128
+ const RED = '#cc0000';
129
+ const GRAY = '#888888';
130
+ const DIM_GRAY = '#555555';
131
+ const WHITE = '#cccccc';
132
+ const THINKING_DIM = '#8a7aa8';
133
+ const THINKING_PREFIX = '💭 ';
134
+
135
+ /** Block kinds the membrane can route into a stream lane. */
136
+ type StreamBlockType = 'text' | 'thinking' | 'tool_call' | 'tool_result';
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Main
140
+ // ---------------------------------------------------------------------------
141
+
142
+ export async function runTui(app: AppContext): Promise<void> {
143
+ const membrane = app.membrane;
144
+
145
+ // Redirect stderr to a log file — console.error is invisible once the TUI owns the terminal
146
+ const logDir = process.env.DATA_DIR || './data';
147
+ mkdirSync(logDir, { recursive: true });
148
+ const logPath = `${logDir}/tui-error.log`;
149
+ const logStream = createWriteStream(logPath, { flags: 'a' });
150
+ logStream.write(`\n--- session ${new Date().toISOString()} ---\n`);
151
+ const origStderrWrite = process.stderr.write.bind(process.stderr);
152
+ process.stderr.write = ((chunk: string | Uint8Array, ...args: unknown[]) => {
153
+ logStream.write(chunk);
154
+ return true;
155
+ }) as typeof process.stderr.write;
156
+
157
+ const renderer = await createCliRenderer({ exitOnCtrlC: false });
158
+
159
+ // Set terminal title
160
+ const recipeName = app.recipe?.name ?? 'connectome-host';
161
+ const rootAgentName = app.recipe?.agent?.name ?? 'agent';
162
+ process.stdout.write(`\x1b]0;${recipeName}\x07`);
163
+
164
+ const state: TuiState = {
165
+ status: 'idle',
166
+ tool: null,
167
+ subagents: [],
168
+ viewMode: 'chat',
169
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
170
+ peekTarget: null,
171
+ peekProcTarget: null,
172
+ pendingQuitConfirm: false,
173
+ };
174
+
175
+ let streaming = false;
176
+ let currentStreamText: TextRenderable | null = null;
177
+ let backgrounded = false; // researcher pushed to background via Ctrl+B
178
+ let backgroundBuffer = ''; // accumulates tokens while backgrounded
179
+ let currentStreamBuffer = '';
180
+ let verboseChat = false;
181
+
182
+ // Main agent spinner + token counter
183
+ let streamOutputTokens = 0;
184
+ let spinnerFrame = 0;
185
+ const SPINNER = ['·', '.', 'o', 'O'];
186
+
187
+ // Subagent phase tracking
188
+ type SubagentPhase = 'sending' | 'streaming' | 'invoking' | 'executing' | 'done' | 'failed';
189
+ const subagentPhase = new Map<string, SubagentPhase>();
190
+ const PHASE_COLOR: Record<SubagentPhase, string> = {
191
+ sending: YELLOW,
192
+ streaming: CYAN,
193
+ invoking: MAGENTA,
194
+ executing: YELLOW,
195
+ done: DIM_GRAY,
196
+ failed: RED,
197
+ };
198
+
199
+ // ── Layout ────────────────────────────────────────────────────────────
200
+
201
+ const rootBox = new BoxRenderable(renderer, {
202
+ id: 'root',
203
+ flexDirection: 'column',
204
+ width: '100%',
205
+ height: '100%',
206
+ });
207
+
208
+ const scrollBox = new ScrollBoxRenderable(renderer, {
209
+ id: 'conversation',
210
+ flexGrow: 1,
211
+ stickyScroll: true,
212
+ stickyStart: 'bottom',
213
+ });
214
+
215
+ const fleetBox = new BoxRenderable(renderer, {
216
+ id: 'fleet',
217
+ flexGrow: 1,
218
+ flexDirection: 'column',
219
+ paddingLeft: 1,
220
+ paddingTop: 1,
221
+ });
222
+ let fleetLineCounter = 0;
223
+
224
+ const statusLeft = new TextRenderable(renderer, {
225
+ id: 'status-left',
226
+ content: formatStatusLeft(state),
227
+ fg: GRAY,
228
+ });
229
+
230
+ const statusRight = new TextRenderable(renderer, {
231
+ id: 'status-right',
232
+ content: formatTokens(state.tokens, false),
233
+ fg: DIM_GRAY,
234
+ });
235
+
236
+ const statusBox = new BoxRenderable(renderer, {
237
+ id: 'status-box',
238
+ height: 1,
239
+ paddingLeft: 1,
240
+ paddingRight: 1,
241
+ flexDirection: 'row',
242
+ justifyContent: 'space-between',
243
+ });
244
+
245
+ // Multi-line input. Enter submits; Alt+Enter (meta+return) and Ctrl+J
246
+ // (linefeed, default Textarea binding) insert a newline. Shift+Enter is
247
+ // not bound because most terminals don't transmit the shift modifier on
248
+ // Enter without Kitty Keyboard protocol; users who want it can run their
249
+ // terminal's equivalent of Claude Code's /terminal-setup.
250
+ const input = new TextareaRenderable(renderer, {
251
+ id: 'input',
252
+ placeholder: 'Type a message or /help — Alt+Enter for newline',
253
+ wrapMode: 'word',
254
+ keyBindings: [
255
+ { name: 'return', action: 'submit' },
256
+ { name: 'return', meta: true, action: 'newline' },
257
+ ],
258
+ onSubmit: () => { handleSubmit(); },
259
+ });
260
+
261
+ // ── Paste handling ─────────────────────────────────────────────────
262
+ // Short single-line pastes are inlined for visibility. Larger or
263
+ // multi-line pastes get stored out-of-band; an informative placeholder
264
+ // — `[paste #N: "head…" Nch, Mlines]` — appears in the input. The
265
+ // placeholder is expanded back to the original text on submit.
266
+ const INLINE_PASTE_THRESHOLD = 200;
267
+ const pastedTexts: string[] = [];
268
+ function formatPastePlaceholder(n: number, text: string): string {
269
+ const head = text.replace(/\s+/g, ' ').trim().slice(0, 30);
270
+ const lines = text.split(/\r?\n/).length;
271
+ const sizeHint = lines > 1 ? `${text.length}ch, ${lines}L` : `${text.length}ch`;
272
+ return `[paste #${n}: "${head}…" ${sizeHint}]`;
273
+ }
274
+ (input as any).handlePaste = (event: { bytes: Uint8Array }) => {
275
+ const text = stripAnsiSequences(decodePasteBytes(event.bytes));
276
+ if (text.length <= INLINE_PASTE_THRESHOLD && !/[\r\n]/.test(text)) {
277
+ (input as any).insertText(text);
278
+ return;
279
+ }
280
+ pastedTexts.push(text);
281
+ (input as any).insertText(formatPastePlaceholder(pastedTexts.length, text));
282
+ };
283
+
284
+ const inputBox = new BoxRenderable(renderer, {
285
+ id: 'input-box',
286
+ height: 1,
287
+ paddingLeft: 1,
288
+ });
289
+
290
+ // Assembly — both views always present; fleet starts hidden
291
+ statusBox.add(statusLeft);
292
+ statusBox.add(statusRight);
293
+ inputBox.add(input);
294
+ rootBox.add(scrollBox);
295
+ rootBox.add(fleetBox);
296
+ fleetBox.visible = false;
297
+ rootBox.add(statusBox);
298
+ rootBox.add(inputBox);
299
+ renderer.root.add(rootBox);
300
+
301
+ input.focus();
302
+
303
+ // ── Agent observability maps ──────────────────────────────────────
304
+
305
+ /** Accumulated transcript per agent (text output + tool calls). */
306
+ const agentTranscripts = new Map<string, string>();
307
+
308
+ /** Parent tracking: child short name → parent full agent name. */
309
+ const agentParent = new Map<string, string>();
310
+
311
+ /** Last known input token count per agent (= context window size). */
312
+ const agentContextTokens = new Map<string, number>();
313
+
314
+ /** Synesthete summary per agent, keyed by full agent name. */
315
+ const summaryCache = new Map<string, string>();
316
+ const summarySnapshotLen = new Map<string, number>();
317
+ const summaryPending = new Set<string>();
318
+
319
+ const SUMMARY_DELTA = 2000;
320
+ const SUMMARY_WINDOW = 10_000;
321
+
322
+ function appendTranscript(agent: string, text: string) {
323
+ const prev = agentTranscripts.get(agent) ?? '';
324
+ agentTranscripts.set(agent, prev + text);
325
+ }
326
+
327
+ async function generateSummary(agentName: string) {
328
+ if (summaryPending.has(agentName)) return;
329
+ const transcript = agentTranscripts.get(agentName);
330
+ if (!transcript || transcript.length < 50) return;
331
+
332
+ const lastLen = summarySnapshotLen.get(agentName) ?? 0;
333
+ if (transcript.length - lastLen < SUMMARY_DELTA && summaryCache.has(agentName)) return;
334
+
335
+ summaryPending.add(agentName);
336
+ try {
337
+ const window = transcript.slice(-SUMMARY_WINDOW);
338
+ const request: NormalizedRequest = {
339
+ messages: [{
340
+ participant: 'user',
341
+ content: [{ type: 'text', text: `Agent activity stream:\n\n${window}\n\nWhat is this agent doing right now? Answer in 5-10 words.` }],
342
+ }],
343
+ system: 'You distill an agent\'s activity into a terse status phrase. 5-10 words max. No punctuation. Specific, not generic.',
344
+ config: { model: 'claude-haiku-4-5-20251001', maxTokens: 40, temperature: 0.3 },
345
+ };
346
+ const response = await membrane.complete(request);
347
+ const text = response.content
348
+ .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
349
+ .map(b => b.text).join('').trim();
350
+ summaryCache.set(agentName, text.length > 60 ? text.slice(0, 57) + '...' : text);
351
+ summarySnapshotLen.set(agentName, transcript.length);
352
+ if (state.viewMode === 'fleet') updateFleetView();
353
+ } catch {
354
+ // best-effort
355
+ } finally {
356
+ summaryPending.delete(agentName);
357
+ }
358
+ }
359
+
360
+ // ── Helpers ───────────────────────────────────────────────────────────
361
+
362
+ let messageCounter = 0;
363
+
364
+ function addLine(text: string, color: string = WHITE) {
365
+ scrollBox.add(new TextRenderable(renderer, {
366
+ id: `msg-${++messageCounter}`,
367
+ content: text,
368
+ fg: color,
369
+ }));
370
+ }
371
+
372
+ function updateStatus() {
373
+ statusLeft.content = formatStatusLeft(state, SPINNER[spinnerFrame], streamOutputTokens);
374
+ statusRight.content = formatTokens(state.tokens, verboseChat) + formatMemStats(getRootCM());
375
+ }
376
+
377
+ /** Best-effort handle to the root agent's ContextManager, for stats queries. */
378
+ function getRootCM(): { getRenderStats?: () => unknown; getStrategy?: () => { getStats?: () => unknown } } | null {
379
+ try {
380
+ const ag = app.framework.getAgent(rootAgentName);
381
+ if (!ag) return null;
382
+ return ag.getContextManager() as any;
383
+ } catch {
384
+ return null;
385
+ }
386
+ }
387
+
388
+ let currentStreamBlockType: StreamBlockType = 'text';
389
+
390
+ function beginStream() {
391
+ currentStreamBuffer = '';
392
+ currentStreamBlockType = 'text';
393
+ currentStreamText = new TextRenderable(renderer, {
394
+ id: `stream-${++messageCounter}`,
395
+ content: '',
396
+ fg: WHITE,
397
+ });
398
+ scrollBox.add(currentStreamText);
399
+ streaming = true;
400
+ }
401
+
402
+ /**
403
+ * Switch the active stream element to a different block lane. Called from
404
+ * inference:content_block (block_start). If the new block is the same lane
405
+ * as the current one, this is a no-op — Anthropic's stream occasionally
406
+ * splits a single logical "lane" across multiple blocks (e.g. interleaved
407
+ * thinking) and we want them visually contiguous within their kind.
408
+ */
409
+ function switchStreamBlock(blockType: StreamBlockType) {
410
+ if (currentStreamBlockType === blockType && currentStreamText) return;
411
+ // Tool blocks (tool_call / tool_result) aren't rendered as a stream lane
412
+ // here — they're surfaced via the tool:* trace events instead. We still
413
+ // need to update currentStreamBlockType so that when tokens swing back to
414
+ // text or thinking, the next switchStreamBlock() call sees a "different
415
+ // lane than before" and creates a fresh TextRenderable instead of
416
+ // appending to the prior element across a tool sandwich.
417
+ if (blockType !== 'text' && blockType !== 'thinking') {
418
+ currentStreamBlockType = blockType;
419
+ return;
420
+ }
421
+ currentStreamBuffer = '';
422
+ currentStreamBlockType = blockType;
423
+ if (blockType === 'thinking') {
424
+ currentStreamBuffer = THINKING_PREFIX;
425
+ currentStreamText = new TextRenderable(renderer, {
426
+ id: `stream-thinking-${++messageCounter}`,
427
+ content: currentStreamBuffer,
428
+ fg: THINKING_DIM,
429
+ });
430
+ } else {
431
+ currentStreamText = new TextRenderable(renderer, {
432
+ id: `stream-${++messageCounter}`,
433
+ content: '',
434
+ fg: WHITE,
435
+ });
436
+ }
437
+ scrollBox.add(currentStreamText);
438
+ }
439
+
440
+ function streamToken(text: string) {
441
+ if (currentStreamText) {
442
+ currentStreamBuffer += text;
443
+ currentStreamText.content = currentStreamBuffer;
444
+ }
445
+ }
446
+
447
+ function endStream() {
448
+ streaming = false;
449
+ currentStreamText = null;
450
+ currentStreamBuffer = '';
451
+ currentStreamBlockType = 'text';
452
+ }
453
+
454
+ function loadSessionHistory() {
455
+ const agent = app.framework.getAgent(rootAgentName);
456
+ if (!agent) return;
457
+ const cm = agent.getContextManager();
458
+ const messages = cm.getAllMessages();
459
+ if (messages.length === 0) return;
460
+
461
+ addLine(`── session history (${messages.length} messages) ──`, DIM_GRAY);
462
+
463
+ for (const msg of messages) {
464
+ const toolNames: string[] = [];
465
+
466
+ for (const block of msg.content) {
467
+ if (block.type === 'text' && (block as { text: string }).text.trim()) {
468
+ if (msg.participant === 'user') {
469
+ addLine(`You: ${(block as { text: string }).text}`, GREEN);
470
+ } else {
471
+ addLine((block as { text: string }).text, WHITE);
472
+ }
473
+ } else if (block.type === 'thinking') {
474
+ const t = (block as { thinking?: string }).thinking;
475
+ if (t && t.trim()) {
476
+ addLine(`${THINKING_PREFIX}${t}`, THINKING_DIM);
477
+ }
478
+ } else if (block.type === 'tool_use') {
479
+ toolNames.push((block as { name: string }).name);
480
+ }
481
+ // skip tool_result blocks
482
+ }
483
+
484
+ if (toolNames.length > 0) {
485
+ addLine(`[tools] ${toolNames.join(', ')}`, YELLOW);
486
+ }
487
+ }
488
+
489
+ addLine(`── end history ──`, DIM_GRAY);
490
+ }
491
+
492
+ /**
493
+ * Rebuild the TUI display from Chronicle state after a branch switch.
494
+ * Clears conversation, reloads messages, restores fleet tree from persisted subagent state.
495
+ */
496
+ function refreshFromStore() {
497
+ // Clear conversation display
498
+ const children = [...scrollBox.getChildren()];
499
+ for (const child of children) {
500
+ scrollBox.remove(child.id);
501
+ }
502
+ messageCounter = 0;
503
+
504
+ // Reset streaming state
505
+ streaming = false;
506
+ currentStreamText = null;
507
+ currentStreamBuffer = '';
508
+ state.status = 'idle';
509
+ state.tool = null;
510
+
511
+ // Reload conversation from Chronicle
512
+ loadSessionHistory();
513
+
514
+ // Restore fleet tree from persisted subagent module state
515
+ if (subMod) {
516
+ subMod.restoreFromStore();
517
+ state.subagents = [...subMod.activeSubagents.values()];
518
+
519
+ // Rebuild TUI-side parent map from persisted data
520
+ agentParent.clear();
521
+ for (const [child, parent] of subMod.parentMap) {
522
+ agentParent.set(child, parent);
523
+ }
524
+ }
525
+
526
+ // Materialize config files from the (possibly new) branch so gate.json stays in sync
527
+ const ws = app.framework.getModule('workspace');
528
+ if (ws && 'materializeMount' in ws) {
529
+ (ws as any).materializeMount('_config').catch(() => {});
530
+ }
531
+
532
+ updateStatus();
533
+ }
534
+
535
+ const fmtK = fmtTokens;
536
+
537
+ // ── Fleet tree view ────────────────────────────────────────────────
538
+
539
+ const expandedNodes = new Set<string>([rootAgentName]);
540
+ /** Tracks fleet-child header IDs we've seen so we only auto-expand once per
541
+ * child (a manual collapse afterward sticks). */
542
+ const seenFleetHeaders = new Set<string>();
543
+ let fleetCursor = 0;
544
+ /** Ordered list of node IDs in current rendering (for cursor navigation). */
545
+ let visibleNodeIds: string[] = [];
546
+ /** Maps node ID → FleetNode for the currently-rendered tree, so keypress
547
+ * handlers can dispatch on `node.kind` (a typed discriminator) rather than
548
+ * re-parsing prefixes off the string ID. Rebuilt each `updateFleetView`. */
549
+ const visibleNodes = new Map<string, FleetNode>();
550
+
551
+ function buildFleetTree(): FleetNode {
552
+ const root: FleetNode = {
553
+ name: rootAgentName,
554
+ fullName: rootAgentName,
555
+ kind: 'researcher',
556
+ children: [],
557
+ };
558
+
559
+ // Index subagents by short name for tree building
560
+ const byName = new Map<string, FleetNode>();
561
+ for (const sa of state.subagents) {
562
+ const fullName = [...(subMod?.activeSubagents.keys() ?? [])].find(k => k.includes(sa.name)) ?? sa.name;
563
+ const node: FleetNode = {
564
+ name: sa.name,
565
+ fullName,
566
+ kind: 'subagent',
567
+ agent: sa,
568
+ children: [],
569
+ };
570
+ byName.set(sa.name, node);
571
+ }
572
+
573
+ // Build parent-child links
574
+ for (const sa of state.subagents) {
575
+ const parentFullName = agentParent.get(sa.name);
576
+ if (parentFullName && parentFullName !== rootAgentName) {
577
+ // Find the parent's short name
578
+ const parentShort = [...byName.keys()].find(k => parentFullName.includes(k));
579
+ if (parentShort && byName.has(parentShort)) {
580
+ byName.get(parentShort)!.children.push(byName.get(sa.name)!);
581
+ continue;
582
+ }
583
+ }
584
+ // Default: child of researcher
585
+ root.children.push(byName.get(sa.name)!);
586
+ }
587
+
588
+ // Sort children: running on top, then by startedAt ascending (stable reading order)
589
+ const sortChildren = (children: FleetNode[]) => {
590
+ children.sort((a, b) => {
591
+ const aRunning = a.agent?.status === 'running' ? 0 : 1;
592
+ const bRunning = b.agent?.status === 'running' ? 0 : 1;
593
+ if (aRunning !== bRunning) return aRunning - bRunning;
594
+ return (a.agent?.startedAt ?? 0) - (b.agent?.startedAt ?? 0);
595
+ });
596
+ for (const child of children) {
597
+ if (child.children.length > 0) sortChildren(child.children);
598
+ }
599
+ };
600
+ sortChildren(root.children);
601
+
602
+ // Append fleet children as additional descendants of the researcher root.
603
+ // Each fleet-child header carries a subtree built from its AgentTreeReducer:
604
+ // top-level framework agents become direct children of the header, with
605
+ // any subagents they spawned (visible via their parent edge) nested below.
606
+ if (treeAggregator && fleetMod) {
607
+ for (const fc of fleetMod.getChildren().values()) {
608
+ const reducerNodes = treeAggregator.getChildNodes(fc.name);
609
+ const childTreeRoots: FleetNode[] = reducerNodes
610
+ .filter(n => n.parent === undefined)
611
+ .map(n => buildAggregatorSubtree(n, reducerNodes, fc.name));
612
+ const headerKey = `proc:${fc.name}`;
613
+ // Auto-expand a fleet-child header the first time it's rendered so the
614
+ // user sees its agents without an extra keystroke. Subsequent toggles
615
+ // (manual collapse / re-expand) are persisted via expandedNodes as
616
+ // usual; we only seed once per header name.
617
+ if (!seenFleetHeaders.has(headerKey)) {
618
+ seenFleetHeaders.add(headerKey);
619
+ expandedNodes.add(headerKey);
620
+ }
621
+ const headerNode: FleetNode = {
622
+ name: headerKey,
623
+ fullName: fc.name,
624
+ kind: 'fleet-child',
625
+ fleetChildName: fc.name,
626
+ children: childTreeRoots,
627
+ };
628
+ root.children.push(headerNode);
629
+ }
630
+ }
631
+
632
+ return root;
633
+ }
634
+
635
+ /** Build a FleetNode subtree from an AgentTreeReducer node and its descendants. */
636
+ function buildAggregatorSubtree(
637
+ rootReducerNode: AgentNode,
638
+ allNodes: AgentNode[],
639
+ fleetChildName: string,
640
+ ): FleetNode {
641
+ const node: FleetNode = {
642
+ name: `${fleetChildName}:${rootReducerNode.name}`,
643
+ fullName: rootReducerNode.name,
644
+ kind: 'fleet-child-agent',
645
+ reducerNode: rootReducerNode,
646
+ fleetChildName,
647
+ children: allNodes
648
+ .filter(n => n.parent === rootReducerNode.name)
649
+ .map(child => buildAggregatorSubtree(child, allNodes, fleetChildName)),
650
+ };
651
+ return node;
652
+ }
653
+
654
+ /** Priority order for "which active phase is most visible." Higher = more
655
+ * user-attention-worthy. Quiescent phases (done/idle/failed) are absent
656
+ * on purpose: rollups represent *current* work. */
657
+ const PHASE_PRIORITY: Partial<Record<SubagentPhase, number>> = {
658
+ streaming: 5,
659
+ invoking: 4,
660
+ executing: 3,
661
+ sending: 2,
662
+ };
663
+
664
+ /** Pick the busiest active phase from a sequence of phases. Returns null
665
+ * if none qualify. */
666
+ function pickBusiest(phases: Iterable<SubagentPhase | undefined>): SubagentPhase | null {
667
+ let best: SubagentPhase | null = null;
668
+ let bestScore = -1;
669
+ for (const phase of phases) {
670
+ if (phase === undefined) continue;
671
+ const score = PHASE_PRIORITY[phase];
672
+ if (score !== undefined && score > bestScore) {
673
+ best = phase;
674
+ bestScore = score;
675
+ }
676
+ }
677
+ return best;
678
+ }
679
+
680
+ /** Pick the busiest active phase across a list of reducer nodes. */
681
+ function rollupActivePhase(nodes: AgentNode[]): SubagentPhase | null {
682
+ return pickBusiest(nodes.map(n => n.phase as SubagentPhase));
683
+ }
684
+
685
+ /** "Is anything underneath the researcher busy?" Aggregates across local
686
+ * subagents + every fleet child's reducer. Used so the researcher header
687
+ * shows activity even when the researcher's own inference is idle. */
688
+ function anyDescendantActive(): SubagentPhase | null {
689
+ const phases: Array<SubagentPhase | undefined> = [];
690
+ for (const sa of state.subagents) {
691
+ if (sa.status === 'running') {
692
+ phases.push(subagentPhase.get(sa.name) ?? 'sending');
693
+ }
694
+ }
695
+ if (treeAggregator && fleetMod) {
696
+ for (const childName of fleetMod.getChildren().keys()) {
697
+ const phase = rollupActivePhase(treeAggregator.getChildNodes(childName));
698
+ if (phase) phases.push(phase);
699
+ }
700
+ }
701
+ return pickBusiest(phases);
702
+ }
703
+
704
+ function renderNode(node: FleetNode, depth: number, lines: FleetLine[]): void {
705
+ const indent = ' '.repeat(depth);
706
+ const isExpanded = expandedNodes.has(node.name);
707
+ const hasChildren = node.children.length > 0;
708
+
709
+ // Determine node color based on status
710
+ let nodeColor: string;
711
+ if (node.kind === 'researcher') {
712
+ // Researcher color: own activity wins; otherwise reflect descendants.
713
+ if (state.status !== 'idle' && state.status !== 'error') {
714
+ nodeColor = WHITE;
715
+ } else {
716
+ const descendant = anyDescendantActive();
717
+ nodeColor = state.status === 'error' ? RED
718
+ : descendant ? PHASE_COLOR[descendant]
719
+ : GRAY;
720
+ }
721
+ } else if (node.kind === 'subagent') {
722
+ const sa = node.agent!;
723
+ if (sa.status === 'running') {
724
+ const phase = subagentPhase.get(sa.name) ?? 'sending';
725
+ nodeColor = PHASE_COLOR[phase];
726
+ } else {
727
+ nodeColor = sa.status === 'failed' ? RED : DIM_GRAY;
728
+ }
729
+ } else if (node.kind === 'fleet-child') {
730
+ const fc = fleetMod?.getChildren().get(node.fleetChildName!);
731
+ if (fc?.status === 'ready') {
732
+ // Process is alive — surface the busiest agent inside it instead of
733
+ // a flat "ready", so the user can see *what* the child is doing without
734
+ // having to unfold and inspect each agent.
735
+ const active = rollupActivePhase(treeAggregator?.getChildNodes(node.fleetChildName!) ?? []);
736
+ nodeColor = active ? PHASE_COLOR[active] : CYAN;
737
+ } else {
738
+ nodeColor = fc?.status === 'starting' ? YELLOW
739
+ : fc?.status === 'crashed' ? RED
740
+ : DIM_GRAY;
741
+ }
742
+ } else {
743
+ // fleet-child-agent
744
+ const rn = node.reducerNode!;
745
+ // Postmortem 2026-05-28 P1 #3: 'cancelled' = benign termination
746
+ // (user cancel, zombie-reclaim, supersession, budget restart). Must
747
+ // not paint red — that was the visible "failed labels" symptom that
748
+ // drove the operator to file the postmortem.
749
+ if (rn.status === 'failed') nodeColor = RED;
750
+ else if (rn.phase === 'idle' || rn.phase === 'done' || rn.phase === 'cancelled') nodeColor = DIM_GRAY;
751
+ else nodeColor = PHASE_COLOR[rn.phase as SubagentPhase] ?? GRAY;
752
+ }
753
+
754
+ // Dimmer variant for detail/child lines
755
+ const detailColor = node.kind === 'researcher'
756
+ ? (state.status === 'idle' ? DIM_GRAY : GRAY)
757
+ : (node.kind === 'subagent' && node.agent?.status === 'running' ? GRAY : DIM_GRAY);
758
+
759
+ // Status tag
760
+ let statusTag: string;
761
+ if (node.kind === 'researcher') {
762
+ if (state.status === 'error') {
763
+ statusTag = '✗ error';
764
+ } else if (state.status !== 'idle') {
765
+ statusTag = `… ${state.status}`;
766
+ } else {
767
+ // Researcher's own inference is idle — but if anything underneath
768
+ // (local subagent or fleet child) is active, surface that so the
769
+ // header doesn't lie about an "idle" tree where work is happening.
770
+ const descendant = anyDescendantActive();
771
+ statusTag = descendant ? `… ${descendant} (descendant)` : '✓ idle';
772
+ }
773
+ } else if (node.kind === 'subagent') {
774
+ const sa = node.agent!;
775
+ const endTime = sa.completedAt ?? Date.now();
776
+ const elapsed = Math.floor((endTime - sa.startedAt) / 1000);
777
+ if (sa.status !== 'running') {
778
+ // Postmortem 2026-05-28 P1 #4: 'cancelled' is a third terminal state
779
+ // (zombie reclaim, user cancel). Show it distinctly so the operator
780
+ // can tell which subagents ended on a benign cancel vs. a fault.
781
+ statusTag = sa.status === 'completed' ? `done ${elapsed}s`
782
+ : sa.status === 'cancelled' ? `cancelled ${elapsed}s`
783
+ : `failed ${elapsed}s`;
784
+ } else {
785
+ const phase = subagentPhase.get(sa.name) ?? 'sending';
786
+ statusTag = `${phase} ${elapsed}s`;
787
+ }
788
+ } else if (node.kind === 'fleet-child') {
789
+ const fc = fleetMod?.getChildren().get(node.fleetChildName!);
790
+ const elapsed = fc ? Math.floor((Date.now() - fc.startedAt) / 1000) : 0;
791
+ if (fc?.status === 'ready') {
792
+ // Roll up agent activity from inside the child. Lifecycle 'ready' is
793
+ // the boring case ("process alive, doing nothing right now"); when
794
+ // any agent is busy, surface that phase instead so the header
795
+ // reflects what's actually happening.
796
+ const active = rollupActivePhase(treeAggregator?.getChildNodes(node.fleetChildName!) ?? []);
797
+ statusTag = active ? `${active} ${elapsed}s` : `ready ${elapsed}s`;
798
+ } else {
799
+ statusTag = fc ? `${fc.status} ${elapsed}s` : 'unknown';
800
+ }
801
+ } else {
802
+ // fleet-child-agent
803
+ const rn = node.reducerNode!;
804
+ const elapsed = rn.startedAt ? Math.floor(((rn.completedAt ?? Date.now()) - rn.startedAt) / 1000) : 0;
805
+ statusTag = `${rn.phase} ${elapsed}s`;
806
+ }
807
+
808
+ // Context size: local maps for researcher/subagent, reducer node for fleet-child-agent.
809
+ let ctxTokens: number | undefined;
810
+ if (node.kind === 'fleet-child-agent') {
811
+ const v = node.reducerNode?.tokens.input;
812
+ if (typeof v === 'number' && v > 0) ctxTokens = v;
813
+ } else if (node.kind !== 'fleet-child') {
814
+ ctxTokens = agentContextTokens.get(node.fullName) ?? agentContextTokens.get(node.name);
815
+ }
816
+ const ctxStr = ctxTokens ? ` ${fmtK(ctxTokens)}ctx` : '';
817
+
818
+ // Compression stats (researcher only — we can access the strategy)
819
+ let compStr = '';
820
+ if (node.kind === 'researcher') {
821
+ try {
822
+ const agent = app.framework.getAgent(rootAgentName);
823
+ const cm = agent?.getContextManager();
824
+ const strategy = (cm as any)?.strategy as AutobiographicalStrategy | undefined;
825
+ if (strategy?.getStats) {
826
+ const stats = strategy.getStats();
827
+ if (stats.compressionCount > 0) {
828
+ compStr = ` ${stats.compressionCount}comp`;
829
+ }
830
+ }
831
+ } catch { /* best-effort */ }
832
+ }
833
+
834
+ // Fold marker
835
+ const marker = hasChildren ? (isExpanded ? '▼' : '►') : '─';
836
+
837
+ // Header line (this is a navigable node)
838
+ const isCursor = visibleNodeIds.length === fleetCursor;
839
+ const cursor = isCursor ? '→' : ' ';
840
+ visibleNodeIds.push(node.name);
841
+ visibleNodes.set(node.name, node);
842
+
843
+ // Contextual key hints on the cursor line
844
+ let hints = '';
845
+ if (isCursor) {
846
+ if (node.kind === 'subagent' && node.agent?.status === 'running') {
847
+ hints = ' ⏎:fold p:peek Del:stop';
848
+ } else if (node.kind === 'fleet-child') {
849
+ const fc = fleetMod?.getChildren().get(node.fleetChildName!);
850
+ hints = fc?.status === 'ready' ? ' ⏎:fold p:peek Del:stop' : ' ⏎:fold';
851
+ } 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';
855
+ } else {
856
+ hints = ' ⏎:fold';
857
+ }
858
+ }
859
+
860
+ // Display name: strip the namespace prefix added in buildAggregatorSubtree
861
+ // so fleet-child agents read as their bare names ('commander', not 'miner:commander').
862
+ const displayName = node.kind === 'fleet-child-agent' ? node.fullName
863
+ : node.kind === 'fleet-child' ? `▣ ${node.fleetChildName}`
864
+ : node.name;
865
+
866
+ lines.push({
867
+ text: `${cursor} ${indent}${marker} ${displayName} [${statusTag}]${ctxStr}${compStr}${hints}`,
868
+ color: nodeColor,
869
+ });
870
+
871
+ if (!isExpanded) return;
872
+
873
+ // Detail lines (indented further)
874
+ const detail = indent + ' ';
875
+
876
+ if (node.kind === 'researcher' && state.tool) {
877
+ lines.push({ text: ` ${detail}tool: ${state.tool}`, color: detailColor });
878
+ }
879
+ if (node.kind === 'subagent' && node.agent) {
880
+ const sa = node.agent;
881
+ // Truncate task to 60 chars
882
+ const task = sa.task.length > 60 ? sa.task.slice(0, 57) + '...' : sa.task;
883
+ lines.push({ text: ` ${detail}task: ${task}`, color: detailColor });
884
+ if (sa.statusMessage) {
885
+ lines.push({ text: ` ${detail}tool: ${sa.statusMessage} (${sa.toolCallsCount} calls)`, color: detailColor });
886
+ }
887
+ }
888
+ if (node.kind === 'fleet-child') {
889
+ const fc = fleetMod?.getChildren().get(node.fleetChildName!);
890
+ if (fc) {
891
+ lines.push({ text: ` ${detail}pid: ${fc.pid ?? '-'} events: ${fc.events.length}`, color: detailColor });
892
+ if (fc.exitReason && fc.status !== 'ready' && fc.status !== 'starting') {
893
+ lines.push({ text: ` ${detail}${fc.exitReason}`, color: detailColor });
894
+ }
895
+ }
896
+ }
897
+ if (node.kind === 'fleet-child-agent' && node.reducerNode) {
898
+ const rn = node.reducerNode;
899
+ if (rn.toolCallsCount > 0) {
900
+ lines.push({ text: ` ${detail}tools: ${rn.toolCallsCount} calls`, color: detailColor });
901
+ }
902
+ if (rn.task) {
903
+ const task = rn.task.length > 60 ? rn.task.slice(0, 57) + '...' : rn.task;
904
+ lines.push({ text: ` ${detail}task: ${task}`, color: detailColor });
905
+ }
906
+ }
907
+
908
+ // Synesthete summary — only meaningful for nodes whose transcripts we own
909
+ // locally (researcher + local subagents). Fleet-child agents transcribe to
910
+ // their own process; we don't have their text here.
911
+ const fullName = node.kind === 'fleet-child' || node.kind === 'fleet-child-agent'
912
+ ? null
913
+ : node.kind === 'researcher' ? rootAgentName
914
+ : [...agentTranscripts.keys()].find(k => k.includes(node.name));
915
+ if (fullName) {
916
+ const summary = summaryCache.get(fullName);
917
+ if (summary) {
918
+ lines.push({ text: ` ${detail}┈ ${summary}`, color: DIM_GRAY });
919
+ } else if (summaryPending.has(fullName)) {
920
+ lines.push({ text: ` ${detail}┈ …`, color: DIM_GRAY });
921
+ }
922
+ generateSummary(fullName);
923
+ }
924
+
925
+ // Recurse into children
926
+ for (const child of node.children) {
927
+ renderNode(child, depth + 1, lines);
928
+ }
929
+ }
930
+
931
+ function updateFleetView() {
932
+ const tree = buildFleetTree();
933
+ visibleNodeIds = [];
934
+ visibleNodes.clear();
935
+
936
+ const lines: FleetLine[] = [];
937
+ lines.push({ text: '─── Agent Fleet ─── ↑↓:nav ⏎/→:fold p:peek Del:stop r:restart ───', color: GRAY });
938
+ lines.push({ text: '', color: GRAY });
939
+
940
+ renderNode(tree, 0, lines);
941
+
942
+ // Clamp cursor
943
+ if (fleetCursor >= visibleNodeIds.length) fleetCursor = visibleNodeIds.length - 1;
944
+ if (fleetCursor < 0) fleetCursor = 0;
945
+
946
+ lines.push({ text: '', color: GRAY });
947
+ lines.push({ text: ' Tab: chat', color: DIM_GRAY });
948
+
949
+ // Rebuild fleetBox children: clear old, add new per-line renderables
950
+ for (const child of [...fleetBox.getChildren()]) {
951
+ fleetBox.remove(child.id);
952
+ }
953
+ for (const line of lines) {
954
+ fleetBox.add(new TextRenderable(renderer, {
955
+ id: `fleet-ln-${++fleetLineCounter}`,
956
+ content: line.text,
957
+ fg: line.color,
958
+ }));
959
+ }
960
+ }
961
+
962
+ function switchView(mode: 'chat' | 'fleet' | 'peek' | 'peek-proc') {
963
+ state.viewMode = mode;
964
+ scrollBox.visible = mode === 'chat';
965
+ fleetBox.visible = mode !== 'chat';
966
+ if (mode === 'chat') {
967
+ input.focus();
968
+ } else {
969
+ input.blur();
970
+ if (mode === 'fleet') updateFleetView();
971
+ else if (mode === 'peek-proc') updatePeekProcView();
972
+ }
973
+ }
974
+
975
+ function updatePeekProcView(): void {
976
+ const name = state.peekProcTarget;
977
+ if (!name || !fleetMod) return;
978
+ const child = fleetMod.getChildren().get(name);
979
+
980
+ const lines: FleetLine[] = [];
981
+ lines.push({ text: `─── Peek proc: ${name} ──────────────── Esc:back ───`, color: GRAY });
982
+ lines.push({ text: '', color: GRAY });
983
+
984
+ if (child) {
985
+ const elapsed = Math.floor((Date.now() - child.startedAt) / 1000);
986
+ const min = Math.floor(elapsed / 60);
987
+ const sec = elapsed % 60;
988
+ const timeStr = min > 0 ? `${min}m${sec}s` : `${sec}s`;
989
+ const statusColor =
990
+ child.status === 'ready' ? CYAN :
991
+ child.status === 'starting' ? YELLOW :
992
+ child.status === 'crashed' ? RED :
993
+ DIM_GRAY;
994
+ lines.push({
995
+ text: ` ${child.status} ${timeStr} pid=${child.pid ?? '-'} events=${child.events.length}`,
996
+ color: statusColor,
997
+ });
998
+ lines.push({ text: ` recipe: ${child.recipePath}`, color: GRAY });
999
+ } else {
1000
+ lines.push({ text: ' (child not found)', color: RED });
1001
+ }
1002
+
1003
+ lines.push({ text: '', color: GRAY });
1004
+
1005
+ const log = procPeekLogs.get(name);
1006
+ if (log && log.length > 0) {
1007
+ const maxLines = Math.max(10, renderer.terminalHeight - 10);
1008
+ const tail = log.slice(-maxLines);
1009
+ if (log.length > maxLines) {
1010
+ lines.push({ text: ` ┈ (${log.length - maxLines} lines above)`, color: DIM_GRAY });
1011
+ }
1012
+ for (const entry of tail) {
1013
+ lines.push({ text: ` ${entry.text}`, color: entry.color });
1014
+ }
1015
+ } else {
1016
+ lines.push({ text: ' (no events yet — child may be idle)', color: DIM_GRAY });
1017
+ }
1018
+
1019
+ // In-progress token line (not yet flushed to log) — shows live streaming output.
1020
+ const pending = procPeekTokenLine.get(name);
1021
+ if (pending) {
1022
+ lines.push({ text: ` ${pending}`, color: WHITE });
1023
+ }
1024
+
1025
+ for (const boxChild of [...fleetBox.getChildren()]) fleetBox.remove(boxChild.id);
1026
+ for (const line of lines) {
1027
+ fleetBox.add(new TextRenderable(renderer, {
1028
+ id: `fleet-ln-${++fleetLineCounter}`,
1029
+ content: line.text,
1030
+ fg: line.color,
1031
+ }));
1032
+ }
1033
+ }
1034
+
1035
+ function enterPeekProc(name: string): void {
1036
+ if (!fleetMod) return;
1037
+ const child = fleetMod.getChildren().get(name);
1038
+ if (!child) return;
1039
+
1040
+ state.peekProcTarget = name;
1041
+
1042
+ // Seed the log from the child's existing event buffer so the user
1043
+ // 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)!;
1046
+ if (log.length === 0) {
1047
+ for (const evt of child.events) {
1048
+ const formatted = formatWireEvent(evt);
1049
+ if (formatted) log.push(formatted);
1050
+ }
1051
+ }
1052
+
1053
+ // Subscribe for live updates. Handle token events with line merging so
1054
+ // streaming output shows up as a natural-looking line buffer rather
1055
+ // than one log entry per token.
1056
+ if (procPeekUnsub) { procPeekUnsub(); procPeekUnsub = null; }
1057
+ procPeekUnsub = fleetMod.onChildEvent(name, (_childName, evt) => {
1058
+ const type = typeof evt.type === 'string' ? evt.type : '';
1059
+
1060
+ if (type === 'inference:tokens') {
1061
+ const content = (evt as { content?: string }).content ?? '';
1062
+ if (!content) return;
1063
+ const prev = procPeekTokenLine.get(name) ?? '';
1064
+ const merged = prev + content;
1065
+ const parts = merged.split('\n');
1066
+ // Flush completed lines (everything except the last segment).
1067
+ 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();
1073
+ }
1074
+ return;
1075
+ }
1076
+
1077
+ // Non-token event: flush any pending token line first so its text
1078
+ // doesn't get visually chopped by subsequent log entries.
1079
+ const pending = procPeekTokenLine.get(name);
1080
+ if (pending?.trim()) {
1081
+ appendProcPeekLog(name, pending, WHITE);
1082
+ }
1083
+ procPeekTokenLine.delete(name);
1084
+
1085
+ const formatted = formatWireEvent(evt);
1086
+ if (!formatted) return;
1087
+ appendProcPeekLog(name, formatted.text, formatted.color);
1088
+ if (state.viewMode === 'peek-proc' && state.peekProcTarget === name) {
1089
+ updatePeekProcView();
1090
+ }
1091
+ });
1092
+
1093
+ switchView('peek-proc');
1094
+ }
1095
+
1096
+ function cleanupPeekProc(): void {
1097
+ if (procPeekUnsub) { procPeekUnsub(); procPeekUnsub = null; }
1098
+ // Flush any in-progress token line into the log before clearing it so
1099
+ // re-entering peek-proc doesn't lose the last few tokens mid-stream.
1100
+ if (state.peekProcTarget) {
1101
+ const pending = procPeekTokenLine.get(state.peekProcTarget);
1102
+ if (pending?.trim()) {
1103
+ appendProcPeekLog(state.peekProcTarget, pending, WHITE);
1104
+ }
1105
+ procPeekTokenLine.delete(state.peekProcTarget);
1106
+ }
1107
+ state.peekProcTarget = null;
1108
+ }
1109
+
1110
+ // ── Peek view ────────────────────────────────────────────────────────
1111
+
1112
+ /** Accumulated event log per agent (keyed by display name). */
1113
+ const peekLogs = new Map<string, FleetLine[]>();
1114
+ /** Current in-progress tool per agent (for sticky display). */
1115
+ const peekCurrentTool = new Map<string, string | null>();
1116
+ let peekUnsubscribe: (() => void) | null = null;
1117
+
1118
+ function appendPeekLog(name: string, text: string, color: string) {
1119
+ if (!peekLogs.has(name)) peekLogs.set(name, []);
1120
+ peekLogs.get(name)!.push({ text, color });
1121
+ }
1122
+
1123
+ function cleanupPeek() {
1124
+ if (peekUnsubscribe) {
1125
+ peekUnsubscribe();
1126
+ peekUnsubscribe = null;
1127
+ }
1128
+ state.peekTarget = null;
1129
+ }
1130
+
1131
+ function enterPeek(name: string) {
1132
+ // Only peek at running subagents
1133
+ const sa = state.subagents.find(s => s.name === name);
1134
+ if (!sa || sa.status !== 'running') return;
1135
+
1136
+ state.viewMode = 'peek';
1137
+ state.peekTarget = name;
1138
+
1139
+ // Ensure log exists (may already have entries from global subscriber)
1140
+ if (!peekLogs.has(name)) peekLogs.set(name, []);
1141
+
1142
+ if (subMod) {
1143
+ // Get initial snapshot (async, best-effort) — seed the log if empty
1144
+ subMod.peek(name).then(snapshots => {
1145
+ if (snapshots.length > 0 && state.viewMode === 'peek' && state.peekTarget === name) {
1146
+ const snap = snapshots[0]!;
1147
+ const log = peekLogs.get(name);
1148
+ if (log && log.length === 0) {
1149
+ if (snap.currentStream) {
1150
+ // Show last few lines of existing stream as initial context
1151
+ const streamLines = snap.currentStream.split('\n').slice(-10);
1152
+ for (const l of streamLines) {
1153
+ if (l.trim()) appendPeekLog(name, l, WHITE);
1154
+ }
1155
+ }
1156
+ if (snap.pendingToolCalls.length > 0) {
1157
+ for (const tc of snap.pendingToolCalls) {
1158
+ appendPeekLog(name, `⟳ ${tc.name}`, YELLOW);
1159
+ peekCurrentTool.set(name, tc.name);
1160
+ }
1161
+ }
1162
+ }
1163
+ updatePeekView();
1164
+ }
1165
+ }).catch(() => {});
1166
+ }
1167
+
1168
+ updatePeekView();
1169
+ }
1170
+
1171
+ function updatePeekView() {
1172
+ const name = state.peekTarget;
1173
+ if (!name) return;
1174
+
1175
+ const lines: FleetLine[] = [];
1176
+ lines.push({ text: `─── Peek: ${name} ──────────────── Esc:back ───`, color: GRAY });
1177
+ lines.push({ text: '', color: GRAY });
1178
+
1179
+ const sa = state.subagents.find(s => s.name === name);
1180
+ if (sa) {
1181
+ const elapsed = Math.floor((Date.now() - sa.startedAt) / 1000);
1182
+ const min = Math.floor(elapsed / 60);
1183
+ const sec = elapsed % 60;
1184
+ const timeStr = min > 0 ? `${min}m${sec}s` : `${sec}s`;
1185
+ const statusColor = sa.status === 'running' ? CYAN : sa.status === 'failed' ? RED : DIM_GRAY;
1186
+ lines.push({ text: ` ${sa.status} ${timeStr} ${sa.toolCallsCount} tool calls`, color: statusColor });
1187
+
1188
+ const task = sa.task.length > 70 ? sa.task.slice(0, 67) + '...' : sa.task;
1189
+ lines.push({ text: ` task: ${task}`, color: GRAY });
1190
+ }
1191
+
1192
+ // Sticky: current pending tool (if any)
1193
+ const log = peekLogs.get(name);
1194
+ if (peekCurrentTool.get(name)) {
1195
+ lines.push({ text: '', color: GRAY });
1196
+ lines.push({ text: ` ⟳ ${peekCurrentTool.get(name)}`, color: YELLOW });
1197
+ }
1198
+
1199
+ lines.push({ text: '', color: GRAY });
1200
+
1201
+ // Accumulated event log — show last N lines
1202
+ if (log && log.length > 0) {
1203
+ const maxLines = Math.max(10, renderer.terminalHeight - 8);
1204
+ const tail = log.slice(-maxLines);
1205
+ if (log.length > maxLines) {
1206
+ lines.push({ text: ` ┈ (${log.length - maxLines} lines above)`, color: DIM_GRAY });
1207
+ }
1208
+ for (const entry of tail) {
1209
+ lines.push({ text: ` ${entry.text}`, color: entry.color });
1210
+ }
1211
+ } else {
1212
+ lines.push({ text: ' (waiting for output)', color: DIM_GRAY });
1213
+ }
1214
+
1215
+ // Rebuild fleetBox children
1216
+ for (const child of [...fleetBox.getChildren()]) {
1217
+ fleetBox.remove(child.id);
1218
+ }
1219
+ for (const line of lines) {
1220
+ fleetBox.add(new TextRenderable(renderer, {
1221
+ id: `fleet-ln-${++fleetLineCounter}`,
1222
+ content: line.text,
1223
+ fg: line.color,
1224
+ }));
1225
+ }
1226
+ }
1227
+
1228
+ // ── Trace listener ──────────────────────────────────────────────────
1229
+
1230
+ function onTrace(event: Record<string, unknown>) {
1231
+ const agent = event.agentName as string | undefined;
1232
+
1233
+ switch (event.type) {
1234
+ case 'inference:started': {
1235
+ if (agent === rootAgentName) {
1236
+ if (backgrounded) {
1237
+ // Root agent is running in background — don't show stream UI
1238
+ state.status = 'background';
1239
+ streamOutputTokens = 0;
1240
+ } else {
1241
+ state.status = 'thinking';
1242
+ streamOutputTokens = 0;
1243
+ spinnerFrame = 0;
1244
+ beginStream();
1245
+ }
1246
+ updateStatus();
1247
+ }
1248
+ break;
1249
+ }
1250
+
1251
+ case 'inference:content_block': {
1252
+ if (agent === rootAgentName && event.phase === 'block_start') {
1253
+ // `as string` rather than narrowing to StreamBlockType up front:
1254
+ // honest about the trust boundary. Anthropic ships block types we
1255
+ // may not have enumerated yet (e.g. redacted_thinking); ignore them.
1256
+ const bt = event.blockType as string;
1257
+ if (bt === 'text' || bt === 'thinking') {
1258
+ if (!streaming) beginStream();
1259
+ switchStreamBlock(bt);
1260
+ }
1261
+ }
1262
+ break;
1263
+ }
1264
+
1265
+ case 'inference:tokens': {
1266
+ const content = event.content as string;
1267
+ const blockType = event.blockType as string | undefined;
1268
+ if (content) {
1269
+ if (agent === rootAgentName && backgrounded) {
1270
+ // Silently accumulate tokens while backgrounded
1271
+ backgroundBuffer += content;
1272
+ streamOutputTokens += Math.ceil(content.length / 4);
1273
+ } else if (agent === rootAgentName && streaming) {
1274
+ // Belt-and-braces: if a token's blockType disagrees with what
1275
+ // block_start announced (or block_start was missed), switch lanes
1276
+ // before appending so thinking doesn't leak into the text element.
1277
+ if (blockType && (blockType === 'text' || blockType === 'thinking')
1278
+ && blockType !== currentStreamBlockType) {
1279
+ switchStreamBlock(blockType);
1280
+ }
1281
+ streamToken(content);
1282
+ streamOutputTokens += Math.ceil(content.length / 4);
1283
+ spinnerFrame = (spinnerFrame + 1) % SPINNER.length;
1284
+ updateStatus();
1285
+ }
1286
+ if (agent) {
1287
+ appendTranscript(agent, content);
1288
+ // Project context growth: output tokens will be in context next round
1289
+ const prev = agentContextTokens.get(agent);
1290
+ if (prev) {
1291
+ const delta = Math.ceil(content.length / 4);
1292
+ agentContextTokens.set(agent, prev + delta);
1293
+ const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1294
+ if (short !== agent) agentContextTokens.set(short, prev + delta);
1295
+ }
1296
+ }
1297
+ }
1298
+ break;
1299
+ }
1300
+
1301
+ case 'inference:usage': {
1302
+ // Per-round usage updates during yielding streams
1303
+ const roundUsage = event.tokenUsage as {
1304
+ input?: number; output?: number; cacheCreation?: number; cacheRead?: number;
1305
+ } | undefined;
1306
+ if (agent && roundUsage?.input) {
1307
+ agentContextTokens.set(agent, roundUsage.input);
1308
+ const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1309
+ if (short !== agent) agentContextTokens.set(short, roundUsage.input);
1310
+ if (state.viewMode === 'fleet') updateFleetView();
1311
+ }
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;
1318
+ updateStatus();
1319
+ }
1320
+ break;
1321
+ }
1322
+
1323
+ case 'inference:completed': {
1324
+ const usage = event.tokenUsage as { input?: number; output?: number } | undefined;
1325
+ // Track context size per agent (store by both full and short name)
1326
+ if (usage && agent && usage.input) {
1327
+ agentContextTokens.set(agent, usage.input);
1328
+ const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1329
+ if (short !== agent) agentContextTokens.set(short, usage.input);
1330
+ }
1331
+
1332
+ if (agent === rootAgentName) {
1333
+ state.status = 'idle';
1334
+ state.tool = null;
1335
+ if (backgrounded) {
1336
+ // Researcher returned from background — show accumulated output as a message
1337
+ if (backgroundBuffer.trim()) {
1338
+ addLine(backgroundBuffer, WHITE);
1339
+ }
1340
+ addLine(' (researcher returned from background)', CYAN);
1341
+ backgrounded = false;
1342
+ backgroundBuffer = '';
1343
+ }
1344
+ if (streaming) endStream();
1345
+ }
1346
+ updateStatus();
1347
+ break;
1348
+ }
1349
+
1350
+ case 'usage:updated': {
1351
+ const { totals } = event as { totals: SessionUsage };
1352
+ state.tokens.input = totals.inputTokens;
1353
+ state.tokens.output = totals.outputTokens;
1354
+ state.tokens.cacheRead = totals.cacheReadTokens;
1355
+ state.tokens.cacheWrite = totals.cacheCreationTokens;
1356
+ updateStatus();
1357
+ break;
1358
+ }
1359
+
1360
+ case 'inference:failed': {
1361
+ if (agent === rootAgentName) {
1362
+ state.status = 'error';
1363
+ if (backgrounded) {
1364
+ backgrounded = false;
1365
+ backgroundBuffer = '';
1366
+ }
1367
+ if (streaming) endStream();
1368
+ addLine(`Error: ${event.error}`, RED);
1369
+ updateStatus();
1370
+ } else {
1371
+ if (agent) {
1372
+ const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1373
+ subagentPhase.set(short, 'failed');
1374
+ }
1375
+ addLine(`[${agent}] Error: ${event.error}`, DIM_GRAY);
1376
+ }
1377
+ break;
1378
+ }
1379
+
1380
+ case 'inference:tool_calls_yielded': {
1381
+ const calls = event.calls as Array<{ name: string; input?: unknown }>;
1382
+ const names = calls.map(c => c.name).join(', ');
1383
+
1384
+ if (agent) {
1385
+ const toolSnippet = calls.map(c => {
1386
+ const inp = c.input ? JSON.stringify(c.input) : '';
1387
+ return `[tool: ${c.name}${inp ? ' ' + inp.slice(0, 200) : ''}]`;
1388
+ }).join('\n');
1389
+ appendTranscript(agent, '\n' + toolSnippet + '\n');
1390
+
1391
+ // Track parent-child for fleet tree
1392
+ for (const call of calls) {
1393
+ if (call.name === 'subagent--spawn' || call.name === 'subagent--fork') {
1394
+ const childName = (call.input as Record<string, unknown>)?.name as string | undefined;
1395
+ if (childName) {
1396
+ agentParent.set(childName, agent);
1397
+ }
1398
+ }
1399
+ }
1400
+ }
1401
+
1402
+ if (agent === rootAgentName) {
1403
+ state.status = backgrounded ? 'background' : 'tools';
1404
+ state.tool = names;
1405
+ if (streaming) endStream();
1406
+ if (!backgrounded) addLine(`[tools] ${names}`, YELLOW);
1407
+ } else {
1408
+ const short = (agent ?? '').replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '');
1409
+ addLine(` [${short}] ${names}`, DIM_GRAY);
1410
+ const sa = state.subagents.find(s => (agent ?? '').includes(s.name));
1411
+ if (sa) {
1412
+ sa.toolCallsCount += calls.length;
1413
+ sa.statusMessage = names.split('--').pop();
1414
+ }
1415
+ }
1416
+ updateStatus();
1417
+ break;
1418
+ }
1419
+
1420
+ case 'inference:stream_resumed': {
1421
+ if (agent === rootAgentName) {
1422
+ state.status = 'thinking';
1423
+ state.tool = null;
1424
+ beginStream();
1425
+ updateStatus();
1426
+ }
1427
+ break;
1428
+ }
1429
+
1430
+ // Wake subscription trigger notice
1431
+ case 'process:received': {
1432
+ const pe = event.processEvent as { type: string; source?: string; metadata?: Record<string, unknown> } | undefined;
1433
+ if (pe?.source === 'wake:triggered' && pe.metadata) {
1434
+ const subs = (pe.metadata.subscriptions as string[]) ?? [];
1435
+ const summary = (pe.metadata.eventSummary as string) ?? '';
1436
+ const snippet = summary.length > 60 ? summary.slice(0, 57) + '...' : summary;
1437
+ const label = subs.join(', ');
1438
+ addLine(`\u2691 wake triggered: ${label} \u2014 "${snippet}"`, YELLOW);
1439
+ }
1440
+ break;
1441
+ }
1442
+
1443
+ case 'tool:started': {
1444
+ const tool = event.tool as string;
1445
+ if (agent === rootAgentName) {
1446
+ state.tool = tool;
1447
+ updateStatus();
1448
+ }
1449
+ // Show file operations in chat
1450
+ const toolInput = event.input as Record<string, unknown> | undefined;
1451
+ if (toolInput && (agent === rootAgentName || verboseChat)) {
1452
+ const short = agent === rootAgentName ? '' : `[${(agent ?? '').replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '')}] `;
1453
+ if (tool === 'files:write' && toolInput.filePath) {
1454
+ const fp = String(toolInput.filePath);
1455
+ addLine(` ${short}write ${fp}`, DIM_GRAY);
1456
+ } else if (tool === 'files:materialize' && toolInput.targetDir) {
1457
+ const dir = String(toolInput.targetDir);
1458
+ const files = toolInput.files as string[] | undefined;
1459
+ const fileList = files ? files.join(', ') : 'all';
1460
+ // OSC 8 hyperlink for the target directory
1461
+ const link = `\x1b]8;;file://${dir}\x07${dir}\x1b]8;;\x07`;
1462
+ addLine(` ${short}materialize → ${link} (${fileList})`, DIM_GRAY);
1463
+ } else if (tool === 'lessons--create' && toolInput.content) {
1464
+ const content = String(toolInput.content);
1465
+ const tags = (toolInput.tags as string[] | undefined)?.join(', ') ?? '';
1466
+ const preview = content.length > 80 ? content.slice(0, 77) + '...' : content;
1467
+ addLine(` ${short}+ lesson${tags ? ` [${tags}]` : ''}: ${preview}`, GREEN);
1468
+ }
1469
+ }
1470
+ break;
1471
+ }
1472
+
1473
+ case 'tool:completed':
1474
+ break;
1475
+
1476
+ case 'tool:failed': {
1477
+ const tool = event.tool as string;
1478
+ const error = event.error as string;
1479
+ if (agent === rootAgentName) {
1480
+ addLine(`[tool error] ${tool}: ${error}`, RED);
1481
+ } else if (agent) {
1482
+ const short = agent.replace(/^(spawn|fork)-/, '').replace(/-\d+$/, '').replace(/-retry\d+$/, '');
1483
+ addLine(` [${short}] tool error: ${tool}: ${error}`, RED);
1484
+ }
1485
+ break;
1486
+ }
1487
+
1488
+ case 'branches:changed': {
1489
+ const branchEvent = event.event as string;
1490
+ const branch = event.branch as string;
1491
+ const previous = event.previous as string | undefined;
1492
+ const source = event.source as string;
1493
+
1494
+ if (branchEvent === 'switched') {
1495
+ addLine(`Branch switched: ${previous ?? '?'} → ${branch} (via ${source})`, CYAN);
1496
+ resetBranchState(app.branchState);
1497
+ refreshFromStore();
1498
+ } else if (branchEvent === 'created') {
1499
+ addLine(`Branch created: ${branch} (via ${source})`, CYAN);
1500
+ } else if (branchEvent === 'deleted') {
1501
+ addLine(`Branch deleted: ${branch} (via ${source})`, CYAN);
1502
+ }
1503
+ updateStatus();
1504
+ break;
1505
+ }
1506
+ }
1507
+ }
1508
+
1509
+ // ── Subagent polling ────────────────────────────────────────────────
1510
+
1511
+ let subMod = app.framework.getAllModules().find(m => m.name === 'subagent') as SubagentModule | undefined;
1512
+ let fleetMod = app.framework.getAllModules().find(m => m.name === 'fleet') as FleetModule | undefined;
1513
+
1514
+ // FleetTreeAggregator owns one AgentTreeReducer per fleet child plus a local
1515
+ // one. Drives the unified subagent-tree rendering: fleet children appear as
1516
+ // first-class nodes alongside in-process subagents, with the same readouts
1517
+ // (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
+ });
1530
+ }
1531
+
1532
+ // Per-child event log (analogue of peekLogs but for child processes).
1533
+ const procPeekLogs = new Map<string, FleetLine[]>();
1534
+ // In-progress token line buffer per child (flushed to log on newline / next event).
1535
+ const procPeekTokenLine = new Map<string, string>();
1536
+ let procPeekUnsub: (() => void) | null = null;
1537
+
1538
+ function appendProcPeekLog(name: string, text: string, color: string): void {
1539
+ if (!procPeekLogs.has(name)) procPeekLogs.set(name, []);
1540
+ const log = procPeekLogs.get(name)!;
1541
+ log.push({ text, color });
1542
+ // Cap at 500 lines to match FleetModule buffer sizing.
1543
+ if (log.length > 500) log.splice(0, log.length - 500);
1544
+ }
1545
+
1546
+ function formatWireEvent(evt: WireEvent): { text: string; color: string } | null {
1547
+ const type = typeof evt.type === 'string' ? evt.type : '';
1548
+ if (!type) return null;
1549
+ const get = (k: string): unknown => (evt as Record<string, unknown>)[k];
1550
+
1551
+ switch (type) {
1552
+ case 'lifecycle': {
1553
+ const phase = get('phase') as string;
1554
+ const color = phase === 'ready' ? GREEN : phase === 'exiting' ? YELLOW : GRAY;
1555
+ return { text: `◆ lifecycle: ${phase}${get('reason') ? ` (${get('reason') as string})` : ''}`, color };
1556
+ }
1557
+ case 'inference:started': return { text: `─ inference started (${get('agentName') ?? '?'}) ─`, color: DIM_GRAY };
1558
+ case 'inference:completed': return { text: `─ inference completed ─`, color: DIM_GRAY };
1559
+ case 'inference:failed': return { text: `✗ inference failed: ${get('error') as string}`, color: RED };
1560
+ case 'inference:tokens': {
1561
+ // Token streams would spam the log; the line-merge needed to show them
1562
+ // nicely is in peek-proc's dedicated renderer, not this formatter.
1563
+ return null;
1564
+ }
1565
+ case 'tool:started': return { text: ` ⟳ ${get('tool') as string}`, color: YELLOW };
1566
+ case 'tool:completed': return { text: ` ✓ ${get('tool') as string} (${get('durationMs') ?? '?'}ms)`, color: CYAN };
1567
+ case 'tool:failed': return { text: ` ✗ ${get('tool') as string}: ${get('error') as string}`, color: RED };
1568
+ case 'command-output': return { text: ` ${get('text') as string}`, color: GRAY };
1569
+ default:
1570
+ return { text: `· ${type}`, color: DIM_GRAY };
1571
+ }
1572
+ }
1573
+
1574
+ // Subscribe to each subagent's stream for peek logs + done events.
1575
+ const subagentStreamUnsubs: Array<() => void> = [];
1576
+ const subscribedSubagents = new Set<string>();
1577
+
1578
+ /** Tracks the last token line being built for each agent (to merge consecutive token events). */
1579
+ const peekTokenLine = new Map<string, string>();
1580
+
1581
+ function subscribeSubagentStream(name: string) {
1582
+ if (subscribedSubagents.has(name) || !subMod) return;
1583
+ subscribedSubagents.add(name);
1584
+
1585
+ if (!peekLogs.has(name)) peekLogs.set(name, []);
1586
+
1587
+ const unsub = subMod.onPeekStream(name, (event) => {
1588
+ switch (event.type) {
1589
+ case 'inference:started':
1590
+ subagentPhase.set(name, 'sending');
1591
+ appendPeekLog(name, '── inference round ──', DIM_GRAY);
1592
+ peekCurrentTool.set(name, null);
1593
+ peekTokenLine.delete(name);
1594
+ break;
1595
+
1596
+ case 'tokens': {
1597
+ if (subagentPhase.get(name) !== 'streaming') subagentPhase.set(name, 'streaming');
1598
+ // Merge consecutive token events into the last line
1599
+ const prev = peekTokenLine.get(name) ?? '';
1600
+ const merged = prev + event.content;
1601
+ // Split by newlines — only the last segment is "in progress"
1602
+ const parts = merged.split('\n');
1603
+ if (parts.length > 1) {
1604
+ // Flush completed lines
1605
+ for (let i = 0; i < parts.length - 1; i++) {
1606
+ if (parts[i]!.trim()) appendPeekLog(name, parts[i]!, WHITE);
1607
+ }
1608
+ }
1609
+ peekTokenLine.set(name, parts[parts.length - 1]!);
1610
+ break;
1611
+ }
1612
+
1613
+ case 'tool_calls': {
1614
+ subagentPhase.set(name, 'invoking');
1615
+ // Flush any pending token line
1616
+ const pending = peekTokenLine.get(name);
1617
+ if (pending?.trim()) appendPeekLog(name, pending, WHITE);
1618
+ peekTokenLine.delete(name);
1619
+ const toolNames = event.calls.map(c => c.name).join(', ');
1620
+ appendPeekLog(name, `→ ${toolNames}`, YELLOW);
1621
+ break;
1622
+ }
1623
+
1624
+ case 'tool:started':
1625
+ subagentPhase.set(name, 'executing');
1626
+ peekCurrentTool.set(name, event.tool);
1627
+ appendPeekLog(name, ` ⟳ ${event.tool}`, GRAY);
1628
+ break;
1629
+
1630
+ case 'tool:completed':
1631
+ if (peekCurrentTool.get(name) === event.tool) peekCurrentTool.set(name, null);
1632
+ appendPeekLog(name, ` ✓ ${event.tool} (${event.durationMs}ms)`, DIM_GRAY);
1633
+ break;
1634
+
1635
+ case 'tool:failed':
1636
+ if (peekCurrentTool.get(name) === event.tool) peekCurrentTool.set(name, null);
1637
+ appendPeekLog(name, ` ✗ ${event.tool}: ${event.error}`, RED);
1638
+ break;
1639
+
1640
+ case 'stream_resumed':
1641
+ subagentPhase.set(name, 'sending');
1642
+ appendPeekLog(name, '── stream resumed ──', DIM_GRAY);
1643
+ peekCurrentTool.set(name, null);
1644
+ peekTokenLine.delete(name);
1645
+ break;
1646
+
1647
+ case 'inference:completed':
1648
+ break;
1649
+
1650
+ case 'done': {
1651
+ subagentPhase.set(name, 'done');
1652
+ // Flush any pending token line
1653
+ const pendingTok = peekTokenLine.get(name);
1654
+ if (pendingTok?.trim()) appendPeekLog(name, pendingTok, WHITE);
1655
+ peekTokenLine.delete(name);
1656
+ peekCurrentTool.set(name, null);
1657
+
1658
+ const summary = event.summary;
1659
+ const truncated = summary.length > 100 ? summary.slice(0, 97) + '...' : summary;
1660
+ appendPeekLog(name, `── done: ${truncated} ──`, DIM_GRAY);
1661
+
1662
+ // Update context tokens from done event
1663
+ if (event.lastInputTokens) {
1664
+ agentContextTokens.set(name, event.lastInputTokens);
1665
+ }
1666
+
1667
+ // Verbose chat display
1668
+ if (verboseChat) {
1669
+ const chatTruncated = summary.length > 200 ? summary.slice(0, 197) + '...' : summary;
1670
+ addLine(` ◀ [${name}] ${chatTruncated}`, CYAN);
1671
+ }
1672
+ break;
1673
+ }
1674
+ }
1675
+
1676
+ if (state.viewMode === 'peek' && state.peekTarget === name) updatePeekView();
1677
+ if (state.viewMode === 'fleet') {
1678
+ state.subagents = [...subMod!.activeSubagents.values()];
1679
+ updateFleetView();
1680
+ }
1681
+ });
1682
+ subagentStreamUnsubs.push(unsub);
1683
+ }
1684
+ const pollTimer = setInterval(() => {
1685
+ // Animate spinner when researcher is active (not just on token events)
1686
+ if (state.status !== 'idle' && state.status !== 'error') {
1687
+ spinnerFrame = (spinnerFrame + 1) % SPINNER.length;
1688
+ }
1689
+
1690
+ if (subMod) {
1691
+ state.subagents = [...subMod.activeSubagents.values()];
1692
+ // Subscribe to stream events for any new subagents
1693
+ for (const sa of state.subagents) {
1694
+ subscribeSubagentStream(sa.name);
1695
+ }
1696
+ updateStatus();
1697
+ if (state.viewMode === 'fleet') updateFleetView();
1698
+ else if (state.viewMode === 'peek') updatePeekView();
1699
+ }
1700
+ if (fleetMod) {
1701
+ // Pick up fleet children that were launched after TUI init so the
1702
+ // aggregator can request describe + start folding their event stream.
1703
+ if (treeAggregator) {
1704
+ const known = new Set(treeAggregator.getAllChildNames());
1705
+ for (const name of fleetMod.getChildren().keys()) {
1706
+ if (!known.has(name)) treeAggregator.registerChild(name);
1707
+ }
1708
+ }
1709
+ if (state.viewMode === 'peek-proc') updatePeekProcView();
1710
+ }
1711
+ }, 500);
1712
+
1713
+ // ── Keyboard ───────────────────────────────────────────────────────
1714
+
1715
+ renderer.keyInput.on('keypress', (key: { name?: string; ctrl?: boolean }) => {
1716
+ if (key.name === 'tab') {
1717
+ cleanupPeek();
1718
+ cleanupPeekProc();
1719
+ // Tab toggles between chat and the unified fleet view, which now
1720
+ // subsumes the per-process status that used to live in a separate
1721
+ // "processes" view. The fleet view is useful whenever either
1722
+ // SubagentModule (for local subagents) or FleetModule (for fleet
1723
+ // children) is loaded; otherwise stay on chat.
1724
+ const next = state.viewMode === 'chat'
1725
+ ? ((subMod || fleetMod) ? 'fleet' : 'chat')
1726
+ : 'chat';
1727
+ switchView(next);
1728
+ updateStatus();
1729
+ return;
1730
+ }
1731
+ // Ctrl+F: jump directly to fleet view from anywhere (or back to chat if already there).
1732
+ if (key.ctrl && key.name === 'f' && (subMod || fleetMod)) {
1733
+ cleanupPeek();
1734
+ cleanupPeekProc();
1735
+ switchView(state.viewMode === 'fleet' ? 'chat' : 'fleet');
1736
+ updateStatus();
1737
+ return;
1738
+ }
1739
+ if (key.ctrl && key.name === 'c') {
1740
+ cleanup();
1741
+ return;
1742
+ }
1743
+ if (key.ctrl && key.name === 'v') {
1744
+ verboseChat = !verboseChat;
1745
+ addLine(verboseChat ? '(verbose: on — showing agent thoughts & subagent results)' : '(verbose: off)', DIM_GRAY);
1746
+ return;
1747
+ }
1748
+ // Ctrl+B: push to background — detach any blocking sync subagents and/or
1749
+ // background the researcher's current inference (stop displaying tokens,
1750
+ // re-enable input; result appears as message when done)
1751
+ if (key.ctrl && key.name === 'b' && state.viewMode === 'chat') {
1752
+ let acted = false;
1753
+
1754
+ // 1. Detach any blocking sync subagents
1755
+ if (subMod?.hasDetachable()) {
1756
+ const detached = subMod.detachAll();
1757
+ if (detached > 0) {
1758
+ addLine(` (${detached} sync subagent${detached > 1 ? 's' : ''} moved to background)`, CYAN);
1759
+ acted = true;
1760
+ }
1761
+ }
1762
+
1763
+ // 2. Background the researcher's streaming output
1764
+ if (streaming && state.status !== 'idle') {
1765
+ endStream();
1766
+ backgrounded = true;
1767
+ addLine(' (researcher moved to background — result will appear when done)', CYAN);
1768
+ updateStatus();
1769
+ acted = true;
1770
+ }
1771
+
1772
+ if (!acted) {
1773
+ addLine(' (nothing to background)', DIM_GRAY);
1774
+ }
1775
+ return;
1776
+ }
1777
+
1778
+ // Chat view: Escape interrupts the active agent and all running subagents
1779
+ if (key.name === 'escape' && state.viewMode === 'chat') {
1780
+ if (state.status !== 'idle' && state.status !== 'error') {
1781
+ // Cancel all subagents first so their results propagate up
1782
+ const cancelled = subMod?.cancelAll() ?? 0;
1783
+ const agent = app.framework.getAgent(rootAgentName);
1784
+ if (agent) {
1785
+ agent.cancelStream();
1786
+ if (streaming) endStream();
1787
+ if (backgrounded) {
1788
+ backgrounded = false;
1789
+ backgroundBuffer = '';
1790
+ }
1791
+ state.status = 'idle';
1792
+ state.tool = null;
1793
+ addLine(cancelled > 0
1794
+ ? ` (interrupted — ${cancelled} subagent${cancelled > 1 ? 's' : ''} stopped)`
1795
+ : ' (interrupted)', YELLOW);
1796
+ updateStatus();
1797
+ }
1798
+ }
1799
+ return;
1800
+ }
1801
+
1802
+ // Peek view: Escape or p goes back to fleet
1803
+ if (state.viewMode === 'peek') {
1804
+ if (key.name === 'escape' || key.name === 'p') {
1805
+ cleanupPeek();
1806
+ switchView('fleet');
1807
+ updateStatus();
1808
+ }
1809
+ return;
1810
+ }
1811
+
1812
+ // Peek-proc view: Escape goes back to the unified fleet view
1813
+ if (state.viewMode === 'peek-proc') {
1814
+ if (key.name === 'escape' || key.name === 'p') {
1815
+ cleanupPeekProc();
1816
+ switchView('fleet');
1817
+ updateStatus();
1818
+ }
1819
+ return;
1820
+ }
1821
+
1822
+ // Fleet view navigation
1823
+ if (state.viewMode === 'fleet') {
1824
+ if (key.name === 'up') {
1825
+ fleetCursor = Math.max(0, fleetCursor - 1);
1826
+ updateFleetView();
1827
+ } else if (key.name === 'down') {
1828
+ fleetCursor = Math.min(visibleNodeIds.length - 1, fleetCursor + 1);
1829
+ updateFleetView();
1830
+ } else if (key.name === 'return' || key.name === 'right') {
1831
+ const nodeId = visibleNodeIds[fleetCursor];
1832
+ if (nodeId) {
1833
+ if (expandedNodes.has(nodeId)) expandedNodes.delete(nodeId);
1834
+ else expandedNodes.add(nodeId);
1835
+ updateFleetView();
1836
+ }
1837
+ } else if (key.name === 'left') {
1838
+ const nodeId = visibleNodeIds[fleetCursor];
1839
+ if (nodeId) {
1840
+ expandedNodes.delete(nodeId);
1841
+ updateFleetView();
1842
+ }
1843
+ } else if (key.name === 'p') {
1844
+ const nodeId = visibleNodeIds[fleetCursor];
1845
+ const node = nodeId ? visibleNodes.get(nodeId) : undefined;
1846
+ if (node && node.kind !== 'researcher') {
1847
+ // Dispatch by node kind, not string-prefix surgery on the ID.
1848
+ if (node.kind === 'fleet-child') {
1849
+ enterPeekProc(node.fleetChildName!);
1850
+ switchView('peek-proc');
1851
+ } 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');
1856
+ } else {
1857
+ // node.kind === 'subagent' — local in-process peek.
1858
+ enterPeek(nodeId!);
1859
+ }
1860
+ }
1861
+ } else if (key.name === 'delete' || key.name === 'backspace') {
1862
+ const nodeId = visibleNodeIds[fleetCursor];
1863
+ const node = nodeId ? visibleNodes.get(nodeId) : undefined;
1864
+ if (node && node.kind !== 'researcher') {
1865
+ if (node.kind === 'fleet-child' && fleetMod) {
1866
+ const childName = node.fleetChildName!;
1867
+ fleetMod.handleToolCall({ id: `tui-kill-${Date.now()}`, name: 'kill', input: { name: childName } })
1868
+ .then(() => updateFleetView())
1869
+ .catch(() => { /* error surfaces in status */ });
1870
+ } else if (node.kind === 'subagent') {
1871
+ const sa = state.subagents.find(s => s.name === nodeId);
1872
+ if (sa?.status === 'running' && subMod) {
1873
+ if (subMod.cancelSubagent(nodeId!)) {
1874
+ addLine(` ■ [${nodeId}] stopped by user`, YELLOW);
1875
+ }
1876
+ }
1877
+ }
1878
+ // fleet-child-agent: no per-agent stop yet (would need the child to
1879
+ // expose a cancel command over IPC). Future work.
1880
+ }
1881
+ } else if (key.name === 'r') {
1882
+ const nodeId = visibleNodeIds[fleetCursor];
1883
+ const node = nodeId ? visibleNodes.get(nodeId) : undefined;
1884
+ if (node?.kind === 'fleet-child' && fleetMod) {
1885
+ const childName = node.fleetChildName!;
1886
+ fleetMod.handleToolCall({ id: `tui-restart-${Date.now()}`, name: 'restart', input: { name: childName } })
1887
+ .then(() => updateFleetView())
1888
+ .catch(() => { /* error surfaces in status */ });
1889
+ }
1890
+ }
1891
+ }
1892
+ });
1893
+
1894
+ // ── Input handling ─────────────────────────────────────────────────
1895
+
1896
+ let resolveExit: (() => void) | null = null;
1897
+
1898
+ function handleSubmit() {
1899
+ const raw = ((input as any).plainText as string).trim();
1900
+ (input as any).clear();
1901
+
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.
1911
+ if (state.pendingQuitConfirm) {
1912
+ state.pendingQuitConfirm = false;
1913
+ const c = text.trim().toLowerCase();
1914
+ if (c === 'n' || c === 'no' || c === 'cancel') {
1915
+ addLine(' (quit cancelled)', GRAY);
1916
+ return;
1917
+ }
1918
+ if (c === 'd' || c === 'detach') {
1919
+ fleetMod?.setDetachMode(true);
1920
+ addLine(' (detaching — children stay running; next parent will adopt them)', CYAN);
1921
+ cleanup();
1922
+ return;
1923
+ }
1924
+ // default (y / empty / anything else) → graceful kill + exit
1925
+ addLine(' (stopping children and exiting...)', GRAY);
1926
+ cleanup();
1927
+ return;
1928
+ }
1929
+
1930
+ if (text.startsWith('/')) {
1931
+ const result = handleCommand(text, app);
1932
+ 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
+ }
1942
+ cleanup();
1943
+ return;
1944
+ }
1945
+ if (text === '/clear') {
1946
+ const children = [...scrollBox.getChildren()];
1947
+ for (const child of children) {
1948
+ scrollBox.remove(child.id);
1949
+ }
1950
+ } else {
1951
+ for (const l of result.lines) {
1952
+ addLine(l.text, GRAY);
1953
+ }
1954
+ }
1955
+
1956
+ // Branch operation: refresh display from Chronicle state
1957
+ if (result.branchChanged) {
1958
+ refreshFromStore();
1959
+ }
1960
+
1961
+ // Async command follow-up (e.g. /newtopic generating transition summary)
1962
+ if (result.asyncWork) {
1963
+ state.status = 'thinking';
1964
+ updateStatus();
1965
+ result.asyncWork.then(asyncResult => {
1966
+ for (const l of asyncResult.lines) {
1967
+ addLine(l.text, GRAY);
1968
+ }
1969
+ state.status = 'idle';
1970
+ updateStatus();
1971
+ }).catch(err => {
1972
+ addLine(`Async command failed: ${err}`, RED);
1973
+ state.status = 'error';
1974
+ updateStatus();
1975
+ });
1976
+ }
1977
+
1978
+ // Session switch: async teardown + rebuild
1979
+ if (result.switchToSessionId) {
1980
+ state.status = 'switching';
1981
+ updateStatus();
1982
+ app.framework.offTrace(onTrace as (e: unknown) => void);
1983
+
1984
+ app.switchSession(result.switchToSessionId).then(() => {
1985
+ // Rebind to new framework
1986
+ subMod = app.framework.getAllModules().find(m => m.name === 'subagent') as SubagentModule | undefined;
1987
+ fleetMod = app.framework.getAllModules().find(m => m.name === 'fleet') as FleetModule | undefined;
1988
+ app.framework.onTrace(onTrace as (e: unknown) => void);
1989
+
1990
+ const session = app.sessionManager.getActiveSession();
1991
+ refreshFromStore();
1992
+ addLine(`Session: ${session?.name ?? 'unknown'}`, GRAY);
1993
+ state.tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1994
+ updateStatus();
1995
+ }).catch(err => {
1996
+ addLine(`Session switch failed: ${err}`, RED);
1997
+ state.status = 'error';
1998
+ updateStatus();
1999
+ });
2000
+ }
2001
+
2002
+ // /fleet view → switch to the unified fleet view (formerly the
2003
+ // processes view, now subsumed by the unified subagent + fleet tree).
2004
+ if (result.switchToFleetView) {
2005
+ cleanupPeek();
2006
+ cleanupPeekProc();
2007
+ switchView('fleet');
2008
+ updateStatus();
2009
+ }
2010
+
2011
+ // /fleet peek <name> → enter peek-proc mode.
2012
+ if (result.switchToFleetPeek) {
2013
+ cleanupPeek();
2014
+ enterPeekProc(result.switchToFleetPeek);
2015
+ updateStatus();
2016
+ }
2017
+ } else {
2018
+ // @childname routing — send text directly to a child, bypassing conductor.
2019
+ const route = parseFleetRoute(text);
2020
+ if (route && fleetMod) {
2021
+ const child = fleetMod.getChildren().get(route.childName);
2022
+ if (!child) {
2023
+ addLine(` (unknown child: ${route.childName})`, RED);
2024
+ return;
2025
+ }
2026
+ addLine(`You → @${route.childName}: ${route.content}`, CYAN);
2027
+ fleetMod.handleToolCall({
2028
+ id: `tui-route-${Date.now()}`,
2029
+ name: 'send',
2030
+ input: { name: route.childName, content: route.content },
2031
+ }).then((res) => {
2032
+ if (!res.success) addLine(` (send to ${route.childName} failed: ${res.error ?? 'unknown'})`, RED);
2033
+ }).catch((err: unknown) => {
2034
+ addLine(` (send to ${route.childName} failed: ${String(err)})`, RED);
2035
+ });
2036
+ return;
2037
+ }
2038
+ addLine(`You: ${raw}`, GREEN);
2039
+ const agent = app.framework.getAgent(rootAgentName);
2040
+ const agentBusy = agent && (agent.state.status === 'streaming' || agent.state.status === 'inferring' || agent.state.status === 'waiting_for_tools');
2041
+ state.status = agentBusy ? 'queued' : 'thinking';
2042
+ updateStatus();
2043
+ app.framework.pushEvent({
2044
+ type: 'external-message', source: 'tui',
2045
+ content: text, metadata: {}, triggerInference: true,
2046
+ });
2047
+ }
2048
+ }
2049
+
2050
+ // ── Init ───────────────────────────────────────────────────────────
2051
+
2052
+ const session = app.sessionManager.getActiveSession();
2053
+ addLine(`${recipeName}. Type /help for commands.`, GRAY);
2054
+ if (session) addLine(`Session: ${session.name}`, DIM_GRAY);
2055
+ addLine(`Error log: ${logPath}`, DIM_GRAY);
2056
+ app.framework.onTrace(onTrace as (e: unknown) => void);
2057
+ loadSessionHistory();
2058
+
2059
+ // ── Cleanup ────────────────────────────────────────────────────────
2060
+
2061
+ function cleanup() {
2062
+ cleanupPeek();
2063
+ cleanupPeekProc();
2064
+ treeAggregator?.dispose();
2065
+ for (const unsub of subagentStreamUnsubs) unsub();
2066
+ clearInterval(pollTimer);
2067
+ app.framework.offTrace(onTrace as (e: unknown) => void);
2068
+ renderer.destroy();
2069
+ process.stdout.write('\x1b]0;\x07');
2070
+ // Restore stderr
2071
+ process.stderr.write = origStderrWrite;
2072
+ logStream.end();
2073
+ app.framework.stop().then(() => {
2074
+ resolveExit?.();
2075
+ });
2076
+ }
2077
+
2078
+ // ── Wait for exit ──────────────────────────────────────────────────
2079
+
2080
+ await new Promise<void>(resolve => {
2081
+ resolveExit = resolve;
2082
+ });
2083
+ }
2084
+
2085
+ // ---------------------------------------------------------------------------
2086
+ // Status bar formatter
2087
+ // ---------------------------------------------------------------------------
2088
+
2089
+ function formatStatusLeft(
2090
+ state: TuiState,
2091
+ spinnerChar?: string,
2092
+ outputTokens?: number,
2093
+ ): string {
2094
+ const sColor = state.status === 'idle' ? '✓' : state.status === 'error' ? '✗' : state.status === 'background' ? '↓' : state.status === 'queued' ? '⏳' : '…';
2095
+ let bar = `[${sColor} ${state.status}`;
2096
+ if (spinnerChar !== undefined && state.status !== 'idle' && state.status !== 'error' && state.status !== 'background') {
2097
+ bar += ` ${spinnerChar}`;
2098
+ if (state.status === 'thinking' && outputTokens !== undefined && outputTokens > 0) {
2099
+ const tokStr = outputTokens >= 1000 ? (outputTokens / 1000).toFixed(1) + 'k' : String(outputTokens);
2100
+ bar += ` ${tokStr} tok`;
2101
+ }
2102
+ }
2103
+ if (state.tool) bar += ` | ${state.tool}`;
2104
+ const running = state.subagents.filter(s => s.status === 'running').length;
2105
+ if (running > 0) {
2106
+ bar += ` | ${running} sub`;
2107
+ }
2108
+ if (state.viewMode === 'fleet' || state.viewMode === 'peek') {
2109
+ bar += state.viewMode === 'peek' ? ` | peek: ${state.peekTarget}` : ' | fleet view';
2110
+ } else if (state.viewMode === 'peek-proc') {
2111
+ bar += ` | peek-proc: ${state.peekProcTarget}`;
2112
+ } else if (state.viewMode === 'chat') {
2113
+ if (state.status === 'background') bar += ' Esc:stop';
2114
+ else if (state.status !== 'idle' && state.status !== 'error') bar += ' Ctrl+B:bg Esc:stop';
2115
+ if (running > 0) bar += ' Tab:fleet';
2116
+ }
2117
+ bar += ']';
2118
+ return bar;
2119
+ }
2120
+
2121
+ function formatTokens(tokens: TokenUsage, verbose: boolean): string {
2122
+ const parts: string[] = [];
2123
+
2124
+ const total = tokens.input + tokens.output;
2125
+ if (total > 0) {
2126
+ let s = `${fmtTokens(tokens.input)}in ${fmtTokens(tokens.output)}out`;
2127
+ if (tokens.cacheRead > 0) s += ` ${fmtTokens(tokens.cacheRead)}hit`;
2128
+ if (tokens.cacheWrite > 0) s += ` ${fmtTokens(tokens.cacheWrite)}write`;
2129
+ parts.push(s);
2130
+ }
2131
+
2132
+ parts.push(verbose ? 'C-v:terse' : 'C-v:verbose');
2133
+ return parts.join(' ');
2134
+ }
2135
+
2136
+ /**
2137
+ * Render strategy memory stats for the status line. Prefers the richer
2138
+ * `getRenderStats()` (tail size + per-level token sums) and falls back to
2139
+ * `getStats()` if only that's available.
2140
+ *
2141
+ * Output shape: `mem: L1=5/9k L2=1/2k tail=770/495k (3 pending, 1 merge)`
2142
+ * - L1=count/totalTokens (compact e.g. 9k = 9000)
2143
+ * - tail=messageCount/tokenCount
2144
+ */
2145
+ function formatMemStats(
2146
+ cm: { getRenderStats?: () => unknown; getStrategy?: () => { getStats?: () => unknown } } | null,
2147
+ ): string {
2148
+ if (!cm) return '';
2149
+ try {
2150
+ if (typeof cm.getRenderStats === 'function') {
2151
+ const r = cm.getRenderStats() as {
2152
+ head?: { messages: number; tokens: number };
2153
+ tail?: { messages: number; tokens: number };
2154
+ summaries?: {
2155
+ l1?: { count: number; tokens: number };
2156
+ l2?: { count: number; tokens: number };
2157
+ l3?: { count: number; tokens: number };
2158
+ };
2159
+ pending?: { chunks: number; merges: number };
2160
+ } | null;
2161
+ if (!r) return '';
2162
+ const sumLevel = (lvl?: { count: number; tokens: number }) =>
2163
+ lvl && lvl.count > 0 ? `${lvl.count}/${fmtTokens(lvl.tokens)}t` : null;
2164
+ const parts: string[] = [];
2165
+ const l1 = sumLevel(r.summaries?.l1);
2166
+ const l2 = sumLevel(r.summaries?.l2);
2167
+ const l3 = sumLevel(r.summaries?.l3);
2168
+ if (l1) parts.push(`L1=${l1}`);
2169
+ if (l2) parts.push(`L2=${l2}`);
2170
+ if (l3) parts.push(`L3=${l3}`);
2171
+ if (r.tail && r.tail.messages > 0) parts.push(`tail=${r.tail.messages}msg/${fmtTokens(r.tail.tokens)}t`);
2172
+ const tags: string[] = [];
2173
+ if (r.pending && r.pending.chunks > 0) tags.push(`${r.pending.chunks} pending`);
2174
+ if (r.pending && r.pending.merges > 0) tags.push(`${r.pending.merges} merge`);
2175
+ if (parts.length === 0 && tags.length === 0) return '';
2176
+ return ` mem: ${parts.join(' ')}${tags.length > 0 ? ` (${tags.join(', ')})` : ''}`;
2177
+ }
2178
+ // Fallback to old getStats
2179
+ const strategy = cm.getStrategy?.();
2180
+ if (!strategy?.getStats) return '';
2181
+ const s = strategy.getStats() as {
2182
+ l1?: number; l2?: number; l3?: number;
2183
+ pendingMerges?: number;
2184
+ chunksTotal?: number; chunksCompressed?: number;
2185
+ };
2186
+ const l1 = s.l1 ?? 0;
2187
+ const l2 = s.l2 ?? 0;
2188
+ const l3 = s.l3 ?? 0;
2189
+ if (l1 === 0 && l2 === 0 && l3 === 0) return '';
2190
+ const pending = (s.chunksTotal ?? 0) - (s.chunksCompressed ?? 0);
2191
+ let out = ` mem: L1=${l1}`;
2192
+ if (l2 > 0) out += ` L2=${l2}`;
2193
+ if (l3 > 0) out += ` L3=${l3}`;
2194
+ if (pending > 0) out += ` (${pending} pending)`;
2195
+ if ((s.pendingMerges ?? 0) > 0) out += ` (${s.pendingMerges} merge)`;
2196
+ return out;
2197
+ } catch {
2198
+ return '';
2199
+ }
2200
+ }