@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.
- package/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- 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
|
+
}
|