@animalabs/connectome-host 0.3.10 → 0.5.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/CHANGELOG.md +131 -0
- package/HEADLESS-FLEET-PLAN.md +1 -1
- package/UNIFIED-TREE-PLAN.md +2 -0
- package/bun.lock +6 -6
- 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 +4 -4
- package/src/commands.ts +50 -4
- package/src/framework-agent-config.ts +3 -0
- package/src/framework-strategy.ts +3 -0
- package/src/index.ts +9 -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 +280 -1
- package/src/recipe.ts +58 -1
- package/src/tui.ts +363 -104
- package/src/web/protocol.ts +130 -0
- package/test/commands-checkpoint-restore.test.ts +118 -0
- package/test/settings-protocol.test.ts +64 -0
- 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/web/src/App.tsx +35 -2
- package/web/src/Settings.tsx +434 -0
package/src/tui.ts
CHANGED
|
@@ -47,6 +47,44 @@ export function fmtTokens(n: number): string {
|
|
|
47
47
|
return String(n);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* What a submission at the armed /quit prompt means. Pure so the semantics
|
|
52
|
+
* are pinned by tests: the default for arbitrary input is CANCEL — the
|
|
53
|
+
* pre-fix behavior ("anything else → kill everything") meant a user who
|
|
54
|
+
* forgot the prompt and typed a normal chat message killed the fleet.
|
|
55
|
+
* `cancel-keep-input` = the input looks like a real message; the caller
|
|
56
|
+
* must restore it rather than discard it.
|
|
57
|
+
*/
|
|
58
|
+
export type QuitConfirmAction = 'kill' | 'detach' | 'cancel' | 'cancel-keep-input';
|
|
59
|
+
export function resolveQuitConfirm(raw: string): QuitConfirmAction {
|
|
60
|
+
const c = raw.trim().toLowerCase();
|
|
61
|
+
// Re-typing the quit command at the prompt is a confirmation, not a cancellation.
|
|
62
|
+
if (c === 'y' || c === 'yes' || c === 'q' || c === 'quit' || c === '/q' || c === '/quit') return 'kill';
|
|
63
|
+
if (c === 'd' || c === 'detach') return 'detach';
|
|
64
|
+
if (c === '' || c === 'n' || c === 'no' || c === 'cancel') return 'cancel';
|
|
65
|
+
return 'cancel-keep-input';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Canonical short name for a subagent's full framework name. The single
|
|
70
|
+
* source of truth for full↔short resolution — ad-hoc `.includes()` matching
|
|
71
|
+
* here used to cross-wire agents whose names were substrings of each other
|
|
72
|
+
* (e.g. `web` / `websearch`).
|
|
73
|
+
*
|
|
74
|
+
* Naming schemes (see subagent-module.ts spawn/fork paths):
|
|
75
|
+
* spawn: `spawn-{name}-{ts}`
|
|
76
|
+
* fork: `{name}-d{depth}-{ts}` / `{name}-d{depth}-retry{n}-{ts}` — no
|
|
77
|
+
* fork- prefix at all; the -d{depth} suffix must be stripped too,
|
|
78
|
+
* or every fork resolves to e.g. `web-d1` and matches nothing.
|
|
79
|
+
*/
|
|
80
|
+
export function shortAgentName(full: string): string {
|
|
81
|
+
return full
|
|
82
|
+
.replace(/^(spawn|fork)-/, '')
|
|
83
|
+
.replace(/-d\d+(-retry\d+)?-\d+$/, '')
|
|
84
|
+
.replace(/-\d+$/, '')
|
|
85
|
+
.replace(/-retry\d+$/, '');
|
|
86
|
+
}
|
|
87
|
+
|
|
50
88
|
interface AppContext {
|
|
51
89
|
framework: AgentFramework;
|
|
52
90
|
membrane: Membrane;
|
|
@@ -91,7 +129,13 @@ interface TuiState {
|
|
|
91
129
|
* peek-proc — peek into a child process's live event stream (new)
|
|
92
130
|
*/
|
|
93
131
|
viewMode: 'chat' | 'fleet' | 'peek' | 'peek-proc';
|
|
132
|
+
/** Session-cumulative usage (usage:updated totals across all agents). */
|
|
94
133
|
tokens: TokenUsage;
|
|
134
|
+
/** Root agent's CURRENT context size (per-round input from inference
|
|
135
|
+
* usage events). Deliberately separate from tokens.input — conflating
|
|
136
|
+
* them made the status line oscillate between two different quantities
|
|
137
|
+
* under the same label. */
|
|
138
|
+
ctxTokens: number;
|
|
95
139
|
peekTarget: string | null;
|
|
96
140
|
/** Name of the child process being peeked at (peek-proc mode). */
|
|
97
141
|
peekProcTarget: string | null;
|
|
@@ -182,6 +226,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
182
226
|
subagents: [],
|
|
183
227
|
viewMode: 'chat',
|
|
184
228
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
229
|
+
ctxTokens: 0,
|
|
185
230
|
peekTarget: null,
|
|
186
231
|
peekProcTarget: null,
|
|
187
232
|
peekProcAgent: null,
|
|
@@ -262,6 +307,16 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
262
307
|
});
|
|
263
308
|
let fleetLineCounter = 0;
|
|
264
309
|
|
|
310
|
+
/** Detach AND destroy all fleetBox lines. These views rebuild on every
|
|
311
|
+
* poll tick — remove() without destroy() leaked one native text buffer
|
|
312
|
+
* per line per repaint. */
|
|
313
|
+
function clearFleetBox(): void {
|
|
314
|
+
for (const child of [...fleetBox.getChildren()]) {
|
|
315
|
+
fleetBox.remove(child.id);
|
|
316
|
+
child.destroy();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
265
320
|
const statusLeft = new TextRenderable(renderer, {
|
|
266
321
|
id: 'status-left',
|
|
267
322
|
content: formatStatusLeft(state),
|
|
@@ -307,7 +362,11 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
307
362
|
const INLINE_PASTE_THRESHOLD = 200;
|
|
308
363
|
const pastedTexts: string[] = [];
|
|
309
364
|
function formatPastePlaceholder(n: number, text: string): string {
|
|
310
|
-
|
|
365
|
+
// Square brackets in the head would terminate the `[^\]]*` expansion
|
|
366
|
+
// regex early on submit, mangling the message (pasted JSON/code/links
|
|
367
|
+
// all contain `]`). Substitute them in the *display* head only — the
|
|
368
|
+
// stored paste text is untouched.
|
|
369
|
+
const head = text.replace(/\s+/g, ' ').trim().slice(0, 30).replace(/[[\]]/g, '·');
|
|
311
370
|
const lines = text.split(/\r?\n/).length;
|
|
312
371
|
const sizeHint = lines > 1 ? `${text.length}ch, ${lines}L` : `${text.length}ch`;
|
|
313
372
|
return `[paste #${n}: "${head}…" ${sizeHint}]`;
|
|
@@ -343,8 +402,13 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
343
402
|
|
|
344
403
|
// ── Agent observability maps ──────────────────────────────────────
|
|
345
404
|
|
|
346
|
-
/** Accumulated transcript per agent (text output + tool calls).
|
|
405
|
+
/** Accumulated transcript per agent (text output + tool calls). Retention
|
|
406
|
+
* is capped; the synesthete summarizer only ever reads the last 10k. */
|
|
347
407
|
const agentTranscripts = new Map<string, string>();
|
|
408
|
+
const TRANSCRIPT_CAP = 30_000;
|
|
409
|
+
/** Cumulative appended chars per agent — drives the "enough new text to
|
|
410
|
+
* re-summarize" delta, which transcript.length can't once it hits the cap. */
|
|
411
|
+
const transcriptTotalLen = new Map<string, number>();
|
|
348
412
|
|
|
349
413
|
/** Parent tracking: child short name → parent full agent name. */
|
|
350
414
|
const agentParent = new Map<string, string>();
|
|
@@ -356,22 +420,31 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
356
420
|
const summaryCache = new Map<string, string>();
|
|
357
421
|
const summarySnapshotLen = new Map<string, number>();
|
|
358
422
|
const summaryPending = new Set<string>();
|
|
423
|
+
/** Earliest next attempt per agent after a FAILED summary call. Without
|
|
424
|
+
* this, a failing provider (outage, 429 storm) meets the 500ms poll tick
|
|
425
|
+
* and becomes a 2 Hz per-agent inference retry hose — summaryPending only
|
|
426
|
+
* guards concurrency, not the gap between a fast failure and the next tick. */
|
|
427
|
+
const summaryBackoffUntil = new Map<string, number>();
|
|
359
428
|
|
|
360
429
|
const SUMMARY_DELTA = 2000;
|
|
361
430
|
const SUMMARY_WINDOW = 10_000;
|
|
431
|
+
const SUMMARY_FAILURE_BACKOFF_MS = 30_000;
|
|
362
432
|
|
|
363
433
|
function appendTranscript(agent: string, text: string) {
|
|
364
|
-
const
|
|
365
|
-
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);
|
|
366
437
|
}
|
|
367
438
|
|
|
368
439
|
async function generateSummary(agentName: string) {
|
|
369
440
|
if (summaryPending.has(agentName)) return;
|
|
441
|
+
if (Date.now() < (summaryBackoffUntil.get(agentName) ?? 0)) return;
|
|
370
442
|
const transcript = agentTranscripts.get(agentName);
|
|
371
443
|
if (!transcript || transcript.length < 50) return;
|
|
372
444
|
|
|
445
|
+
const totalLen = transcriptTotalLen.get(agentName) ?? 0;
|
|
373
446
|
const lastLen = summarySnapshotLen.get(agentName) ?? 0;
|
|
374
|
-
if (
|
|
447
|
+
if (totalLen - lastLen < SUMMARY_DELTA && summaryCache.has(agentName)) return;
|
|
375
448
|
|
|
376
449
|
summaryPending.add(agentName);
|
|
377
450
|
try {
|
|
@@ -389,10 +462,12 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
389
462
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
|
390
463
|
.map(b => b.text).join('').trim();
|
|
391
464
|
summaryCache.set(agentName, text.length > 60 ? text.slice(0, 57) + '...' : text);
|
|
392
|
-
summarySnapshotLen.set(agentName,
|
|
465
|
+
summarySnapshotLen.set(agentName, totalLen);
|
|
466
|
+
summaryBackoffUntil.delete(agentName);
|
|
393
467
|
if (state.viewMode === 'fleet') updateFleetView();
|
|
394
468
|
} catch {
|
|
395
|
-
//
|
|
469
|
+
// Best-effort display — but never an unthrottled retry loop.
|
|
470
|
+
summaryBackoffUntil.set(agentName, Date.now() + SUMMARY_FAILURE_BACKOFF_MS);
|
|
396
471
|
} finally {
|
|
397
472
|
summaryPending.delete(agentName);
|
|
398
473
|
}
|
|
@@ -402,12 +477,29 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
402
477
|
|
|
403
478
|
let messageCounter = 0;
|
|
404
479
|
|
|
480
|
+
/** Scrollback retention: one TextRenderable per line accretes forever
|
|
481
|
+
* otherwise (render cost degrades long before memory hurts). */
|
|
482
|
+
const SCROLLBACK_CAP = 2000;
|
|
483
|
+
function pruneScrollback() {
|
|
484
|
+
const children = scrollBox.getChildren();
|
|
485
|
+
if (children.length <= SCROLLBACK_CAP) return;
|
|
486
|
+
for (const child of children.slice(0, children.length - SCROLLBACK_CAP)) {
|
|
487
|
+
if (child === currentStreamText) continue; // never destroy the live stream element
|
|
488
|
+
// remove() only detaches; destroy() is what frees the native text
|
|
489
|
+
// buffer. Without it the "cap" bounds render cost but still leaks
|
|
490
|
+
// one native buffer per line.
|
|
491
|
+
scrollBox.remove(child.id);
|
|
492
|
+
child.destroy();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
405
496
|
function addLine(text: string, color: string = WHITE) {
|
|
406
497
|
scrollBox.add(new TextRenderable(renderer, {
|
|
407
498
|
id: `msg-${++messageCounter}`,
|
|
408
499
|
content: text,
|
|
409
500
|
fg: color,
|
|
410
501
|
}));
|
|
502
|
+
pruneScrollback();
|
|
411
503
|
}
|
|
412
504
|
|
|
413
505
|
function updateStatus() {
|
|
@@ -415,7 +507,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
415
507
|
// An active ops alert repaints the whole left segment — a one-cell glyph
|
|
416
508
|
// in default gray is exactly the kind of signal that gets scrolled past.
|
|
417
509
|
statusLeft.fg = opsAlerts.size > 0 ? RED : GRAY;
|
|
418
|
-
statusRight.content = formatTokens(state.tokens, verboseChat) + formatMemStats(getRootCM());
|
|
510
|
+
statusRight.content = formatTokens(state.tokens, verboseChat, state.ctxTokens) + formatMemStats(getRootCM());
|
|
419
511
|
}
|
|
420
512
|
|
|
421
513
|
/** Best-effort handle to the root agent's ContextManager, for stats queries. */
|
|
@@ -440,6 +532,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
440
532
|
fg: WHITE,
|
|
441
533
|
});
|
|
442
534
|
scrollBox.add(currentStreamText);
|
|
535
|
+
pruneScrollback();
|
|
443
536
|
streaming = true;
|
|
444
537
|
}
|
|
445
538
|
|
|
@@ -479,6 +572,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
479
572
|
});
|
|
480
573
|
}
|
|
481
574
|
scrollBox.add(currentStreamText);
|
|
575
|
+
pruneScrollback();
|
|
482
576
|
}
|
|
483
577
|
|
|
484
578
|
function streamToken(text: string) {
|
|
@@ -538,17 +632,19 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
538
632
|
* Clears conversation, reloads messages, restores fleet tree from persisted subagent state.
|
|
539
633
|
*/
|
|
540
634
|
function refreshFromStore() {
|
|
541
|
-
//
|
|
635
|
+
// Reset streaming state BEFORE destroying renderables so nothing can
|
|
636
|
+
// touch a freed native buffer through currentStreamText.
|
|
637
|
+
streaming = false;
|
|
638
|
+
currentStreamText = null;
|
|
639
|
+
currentStreamBuffer = '';
|
|
640
|
+
|
|
641
|
+
// Clear conversation display (destroy frees the native text buffers)
|
|
542
642
|
const children = [...scrollBox.getChildren()];
|
|
543
643
|
for (const child of children) {
|
|
544
644
|
scrollBox.remove(child.id);
|
|
645
|
+
child.destroy();
|
|
545
646
|
}
|
|
546
647
|
messageCounter = 0;
|
|
547
|
-
|
|
548
|
-
// Reset streaming state
|
|
549
|
-
streaming = false;
|
|
550
|
-
currentStreamText = null;
|
|
551
|
-
currentStreamBuffer = '';
|
|
552
648
|
state.status = 'idle';
|
|
553
649
|
state.tool = null;
|
|
554
650
|
|
|
@@ -603,7 +699,8 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
603
699
|
// Index subagents by short name for tree building
|
|
604
700
|
const byName = new Map<string, FleetNode>();
|
|
605
701
|
for (const sa of state.subagents) {
|
|
606
|
-
const fullName = [...(subMod?.activeSubagents.keys() ?? [])]
|
|
702
|
+
const fullName = [...(subMod?.activeSubagents.keys() ?? [])]
|
|
703
|
+
.find(k => k === sa.name || shortAgentName(k) === sa.name) ?? sa.name;
|
|
607
704
|
const node: FleetNode = {
|
|
608
705
|
name: sa.name,
|
|
609
706
|
fullName,
|
|
@@ -619,8 +716,8 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
619
716
|
const parentFullName = agentParent.get(sa.name);
|
|
620
717
|
if (parentFullName && parentFullName !== rootAgentName) {
|
|
621
718
|
// Find the parent's short name
|
|
622
|
-
const parentShort =
|
|
623
|
-
if (
|
|
719
|
+
const parentShort = byName.has(parentFullName) ? parentFullName : shortAgentName(parentFullName);
|
|
720
|
+
if (byName.has(parentShort)) {
|
|
624
721
|
byName.get(parentShort)!.children.push(byName.get(sa.name)!);
|
|
625
722
|
continue;
|
|
626
723
|
}
|
|
@@ -887,8 +984,8 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
887
984
|
// Contextual key hints on the cursor line
|
|
888
985
|
let hints = '';
|
|
889
986
|
if (isCursor) {
|
|
890
|
-
if (node.kind === 'subagent'
|
|
891
|
-
hints = ' ⏎:fold p:peek Del:stop';
|
|
987
|
+
if (node.kind === 'subagent') {
|
|
988
|
+
hints = node.agent?.status === 'running' ? ' ⏎:fold p:peek Del:stop' : ' ⏎:fold p:peek';
|
|
892
989
|
} else if (node.kind === 'fleet-child') {
|
|
893
990
|
const fc = fleetMod?.getChildren().get(node.fleetChildName!);
|
|
894
991
|
hints = fc?.status === 'ready' ? ' ⏎:fold p:peek Del:stop' : ' ⏎:fold';
|
|
@@ -953,7 +1050,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
953
1050
|
const fullName = node.kind === 'fleet-child' || node.kind === 'fleet-child-agent'
|
|
954
1051
|
? null
|
|
955
1052
|
: node.kind === 'researcher' ? rootAgentName
|
|
956
|
-
: [...agentTranscripts.keys()].find(k => k.
|
|
1053
|
+
: [...agentTranscripts.keys()].find(k => k === node.name || shortAgentName(k) === node.name);
|
|
957
1054
|
if (fullName) {
|
|
958
1055
|
const summary = summaryCache.get(fullName);
|
|
959
1056
|
if (summary) {
|
|
@@ -961,7 +1058,8 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
961
1058
|
} else if (summaryPending.has(fullName)) {
|
|
962
1059
|
lines.push({ text: ` ${detail}┈ …`, color: DIM_GRAY });
|
|
963
1060
|
}
|
|
964
|
-
|
|
1061
|
+
// Summary GENERATION is triggered from the poll tick, not here —
|
|
1062
|
+
// rendering must never originate an inference call.
|
|
965
1063
|
}
|
|
966
1064
|
|
|
967
1065
|
// Recurse into children
|
|
@@ -970,13 +1068,33 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
970
1068
|
}
|
|
971
1069
|
}
|
|
972
1070
|
|
|
1071
|
+
/** Transient error notice shown inside the fleet view — the operator is
|
|
1072
|
+
* looking at THIS view when a kill/restart fails, not the chat scrollback. */
|
|
1073
|
+
let fleetNotice: string | null = null;
|
|
1074
|
+
let fleetNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1075
|
+
function showFleetNotice(text: string): void {
|
|
1076
|
+
fleetNotice = text;
|
|
1077
|
+
if (fleetNoticeTimer) clearTimeout(fleetNoticeTimer);
|
|
1078
|
+
fleetNoticeTimer = setTimeout(() => {
|
|
1079
|
+
fleetNotice = null;
|
|
1080
|
+
fleetNoticeTimer = null;
|
|
1081
|
+
if (state.viewMode === 'fleet') updateFleetView();
|
|
1082
|
+
}, 6000);
|
|
1083
|
+
// Also record it in scrollback so it survives after the notice fades.
|
|
1084
|
+
addLine(` ${text}`, RED);
|
|
1085
|
+
if (state.viewMode === 'fleet') updateFleetView();
|
|
1086
|
+
}
|
|
1087
|
+
|
|
973
1088
|
function updateFleetView() {
|
|
974
1089
|
const tree = buildFleetTree();
|
|
975
1090
|
visibleNodeIds = [];
|
|
976
1091
|
visibleNodes.clear();
|
|
977
1092
|
|
|
978
1093
|
const lines: FleetLine[] = [];
|
|
979
|
-
lines.push({ text: '─── Agent Fleet ─── ↑↓:nav ⏎/→:fold p:peek Del:stop r:restart ───', color: GRAY });
|
|
1094
|
+
lines.push({ text: '─── Agent Fleet ─── ↑↓:nav ⏎/→:fold p:peek Del:stop r:restart Esc:chat ───', color: GRAY });
|
|
1095
|
+
if (fleetNotice) {
|
|
1096
|
+
lines.push({ text: ` ⚠ ${fleetNotice}`, color: RED });
|
|
1097
|
+
}
|
|
980
1098
|
lines.push({ text: '', color: GRAY });
|
|
981
1099
|
|
|
982
1100
|
renderNode(tree, 0, lines);
|
|
@@ -989,9 +1107,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
989
1107
|
lines.push({ text: ' Tab: chat', color: DIM_GRAY });
|
|
990
1108
|
|
|
991
1109
|
// Rebuild fleetBox children: clear old, add new per-line renderables
|
|
992
|
-
|
|
993
|
-
fleetBox.remove(child.id);
|
|
994
|
-
}
|
|
1110
|
+
clearFleetBox();
|
|
995
1111
|
for (const line of lines) {
|
|
996
1112
|
fleetBox.add(new TextRenderable(renderer, {
|
|
997
1113
|
id: `fleet-ln-${++fleetLineCounter}`,
|
|
@@ -1069,8 +1185,15 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1069
1185
|
lines.push({ text: '', color: GRAY });
|
|
1070
1186
|
|
|
1071
1187
|
const log = procPeekLogs.get(key);
|
|
1188
|
+
// In-progress token line (not yet flushed to log) — appended after the
|
|
1189
|
+
// tail below; fetched first so the tail budget accounts for its row.
|
|
1190
|
+
const pending = procPeekTokenLine.get(key);
|
|
1072
1191
|
if (log && log.length > 0) {
|
|
1073
|
-
|
|
1192
|
+
// Same viewport accounting as updatePeekView: header lines already in
|
|
1193
|
+
// `lines`, the "N lines above" marker, and the pending token line all
|
|
1194
|
+
// occupy rows of the terminalHeight - 3 the fleetBox actually has.
|
|
1195
|
+
const available = renderer.terminalHeight - 3;
|
|
1196
|
+
const maxLines = Math.max(5, available - lines.length - 1 - (pending ? 1 : 0));
|
|
1074
1197
|
const tail = log.slice(-maxLines);
|
|
1075
1198
|
if (log.length > maxLines) {
|
|
1076
1199
|
lines.push({ text: ` ┈ (${log.length - maxLines} lines above)`, color: DIM_GRAY });
|
|
@@ -1084,13 +1207,11 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1084
1207
|
: ' (no events yet — child may be idle)', color: DIM_GRAY });
|
|
1085
1208
|
}
|
|
1086
1209
|
|
|
1087
|
-
// In-progress token line (not yet flushed to log) — shows live streaming output.
|
|
1088
|
-
const pending = procPeekTokenLine.get(key);
|
|
1089
1210
|
if (pending) {
|
|
1090
1211
|
lines.push({ text: ` ${pending}`, color: WHITE });
|
|
1091
1212
|
}
|
|
1092
1213
|
|
|
1093
|
-
|
|
1214
|
+
clearFleetBox();
|
|
1094
1215
|
for (const line of lines) {
|
|
1095
1216
|
fleetBox.add(new TextRenderable(renderer, {
|
|
1096
1217
|
id: `fleet-ln-${++fleetLineCounter}`,
|
|
@@ -1123,19 +1244,28 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1123
1244
|
// doesn't have to wait for the next event to see history.
|
|
1124
1245
|
if (!procPeekLogs.has(key)) procPeekLogs.set(key, []);
|
|
1125
1246
|
const log = procPeekLogs.get(key)!;
|
|
1247
|
+
const lastEvt = child.events[child.events.length - 1];
|
|
1126
1248
|
if (log.length === 0) {
|
|
1127
1249
|
for (const evt of child.events) {
|
|
1128
1250
|
if (!matches(evt)) continue;
|
|
1129
1251
|
const formatted = formatWireEvent(evt);
|
|
1130
1252
|
if (formatted) log.push(formatted);
|
|
1131
1253
|
}
|
|
1254
|
+
} else if (lastEvt && procPeekLastEvent.get(key) !== lastEvt) {
|
|
1255
|
+
// The subscription was torn down when the user left this peek; events
|
|
1256
|
+
// the child emitted since then are absent from this log. Mark the gap
|
|
1257
|
+
// instead of silently resuming from "now". (Object identity on the
|
|
1258
|
+
// child's ring buffer is the tell — same process, same array.)
|
|
1259
|
+
appendProcPeekLog(key, '┈ re-attached — events emitted while detached are not shown ┈', DIM_GRAY);
|
|
1132
1260
|
}
|
|
1261
|
+
if (lastEvt) procPeekLastEvent.set(key, lastEvt);
|
|
1133
1262
|
|
|
1134
1263
|
// Subscribe for live updates. Handle token events with line merging so
|
|
1135
1264
|
// streaming output shows up as a natural-looking line buffer rather
|
|
1136
1265
|
// than one log entry per token.
|
|
1137
1266
|
if (procPeekUnsub) { procPeekUnsub(); procPeekUnsub = null; }
|
|
1138
1267
|
procPeekUnsub = fleetMod.onChildEvent(name, (_childName, evt) => {
|
|
1268
|
+
procPeekLastEvent.set(key, evt);
|
|
1139
1269
|
if (!matches(evt)) return;
|
|
1140
1270
|
const type = typeof evt.type === 'string' ? evt.type : '';
|
|
1141
1271
|
const active = state.viewMode === 'peek-proc'
|
|
@@ -1200,7 +1330,11 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1200
1330
|
|
|
1201
1331
|
function appendPeekLog(name: string, text: string, color: string) {
|
|
1202
1332
|
if (!peekLogs.has(name)) peekLogs.set(name, []);
|
|
1203
|
-
peekLogs.get(name)
|
|
1333
|
+
const log = peekLogs.get(name)!;
|
|
1334
|
+
log.push({ text, color });
|
|
1335
|
+
// Cap to match appendProcPeekLog — these accumulate for EVERY subagent
|
|
1336
|
+
// via the global stream subscription, whether anyone ever peeks or not.
|
|
1337
|
+
if (log.length > 500) log.splice(0, log.length - 500);
|
|
1204
1338
|
}
|
|
1205
1339
|
|
|
1206
1340
|
function cleanupPeek() {
|
|
@@ -1212,9 +1346,10 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1212
1346
|
}
|
|
1213
1347
|
|
|
1214
1348
|
function enterPeek(name: string) {
|
|
1215
|
-
//
|
|
1349
|
+
// Peek any known subagent — finished ones included: their captured log
|
|
1350
|
+
// is exactly what "what did that fork actually do?" needs post-hoc.
|
|
1216
1351
|
const sa = state.subagents.find(s => s.name === name);
|
|
1217
|
-
if (!sa
|
|
1352
|
+
if (!sa) return;
|
|
1218
1353
|
|
|
1219
1354
|
state.viewMode = 'peek';
|
|
1220
1355
|
state.peekTarget = name;
|
|
@@ -1261,7 +1396,10 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1261
1396
|
|
|
1262
1397
|
const sa = state.subagents.find(s => s.name === name);
|
|
1263
1398
|
if (sa) {
|
|
1264
|
-
|
|
1399
|
+
// Finished subagents (peekable since the enterPeek relaxation) show
|
|
1400
|
+
// their final runtime, not a clock that keeps counting after done.
|
|
1401
|
+
const endTime = sa.completedAt ?? Date.now();
|
|
1402
|
+
const elapsed = Math.floor((endTime - sa.startedAt) / 1000);
|
|
1265
1403
|
const min = Math.floor(elapsed / 60);
|
|
1266
1404
|
const sec = elapsed % 60;
|
|
1267
1405
|
const timeStr = min > 0 ? `${min}m${sec}s` : `${sec}s`;
|
|
@@ -1281,9 +1419,14 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1281
1419
|
|
|
1282
1420
|
lines.push({ text: '', color: GRAY });
|
|
1283
1421
|
|
|
1284
|
-
// Accumulated event log — show last N lines
|
|
1422
|
+
// Accumulated event log — show last N lines. Tail budget: fleetBox shows
|
|
1423
|
+
// terminalHeight - 3 rows (status bar, input row, box paddingTop), and
|
|
1424
|
+
// everything already in `lines` plus the "N lines above" marker must fit
|
|
1425
|
+
// too — otherwise the NEWEST lines, the whole point of a tail-follow
|
|
1426
|
+
// view, get clipped off the bottom of the box.
|
|
1285
1427
|
if (log && log.length > 0) {
|
|
1286
|
-
const
|
|
1428
|
+
const available = renderer.terminalHeight - 3;
|
|
1429
|
+
const maxLines = Math.max(5, available - lines.length - 1);
|
|
1287
1430
|
const tail = log.slice(-maxLines);
|
|
1288
1431
|
if (log.length > maxLines) {
|
|
1289
1432
|
lines.push({ text: ` ┈ (${log.length - maxLines} lines above)`, color: DIM_GRAY });
|
|
@@ -1296,9 +1439,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1296
1439
|
}
|
|
1297
1440
|
|
|
1298
1441
|
// Rebuild fleetBox children
|
|
1299
|
-
|
|
1300
|
-
fleetBox.remove(child.id);
|
|
1301
|
-
}
|
|
1442
|
+
clearFleetBox();
|
|
1302
1443
|
for (const line of lines) {
|
|
1303
1444
|
fleetBox.add(new TextRenderable(renderer, {
|
|
1304
1445
|
id: `fleet-ln-${++fleetLineCounter}`,
|
|
@@ -1373,7 +1514,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1373
1514
|
if (prev) {
|
|
1374
1515
|
const delta = Math.ceil(content.length / 4);
|
|
1375
1516
|
agentContextTokens.set(agent, prev + delta);
|
|
1376
|
-
const short = agent
|
|
1517
|
+
const short = shortAgentName(agent);
|
|
1377
1518
|
if (short !== agent) agentContextTokens.set(short, prev + delta);
|
|
1378
1519
|
}
|
|
1379
1520
|
}
|
|
@@ -1388,16 +1529,16 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1388
1529
|
} | undefined;
|
|
1389
1530
|
if (agent && roundUsage?.input) {
|
|
1390
1531
|
agentContextTokens.set(agent, roundUsage.input);
|
|
1391
|
-
const short = agent
|
|
1532
|
+
const short = shortAgentName(agent);
|
|
1392
1533
|
if (short !== agent) agentContextTokens.set(short, roundUsage.input);
|
|
1393
1534
|
if (state.viewMode === 'fleet') updateFleetView();
|
|
1394
1535
|
}
|
|
1395
|
-
// Update root
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1536
|
+
// Update the root agent's context-size readout. Only ctxTokens —
|
|
1537
|
+
// per-round numbers must not overwrite the session totals that
|
|
1538
|
+
// usage:updated owns (cache fields included: per-round cacheRead is
|
|
1539
|
+
// this round's hit, not the cumulative the Σ segment displays).
|
|
1540
|
+
if (agent === rootAgentName && roundUsage?.input !== undefined) {
|
|
1541
|
+
state.ctxTokens = roundUsage.input;
|
|
1401
1542
|
updateStatus();
|
|
1402
1543
|
}
|
|
1403
1544
|
break;
|
|
@@ -1408,8 +1549,9 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1408
1549
|
// Track context size per agent (store by both full and short name)
|
|
1409
1550
|
if (usage && agent && usage.input) {
|
|
1410
1551
|
agentContextTokens.set(agent, usage.input);
|
|
1411
|
-
const short = agent
|
|
1552
|
+
const short = shortAgentName(agent);
|
|
1412
1553
|
if (short !== agent) agentContextTokens.set(short, usage.input);
|
|
1554
|
+
if (agent === rootAgentName) state.ctxTokens = usage.input;
|
|
1413
1555
|
}
|
|
1414
1556
|
|
|
1415
1557
|
if (agent === rootAgentName) {
|
|
@@ -1462,7 +1604,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1462
1604
|
updateStatus();
|
|
1463
1605
|
} else {
|
|
1464
1606
|
if (agent) {
|
|
1465
|
-
const short = agent
|
|
1607
|
+
const short = shortAgentName(agent);
|
|
1466
1608
|
subagentPhase.set(short, 'failed');
|
|
1467
1609
|
}
|
|
1468
1610
|
addLine(`[${agent}] Error: ${event.error}`, DIM_GRAY);
|
|
@@ -1498,9 +1640,9 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1498
1640
|
if (streaming) endStream();
|
|
1499
1641
|
if (!backgrounded) addLine(`[tools] ${names}`, YELLOW);
|
|
1500
1642
|
} else {
|
|
1501
|
-
const short = (agent ?? '')
|
|
1643
|
+
const short = shortAgentName(agent ?? '');
|
|
1502
1644
|
addLine(` [${short}] ${names}`, DIM_GRAY);
|
|
1503
|
-
const sa = state.subagents.find(s =>
|
|
1645
|
+
const sa = state.subagents.find(s => s.name === agent || s.name === short);
|
|
1504
1646
|
if (sa) {
|
|
1505
1647
|
sa.toolCallsCount += calls.length;
|
|
1506
1648
|
sa.statusMessage = names.split('--').pop();
|
|
@@ -1542,7 +1684,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1542
1684
|
// Show file operations in chat
|
|
1543
1685
|
const toolInput = event.input as Record<string, unknown> | undefined;
|
|
1544
1686
|
if (toolInput && (agent === rootAgentName || verboseChat)) {
|
|
1545
|
-
const short = agent === rootAgentName ? '' : `[${(agent ?? '')
|
|
1687
|
+
const short = agent === rootAgentName ? '' : `[${shortAgentName(agent ?? '')}] `;
|
|
1546
1688
|
if (tool === 'files:write' && toolInput.filePath) {
|
|
1547
1689
|
const fp = String(toolInput.filePath);
|
|
1548
1690
|
addLine(` ${short}write ${fp}`, DIM_GRAY);
|
|
@@ -1572,7 +1714,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1572
1714
|
if (agent === rootAgentName) {
|
|
1573
1715
|
addLine(`[tool error] ${tool}: ${error}`, RED);
|
|
1574
1716
|
} else if (agent) {
|
|
1575
|
-
const short = agent
|
|
1717
|
+
const short = shortAgentName(agent);
|
|
1576
1718
|
addLine(` [${short}] tool error: ${tool}: ${error}`, RED);
|
|
1577
1719
|
}
|
|
1578
1720
|
break;
|
|
@@ -1608,19 +1750,27 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1608
1750
|
// one. Drives the unified subagent-tree rendering: fleet children appear as
|
|
1609
1751
|
// first-class nodes alongside in-process subagents, with the same readouts
|
|
1610
1752
|
// (phase, context tokens, tool calls). See UNIFIED-TREE-PLAN.md.
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1753
|
+
// Rebuilt (not just re-scanned) on session switch: its IPC subscriptions
|
|
1754
|
+
// live on the fleetMod it was constructed with, so an aggregator from the
|
|
1755
|
+
// old session silently stops receiving events.
|
|
1756
|
+
let treeAggregator: FleetTreeAggregator | null = null;
|
|
1757
|
+
function initTreeAggregator(): void {
|
|
1758
|
+
treeAggregator?.dispose();
|
|
1759
|
+
treeAggregator = fleetMod ? new FleetTreeAggregator(fleetMod) : null;
|
|
1760
|
+
if (treeAggregator && fleetMod) {
|
|
1761
|
+
// Register any fleet children that already exist (e.g. autoStart entries
|
|
1762
|
+
// brought up before TUI init, or reattached survivors after parent restart).
|
|
1763
|
+
for (const childName of fleetMod.getChildren().keys()) {
|
|
1764
|
+
treeAggregator.registerChild(childName);
|
|
1765
|
+
}
|
|
1766
|
+
// Re-render fleet view when any tracked child's tree changes — gives live
|
|
1767
|
+
// updates without polling.
|
|
1768
|
+
treeAggregator.onTreeUpdate(() => {
|
|
1769
|
+
if (state.viewMode === 'fleet') updateFleetView();
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1623
1772
|
}
|
|
1773
|
+
initTreeAggregator();
|
|
1624
1774
|
|
|
1625
1775
|
// Fleet-child ops alerts: a quarantine klaxon or refusal streak inside a
|
|
1626
1776
|
// child process must be as loud here as a local one — the operator is
|
|
@@ -1651,6 +1801,9 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1651
1801
|
}
|
|
1652
1802
|
// In-progress token line buffer per child (flushed to log on newline / next event).
|
|
1653
1803
|
const procPeekTokenLine = new Map<string, string>();
|
|
1804
|
+
/** Last event (by object identity) each peek key has processed — detects
|
|
1805
|
+
* "events arrived while detached" on re-entry so the gap can be marked. */
|
|
1806
|
+
const procPeekLastEvent = new Map<string, WireEvent>();
|
|
1654
1807
|
let procPeekUnsub: (() => void) | null = null;
|
|
1655
1808
|
|
|
1656
1809
|
function appendProcPeekLog(name: string, text: string, color: string): void {
|
|
@@ -1782,11 +1935,12 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1782
1935
|
agentContextTokens.set(name, event.lastInputTokens);
|
|
1783
1936
|
}
|
|
1784
1937
|
|
|
1785
|
-
//
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1938
|
+
// Surface the result in chat — a fork that completes with no
|
|
1939
|
+
// visible line looks like it never returned. Verbose shows more
|
|
1940
|
+
// of the summary; terse shows a shorter, dimmer line.
|
|
1941
|
+
const limit = verboseChat ? 200 : 100;
|
|
1942
|
+
const chatTruncated = summary.length > limit ? summary.slice(0, limit - 3) + '...' : summary;
|
|
1943
|
+
addLine(` ◀ [${name}] ${chatTruncated}`, verboseChat ? CYAN : DIM_GRAY);
|
|
1790
1944
|
break;
|
|
1791
1945
|
}
|
|
1792
1946
|
}
|
|
@@ -1799,6 +1953,42 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1799
1953
|
});
|
|
1800
1954
|
subagentStreamUnsubs.push(unsub);
|
|
1801
1955
|
}
|
|
1956
|
+
|
|
1957
|
+
/**
|
|
1958
|
+
* Per-session observability reset. Everything here accumulates from trace
|
|
1959
|
+
* and IPC subscriptions bound to the CURRENT framework; after a session
|
|
1960
|
+
* switch the old subscriptions feed dead modules and the caches describe
|
|
1961
|
+
* agents that no longer exist. Worse, a new session's subagent with a
|
|
1962
|
+
* previously-seen name would never re-subscribe (subscribeSubagentStream
|
|
1963
|
+
* early-returns on the subscribedSubagents guard).
|
|
1964
|
+
*/
|
|
1965
|
+
function resetObservabilityState(): void {
|
|
1966
|
+
for (const unsub of subagentStreamUnsubs) unsub();
|
|
1967
|
+
subagentStreamUnsubs.length = 0;
|
|
1968
|
+
subscribedSubagents.clear();
|
|
1969
|
+
peekLogs.clear();
|
|
1970
|
+
peekCurrentTool.clear();
|
|
1971
|
+
peekTokenLine.clear();
|
|
1972
|
+
procPeekLogs.clear();
|
|
1973
|
+
procPeekTokenLine.clear();
|
|
1974
|
+
procPeekLastEvent.clear();
|
|
1975
|
+
agentTranscripts.clear();
|
|
1976
|
+
transcriptTotalLen.clear();
|
|
1977
|
+
agentContextTokens.clear();
|
|
1978
|
+
agentParent.clear();
|
|
1979
|
+
summaryCache.clear();
|
|
1980
|
+
summarySnapshotLen.clear();
|
|
1981
|
+
summaryPending.clear();
|
|
1982
|
+
summaryBackoffUntil.clear();
|
|
1983
|
+
subagentPhase.clear();
|
|
1984
|
+
state.subagents = [];
|
|
1985
|
+
seenFleetHeaders.clear();
|
|
1986
|
+
expandedNodes.clear();
|
|
1987
|
+
expandedNodes.add(rootAgentName);
|
|
1988
|
+
fleetCursor = 0;
|
|
1989
|
+
initTreeAggregator();
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1802
1992
|
const pollTimer = setInterval(() => {
|
|
1803
1993
|
// Animate spinner when researcher is active (not just on token events)
|
|
1804
1994
|
if (state.status !== 'idle' && state.status !== 'error') {
|
|
@@ -1826,6 +2016,16 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1826
2016
|
}
|
|
1827
2017
|
if (state.viewMode === 'peek-proc') updatePeekProcView();
|
|
1828
2018
|
}
|
|
2019
|
+
// Synesthete summaries for the fleet view. Triggered here — NOT from the
|
|
2020
|
+
// render path — so a repaint can never originate a Haiku call.
|
|
2021
|
+
// generateSummary self-throttles (pending guard + 2k-char delta).
|
|
2022
|
+
if (state.viewMode === 'fleet') {
|
|
2023
|
+
generateSummary(rootAgentName);
|
|
2024
|
+
for (const sa of state.subagents) {
|
|
2025
|
+
const full = [...agentTranscripts.keys()].find(k => k === sa.name || shortAgentName(k) === sa.name);
|
|
2026
|
+
if (full) generateSummary(full);
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
1829
2029
|
}, 500);
|
|
1830
2030
|
|
|
1831
2031
|
// ── Keyboard ───────────────────────────────────────────────────────
|
|
@@ -1855,6 +2055,10 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1855
2055
|
return;
|
|
1856
2056
|
}
|
|
1857
2057
|
if (key.ctrl && key.name === 'c') {
|
|
2058
|
+
// Same semantics as /quit: children running → confirm first. A second
|
|
2059
|
+
// Ctrl+C while the prompt is pending force-quits (kills children) —
|
|
2060
|
+
// preserves "mash Ctrl+C to really exit" muscle memory.
|
|
2061
|
+
if (!state.pendingQuitConfirm && promptQuitConfirmIfNeeded()) return;
|
|
1858
2062
|
cleanup();
|
|
1859
2063
|
return;
|
|
1860
2064
|
}
|
|
@@ -1866,7 +2070,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1866
2070
|
// Ctrl+B: push to background — detach any blocking sync subagents and/or
|
|
1867
2071
|
// background the researcher's current inference (stop displaying tokens,
|
|
1868
2072
|
// re-enable input; result appears as message when done)
|
|
1869
|
-
if (key.ctrl && key.name === 'b' && state.viewMode === 'chat') {
|
|
2073
|
+
if (key.ctrl && key.name === 'b' && (state.viewMode === 'chat' || state.viewMode === 'fleet')) {
|
|
1870
2074
|
let acted = false;
|
|
1871
2075
|
|
|
1872
2076
|
// 1. Detach any blocking sync subagents
|
|
@@ -1939,7 +2143,10 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1939
2143
|
|
|
1940
2144
|
// Fleet view navigation
|
|
1941
2145
|
if (state.viewMode === 'fleet') {
|
|
1942
|
-
if (key.name === '
|
|
2146
|
+
if (key.name === 'escape') {
|
|
2147
|
+
switchView('chat');
|
|
2148
|
+
updateStatus();
|
|
2149
|
+
} else if (key.name === 'up') {
|
|
1943
2150
|
fleetCursor = Math.max(0, fleetCursor - 1);
|
|
1944
2151
|
updateFleetView();
|
|
1945
2152
|
} else if (key.name === 'down') {
|
|
@@ -1981,9 +2188,14 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
1981
2188
|
if (node && node.kind !== 'researcher') {
|
|
1982
2189
|
if (node.kind === 'fleet-child' && fleetMod) {
|
|
1983
2190
|
const childName = node.fleetChildName!;
|
|
2191
|
+
// handleToolCall resolves with {success:false, error} rather than
|
|
2192
|
+
// throwing — a bare .catch() here used to swallow every failure.
|
|
1984
2193
|
fleetMod.handleToolCall({ id: `tui-kill-${Date.now()}`, name: 'kill', input: { name: childName } })
|
|
1985
|
-
.then(() =>
|
|
1986
|
-
|
|
2194
|
+
.then((res) => {
|
|
2195
|
+
if (!res.success) showFleetNotice(`stop ${childName} failed: ${res.error ?? 'unknown'}`);
|
|
2196
|
+
updateFleetView();
|
|
2197
|
+
})
|
|
2198
|
+
.catch((err: unknown) => showFleetNotice(`stop ${childName} failed: ${String(err)}`));
|
|
1987
2199
|
} else if (node.kind === 'subagent') {
|
|
1988
2200
|
const sa = state.subagents.find(s => s.name === nodeId);
|
|
1989
2201
|
if (sa?.status === 'running' && subMod) {
|
|
@@ -2001,8 +2213,11 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
2001
2213
|
if (node?.kind === 'fleet-child' && fleetMod) {
|
|
2002
2214
|
const childName = node.fleetChildName!;
|
|
2003
2215
|
fleetMod.handleToolCall({ id: `tui-restart-${Date.now()}`, name: 'restart', input: { name: childName } })
|
|
2004
|
-
.then(() =>
|
|
2005
|
-
|
|
2216
|
+
.then((res) => {
|
|
2217
|
+
if (!res.success) showFleetNotice(`restart ${childName} failed: ${res.error ?? 'unknown'}`);
|
|
2218
|
+
updateFleetView();
|
|
2219
|
+
})
|
|
2220
|
+
.catch((err: unknown) => showFleetNotice(`restart ${childName} failed: ${String(err)}`));
|
|
2006
2221
|
}
|
|
2007
2222
|
}
|
|
2008
2223
|
}
|
|
@@ -2012,57 +2227,89 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
2012
2227
|
|
|
2013
2228
|
let resolveExit: (() => void) | null = null;
|
|
2014
2229
|
|
|
2230
|
+
/**
|
|
2231
|
+
* If fleet children are alive, print the quit-confirm prompt, arm
|
|
2232
|
+
* pendingQuitConfirm, and return true (caller should NOT exit yet).
|
|
2233
|
+
* Returns false when nothing is running — safe to exit immediately.
|
|
2234
|
+
* Shared by /quit and Ctrl+C so both exit paths have the same semantics.
|
|
2235
|
+
*/
|
|
2236
|
+
function promptQuitConfirmIfNeeded(): boolean {
|
|
2237
|
+
const running = fleetMod
|
|
2238
|
+
? [...fleetMod.getChildren().values()].filter((c) => c.status === 'ready' || c.status === 'starting')
|
|
2239
|
+
: [];
|
|
2240
|
+
if (running.length === 0) return false;
|
|
2241
|
+
if (state.viewMode !== 'chat') {
|
|
2242
|
+
cleanupPeek();
|
|
2243
|
+
cleanupPeekProc();
|
|
2244
|
+
switchView('chat');
|
|
2245
|
+
}
|
|
2246
|
+
addLine(` ${running.length} child${running.length > 1 ? 'ren' : ''} still running: ${running.map(c => c.name).join(', ')}`, YELLOW);
|
|
2247
|
+
addLine(' Stop them before exit? [y/N/d] — y=kill gracefully, d=detach and leave running, anything else cancels', YELLOW);
|
|
2248
|
+
state.pendingQuitConfirm = true;
|
|
2249
|
+
updateStatus();
|
|
2250
|
+
return true;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2015
2253
|
function handleSubmit() {
|
|
2016
2254
|
const raw = ((input as any).plainText as string).trim();
|
|
2017
2255
|
(input as any).clear();
|
|
2018
2256
|
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
//
|
|
2022
|
-
|
|
2023
|
-
? raw.replace(/\[paste #(\d+):[^\]]*\]/g, (m, n) => pastedTexts[parseInt(n, 10) - 1] ?? m)
|
|
2024
|
-
: raw;
|
|
2025
|
-
pastedTexts.length = 0;
|
|
2026
|
-
|
|
2027
|
-
// Resolve a pending /quit confirmation prompt first.
|
|
2257
|
+
// Resolve a pending /quit confirmation prompt first — BEFORE the
|
|
2258
|
+
// empty-input early return, or plain Enter (the advertised [y/N/d]
|
|
2259
|
+
// default: cancel) would silently do nothing and leave the prompt
|
|
2260
|
+
// armed to swallow the user's next real message.
|
|
2028
2261
|
if (state.pendingQuitConfirm) {
|
|
2029
2262
|
state.pendingQuitConfirm = false;
|
|
2030
|
-
const
|
|
2031
|
-
if (
|
|
2032
|
-
addLine(' (
|
|
2263
|
+
const action = resolveQuitConfirm(raw);
|
|
2264
|
+
if (action === 'kill') {
|
|
2265
|
+
addLine(' (stopping children and exiting...)', GRAY);
|
|
2266
|
+
cleanup();
|
|
2033
2267
|
return;
|
|
2034
2268
|
}
|
|
2035
|
-
if (
|
|
2269
|
+
if (action === 'detach') {
|
|
2036
2270
|
fleetMod?.setDetachMode(true);
|
|
2037
2271
|
addLine(' (detaching — children stay running; next parent will adopt them)', CYAN);
|
|
2038
2272
|
cleanup();
|
|
2039
2273
|
return;
|
|
2040
2274
|
}
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2275
|
+
if (action === 'cancel-keep-input') {
|
|
2276
|
+
// The user typed a real message at the prompt. Cancel the quit and
|
|
2277
|
+
// put the message BACK — clearing it (with its paste referents)
|
|
2278
|
+
// while advising "type it again" would destroy a paste the user
|
|
2279
|
+
// cannot re-type. pastedTexts is deliberately left intact so the
|
|
2280
|
+
// restored placeholders still expand on the next submit.
|
|
2281
|
+
(input as any).insertText(raw);
|
|
2282
|
+
addLine(' (quit cancelled — your message was restored to the input; press Enter to send)', GRAY);
|
|
2283
|
+
} else {
|
|
2284
|
+
pastedTexts.length = 0;
|
|
2285
|
+
addLine(' (quit cancelled)', GRAY);
|
|
2286
|
+
}
|
|
2044
2287
|
return;
|
|
2045
2288
|
}
|
|
2046
2289
|
|
|
2290
|
+
if (!raw) { pastedTexts.length = 0; return; }
|
|
2291
|
+
|
|
2292
|
+
// Expand paste placeholders
|
|
2293
|
+
const text = pastedTexts.length > 0
|
|
2294
|
+
? raw.replace(/\[paste #(\d+):[^\]]*\]/g, (m, n) => pastedTexts[parseInt(n, 10) - 1] ?? m)
|
|
2295
|
+
: raw;
|
|
2296
|
+
pastedTexts.length = 0;
|
|
2297
|
+
|
|
2047
2298
|
if (text.startsWith('/')) {
|
|
2048
2299
|
const result = handleCommand(text, app);
|
|
2049
2300
|
if (result.quit) {
|
|
2050
|
-
|
|
2051
|
-
? [...fleetMod.getChildren().values()].filter((c) => c.status === 'ready' || c.status === 'starting')
|
|
2052
|
-
: [];
|
|
2053
|
-
if (running.length > 0) {
|
|
2054
|
-
addLine(` ${running.length} child${running.length > 1 ? 'ren' : ''} still running: ${running.map(c => c.name).join(', ')}`, YELLOW);
|
|
2055
|
-
addLine(' Stop them before exit? [Y/n/d] — Y=kill gracefully, n=cancel quit, d=detach and leave running', YELLOW);
|
|
2056
|
-
state.pendingQuitConfirm = true;
|
|
2057
|
-
return;
|
|
2058
|
-
}
|
|
2301
|
+
if (promptQuitConfirmIfNeeded()) return;
|
|
2059
2302
|
cleanup();
|
|
2060
2303
|
return;
|
|
2061
2304
|
}
|
|
2062
|
-
if (text === '/clear') {
|
|
2305
|
+
if (text === '/clear' || text.startsWith('/clear ')) {
|
|
2306
|
+
// End any live stream first — its renderable is about to be
|
|
2307
|
+
// destroyed, and streamToken must not write to a freed buffer.
|
|
2308
|
+
if (streaming) endStream();
|
|
2063
2309
|
const children = [...scrollBox.getChildren()];
|
|
2064
2310
|
for (const child of children) {
|
|
2065
2311
|
scrollBox.remove(child.id);
|
|
2312
|
+
child.destroy();
|
|
2066
2313
|
}
|
|
2067
2314
|
} else {
|
|
2068
2315
|
for (const l of result.lines) {
|
|
@@ -2103,12 +2350,14 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
2103
2350
|
subMod = app.framework.getAllModules().find(m => m.name === 'subagent') as SubagentModule | undefined;
|
|
2104
2351
|
fleetMod = app.framework.getAllModules().find(m => m.name === 'fleet') as FleetModule | undefined;
|
|
2105
2352
|
subscribeFleetOps();
|
|
2353
|
+
resetObservabilityState();
|
|
2106
2354
|
app.framework.onTrace(onTrace as (e: unknown) => void);
|
|
2107
2355
|
|
|
2108
2356
|
const session = app.sessionManager.getActiveSession();
|
|
2109
2357
|
refreshFromStore();
|
|
2110
2358
|
addLine(`Session: ${session?.name ?? 'unknown'}`, GRAY);
|
|
2111
2359
|
state.tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
2360
|
+
state.ctxTokens = 0;
|
|
2112
2361
|
// Alerts describe the OLD session's strategy/agents; the new
|
|
2113
2362
|
// session's own klaxons re-fire within their alarm interval if the
|
|
2114
2363
|
// condition still holds there. A stale alert with no reachable
|
|
@@ -2184,6 +2433,7 @@ export async function runTui(app: AppContext): Promise<void> {
|
|
|
2184
2433
|
function cleanup() {
|
|
2185
2434
|
cleanupPeek();
|
|
2186
2435
|
cleanupPeekProc();
|
|
2436
|
+
if (fleetNoticeTimer) clearTimeout(fleetNoticeTimer);
|
|
2187
2437
|
fleetOpsUnsub?.();
|
|
2188
2438
|
treeAggregator?.dispose();
|
|
2189
2439
|
for (const unsub of subagentStreamUnsubs) unsub();
|
|
@@ -2226,7 +2476,12 @@ function formatStatusLeft(
|
|
|
2226
2476
|
bar += ` ${tokStr} tok`;
|
|
2227
2477
|
}
|
|
2228
2478
|
}
|
|
2229
|
-
if (state.tool)
|
|
2479
|
+
if (state.tool) {
|
|
2480
|
+
// A parallel batch of long MCPL-prefixed names would otherwise push the
|
|
2481
|
+
// right status segment (tokens/cost/mem) clean off the row.
|
|
2482
|
+
const tool = state.tool.length > 40 ? state.tool.slice(0, 37) + '…' : state.tool;
|
|
2483
|
+
bar += ` | ${tool}`;
|
|
2484
|
+
}
|
|
2230
2485
|
const running = state.subagents.filter(s => s.status === 'running').length;
|
|
2231
2486
|
if (running > 0) {
|
|
2232
2487
|
bar += ` | ${running} sub`;
|
|
@@ -2246,12 +2501,16 @@ function formatStatusLeft(
|
|
|
2246
2501
|
return bar;
|
|
2247
2502
|
}
|
|
2248
2503
|
|
|
2249
|
-
function formatTokens(tokens: TokenUsage, verbose: boolean): string {
|
|
2504
|
+
function formatTokens(tokens: TokenUsage, verbose: boolean, ctxTokens = 0): string {
|
|
2250
2505
|
const parts: string[] = [];
|
|
2251
2506
|
|
|
2507
|
+
// Current context size first, session totals (Σ) after — two different
|
|
2508
|
+
// quantities, two labels.
|
|
2509
|
+
if (ctxTokens > 0) parts.push(`ctx:${fmtTokens(ctxTokens)}`);
|
|
2510
|
+
|
|
2252
2511
|
const total = tokens.input + tokens.output;
|
|
2253
2512
|
if (total > 0) {
|
|
2254
|
-
let s =
|
|
2513
|
+
let s = `Σ ${fmtTokens(tokens.input)}in ${fmtTokens(tokens.output)}out`;
|
|
2255
2514
|
if (tokens.cacheRead > 0) s += ` ${fmtTokens(tokens.cacheRead)}hit`;
|
|
2256
2515
|
if (tokens.cacheWrite > 0) s += ` ${fmtTokens(tokens.cacheWrite)}write`;
|
|
2257
2516
|
if (tokens.cost && tokens.cost.total > 0) {
|