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