pi-better-subagents 0.1.17 → 0.1.19
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/finalization.ts +16 -8
- package/index.ts +143 -38
- package/package.json +1 -1
- package/parse.ts +116 -1
- package/registry.ts +3 -0
- package/shared-callback-batcher.ts +320 -0
- package/shared-navigator.ts +159 -36
package/finalization.ts
CHANGED
|
@@ -62,6 +62,13 @@ export function finalizeRun(
|
|
|
62
62
|
if (outcome.incomplete) meta.failureReason = "incomplete-stream";
|
|
63
63
|
meta.exitCode = code;
|
|
64
64
|
meta.endedAt = Date.now();
|
|
65
|
+
const callback = meta.callback !== false;
|
|
66
|
+
if (callback
|
|
67
|
+
&& meta.completionCallbackPendingAt === undefined
|
|
68
|
+
&& meta.completionCallbackSentAt === undefined
|
|
69
|
+
&& meta.completionCallbackSuppressedAt === undefined) {
|
|
70
|
+
meta.completionCallbackPendingAt = meta.endedAt;
|
|
71
|
+
}
|
|
65
72
|
writeMeta(meta);
|
|
66
73
|
|
|
67
74
|
const label = meta.name ? `${meta.name} (${id})` : id;
|
|
@@ -84,10 +91,9 @@ export function finalizeRun(
|
|
|
84
91
|
/* ignore */
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// breaking, but it is NEVER put into content — the result lives in subagent_result.
|
|
94
|
+
// buildCompletionDelivery remains the compatibility formatter for callers and
|
|
95
|
+
// direct finalizer tests. Production callback:true delivery is coalesced by the
|
|
96
|
+
// host wrapper; callback:false never invokes a model-message hook.
|
|
91
97
|
const delivery = buildCompletionDelivery({
|
|
92
98
|
id,
|
|
93
99
|
label,
|
|
@@ -98,10 +104,12 @@ export function finalizeRun(
|
|
|
98
104
|
lifecycleClassification: outcome.classification,
|
|
99
105
|
resultText: r.finalText || r.lastActivity || "",
|
|
100
106
|
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
if (callback) {
|
|
108
|
+
hooks.sendMessage?.(
|
|
109
|
+
{ customType: "subagent-complete", content: delivery.content, display: true },
|
|
110
|
+
delivery.options,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
105
113
|
|
|
106
114
|
return {
|
|
107
115
|
applied: true,
|
package/index.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import { execSync } from "node:child_process";
|
|
15
15
|
import { writeFileSync, mkdirSync, statSync } from "node:fs";
|
|
16
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import * as PiTui from "@earendil-works/pi-tui";
|
|
17
19
|
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
18
20
|
import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
|
|
19
21
|
import { Type } from "@earendil-works/pi-ai";
|
|
@@ -30,7 +32,7 @@ import {
|
|
|
30
32
|
type BackgroundWorkRow,
|
|
31
33
|
} from "./shared-navigator.ts";
|
|
32
34
|
import { spawnDetached, type SpawnResult } from "./spawn.ts";
|
|
33
|
-
import { parseRun,
|
|
35
|
+
import { parseRun, readRunTranscript, resetParseRunCursor, type Usage } from "./parse.ts";
|
|
34
36
|
import { finalizeRun as finalizeRunCore } from "./finalization.ts";
|
|
35
37
|
import { loadConfig, normalizeTools, resolveExtensionPath, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS, DEFAULT_MAX_CONCURRENT } from "./config.ts";
|
|
36
38
|
import { resolveExtensions, extensionArgs } from "./extensions.ts";
|
|
@@ -84,6 +86,7 @@ import {
|
|
|
84
86
|
getSharedCapacityGate,
|
|
85
87
|
} from "./capacity.mjs";
|
|
86
88
|
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
89
|
+
import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
|
|
87
90
|
import {
|
|
88
91
|
text,
|
|
89
92
|
subagentListTool,
|
|
@@ -237,12 +240,58 @@ function stopCurrentSessionSubagents(ctx: ExtensionContext): void {
|
|
|
237
240
|
|
|
238
241
|
function markCompletionCallbackSuppressed(id: string, reason: string, now: number = Date.now()): void {
|
|
239
242
|
const meta = readMeta(id);
|
|
240
|
-
if (!meta || meta.completionCallbackSuppressedAt !== undefined) return;
|
|
243
|
+
if (!meta || meta.completionCallbackSentAt !== undefined || meta.completionCallbackSuppressedAt !== undefined) return;
|
|
241
244
|
meta.completionCallbackSuppressedAt = now;
|
|
242
245
|
meta.completionCallbackSuppressedReason = reason;
|
|
243
246
|
writeMeta(meta);
|
|
244
247
|
}
|
|
245
248
|
|
|
249
|
+
function markCompletionCallbackSent(id: string, now: number): void {
|
|
250
|
+
const meta = readMeta(id);
|
|
251
|
+
if (!meta || meta.completionCallbackSentAt !== undefined || meta.completionCallbackSuppressedAt !== undefined) return;
|
|
252
|
+
meta.completionCallbackSentAt = now;
|
|
253
|
+
writeMeta(meta);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Queue one durable ordinary terminal event on the host-shared batch. */
|
|
257
|
+
function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
|
|
258
|
+
const meta = readMeta(id);
|
|
259
|
+
if (!meta
|
|
260
|
+
|| meta.callback === false
|
|
261
|
+
|| meta.completionCallbackPendingAt === undefined
|
|
262
|
+
|| meta.completionCallbackSentAt !== undefined
|
|
263
|
+
|| meta.completionCallbackSuppressedAt !== undefined) return;
|
|
264
|
+
const label = meta.name ? `${meta.name} (${id})` : id;
|
|
265
|
+
getCallbackBatcher(pi).enqueue({
|
|
266
|
+
source: "subagent",
|
|
267
|
+
id,
|
|
268
|
+
label,
|
|
269
|
+
status: meta.status,
|
|
270
|
+
detailTool: "subagent_result",
|
|
271
|
+
callback: true,
|
|
272
|
+
isDelivered: () => {
|
|
273
|
+
const current = readMeta(id);
|
|
274
|
+
return current?.completionCallbackSentAt !== undefined
|
|
275
|
+
|| current?.completionCallbackSuppressedAt !== undefined;
|
|
276
|
+
},
|
|
277
|
+
getSuppressionReason: () => {
|
|
278
|
+
const current = readMeta(id);
|
|
279
|
+
if (!current) return "subagent metadata is unavailable";
|
|
280
|
+
return callbackSuppressionReason(current);
|
|
281
|
+
},
|
|
282
|
+
onDelivered: (at) => markCompletionCallbackSent(id, at),
|
|
283
|
+
onSuppressed: (reason, at) => markCompletionCallbackSuppressed(id, reason, at),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Recover only records explicitly marked pending; legacy terminal runs never replay. */
|
|
288
|
+
function recoverCompletionCallbacks(pi: ExtensionAPI): void {
|
|
289
|
+
for (const meta of listMetas()) {
|
|
290
|
+
if (!ownedByThisParent(meta)) continue;
|
|
291
|
+
enqueueCompletionCallback(pi, meta.id);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
246
295
|
function markHealthCallbackSuppressed(meta: RunMeta, status: "orphaned" | "lost", reason: string, now: number): void {
|
|
247
296
|
if (status === "orphaned") {
|
|
248
297
|
if (meta.orphanedCallbackSuppressedAt !== undefined) return;
|
|
@@ -409,12 +458,6 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
|
|
|
409
458
|
if (!pi) return;
|
|
410
459
|
if (isHealthCallbackHandled(meta, status)) return;
|
|
411
460
|
|
|
412
|
-
const suppressionReason = callbackSuppressionReason(meta);
|
|
413
|
-
if (suppressionReason) {
|
|
414
|
-
markHealthCallbackSuppressed(meta, status, suppressionReason, now);
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
461
|
const callback = meta.callback !== false;
|
|
419
462
|
const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
420
463
|
const delivery = buildHealthCallbackDelivery({ id: meta.id, label, status, callback });
|
|
@@ -426,20 +469,35 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
|
|
|
426
469
|
writeMeta(meta);
|
|
427
470
|
return;
|
|
428
471
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
472
|
+
void getCallbackBatcher(pi).deliverUrgent({
|
|
473
|
+
source: "subagent",
|
|
474
|
+
id: meta.id,
|
|
475
|
+
label,
|
|
476
|
+
status,
|
|
477
|
+
customType: "subagent-health",
|
|
478
|
+
content: delivery.content,
|
|
479
|
+
isDelivered: () => {
|
|
480
|
+
const current = readMeta(meta.id);
|
|
481
|
+
return current ? isHealthCallbackHandled(current, status) : true;
|
|
482
|
+
},
|
|
483
|
+
getSuppressionReason: () => {
|
|
484
|
+
const current = readMeta(meta.id);
|
|
485
|
+
if (!current) return "subagent metadata is unavailable";
|
|
486
|
+
return callbackSuppressionReason(current);
|
|
487
|
+
},
|
|
488
|
+
onDelivered: (at) => {
|
|
489
|
+
const current = readMeta(meta.id);
|
|
490
|
+
if (!current || isHealthCallbackHandled(current, status)) return;
|
|
491
|
+
if (status === "orphaned") current.orphanedCallbackSentAt = at;
|
|
492
|
+
else current.lostCallbackSentAt = at;
|
|
493
|
+
writeMeta(current);
|
|
494
|
+
},
|
|
495
|
+
onSuppressed: (reason, at) => {
|
|
496
|
+
const current = readMeta(meta.id);
|
|
497
|
+
if (!current || isHealthCallbackHandled(current, status)) return;
|
|
498
|
+
markHealthCallbackSuppressed(current, status, reason, at);
|
|
499
|
+
},
|
|
500
|
+
});
|
|
443
501
|
}
|
|
444
502
|
|
|
445
503
|
/** One reconciliation + durable health-callback recovery pass. */
|
|
@@ -780,7 +838,8 @@ function mainAgentWorkRow(now: number): BackgroundWorkRow {
|
|
|
780
838
|
function subagentWorkDetail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
|
|
781
839
|
const detail = navigatorDetail(id, now);
|
|
782
840
|
if (!detail) return null;
|
|
783
|
-
|
|
841
|
+
void options;
|
|
842
|
+
const transcript = readRunTranscript(id);
|
|
784
843
|
const metadata = [
|
|
785
844
|
{ label: "provider", value: "Subagents" },
|
|
786
845
|
{ label: "model", value: detail.effort ? `${detail.model} · effort ${detail.effort}` : detail.model },
|
|
@@ -798,15 +857,65 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
|
|
|
798
857
|
statusTone: statusTone(detail.status),
|
|
799
858
|
subtitle: detail.currentTool ? `current tool ${detail.currentTool}` : undefined,
|
|
800
859
|
metadata,
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
// activity/result snapshot.
|
|
805
|
-
evidence: { label: "log tail", text: tailLog(id, logTailLines) },
|
|
860
|
+
evidence: { label: "transcript", text: detail.output || "(no transcript yet)" },
|
|
861
|
+
transcript: transcript.entries,
|
|
862
|
+
transcriptDiagnostic: transcript.diagnostic,
|
|
806
863
|
footerActions: [detail.status === "running" || detail.status === "orphaned" ? "x stop" : "x dismiss"],
|
|
807
864
|
};
|
|
808
865
|
}
|
|
809
866
|
|
|
867
|
+
function createSubagentTranscriptComponent(detail: BackgroundWorkDetail, theme: unknown) {
|
|
868
|
+
const ContainerComponent = (PiTui as any).Container;
|
|
869
|
+
const AssistantComponent = (PiCodingAgent as any).AssistantMessageComponent;
|
|
870
|
+
const ToolComponent = (PiCodingAgent as any).ToolExecutionComponent;
|
|
871
|
+
const markdownTheme = typeof (PiCodingAgent as any).getMarkdownTheme === "function"
|
|
872
|
+
? (PiCodingAgent as any).getMarkdownTheme()
|
|
873
|
+
: {};
|
|
874
|
+
if (!ContainerComponent || !AssistantComponent || !ToolComponent) {
|
|
875
|
+
return {
|
|
876
|
+
render: () => (detail.transcript ?? []).flatMap((entry) => entry.type === "assistant"
|
|
877
|
+
? entry.content.filter((block) => block.type === "text").flatMap((block) => String(block.text ?? "").split("\n"))
|
|
878
|
+
: [`[${entry.state}] ${entry.name}`]),
|
|
879
|
+
invalidate() {},
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
const container = new ContainerComponent();
|
|
883
|
+
const ui = { requestRender: () => { try { (uiCtx?.ui as any)?.requestRender?.(); } catch { /* ignore */ } } };
|
|
884
|
+
for (const entry of detail.transcript ?? []) {
|
|
885
|
+
if (entry.type === "assistant") {
|
|
886
|
+
const message = {
|
|
887
|
+
role: "assistant" as const,
|
|
888
|
+
content: entry.content,
|
|
889
|
+
stopReason: entry.streaming ? undefined : "stop",
|
|
890
|
+
timestamp: Date.now(),
|
|
891
|
+
};
|
|
892
|
+
const component = new AssistantComponent(message as any, true, markdownTheme, "Thinking...", 1);
|
|
893
|
+
component.updateContent(message as any, entry.streaming);
|
|
894
|
+
container.addChild(component);
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
const component = new ToolComponent(
|
|
898
|
+
entry.name,
|
|
899
|
+
entry.id ?? `transcript-${entry.name}`,
|
|
900
|
+
entry.args ?? {},
|
|
901
|
+
{ showImages: false },
|
|
902
|
+
undefined,
|
|
903
|
+
ui as any,
|
|
904
|
+
uiCtx?.cwd ?? process.cwd(),
|
|
905
|
+
);
|
|
906
|
+
component.markExecutionStarted();
|
|
907
|
+
component.setArgsComplete();
|
|
908
|
+
if (entry.state === "completed") {
|
|
909
|
+
const result = entry.result && typeof entry.result === "object"
|
|
910
|
+
? entry.result as any
|
|
911
|
+
: { content: entry.result == null ? [] : [{ type: "text", text: String(entry.result) }] };
|
|
912
|
+
component.updateResult({ ...result, isError: entry.isError });
|
|
913
|
+
}
|
|
914
|
+
container.addChild(component);
|
|
915
|
+
}
|
|
916
|
+
return container;
|
|
917
|
+
}
|
|
918
|
+
|
|
810
919
|
function ensureSubagentProvider(): void {
|
|
811
920
|
if (unregisterSubagentProvider) return;
|
|
812
921
|
const provider: BackgroundWorkProvider = {
|
|
@@ -814,6 +923,7 @@ function ensureSubagentProvider(): void {
|
|
|
814
923
|
label: "Subagents",
|
|
815
924
|
priority: 10,
|
|
816
925
|
visibleCount: () => navigatorRunningCount(),
|
|
926
|
+
showSection: (rows) => rows.some((row) => row.status === "running"),
|
|
817
927
|
parentRow: (now) => mainAgentWorkRow(now),
|
|
818
928
|
listRows: (now) => subagentWorkRows(now),
|
|
819
929
|
detail: (id, now, options) => subagentWorkDetail(id, now, options),
|
|
@@ -905,6 +1015,7 @@ function ensureNavigator(ctx: ExtensionContext): void {
|
|
|
905
1015
|
isOpenTrigger: (data: string) => matchesKey(data, Key.left),
|
|
906
1016
|
matchKey: (data: string, keyId: string) => matchesKey(data, keyId),
|
|
907
1017
|
truncate: truncateToWidth,
|
|
1018
|
+
createTranscriptComponent: createSubagentTranscriptComponent,
|
|
908
1019
|
});
|
|
909
1020
|
} catch { /* ignore */ }
|
|
910
1021
|
}
|
|
@@ -935,16 +1046,7 @@ function finalizeRun(pi: ExtensionAPI, ctx: ExtensionContext, id: string, code:
|
|
|
935
1046
|
notify: (message, level) => {
|
|
936
1047
|
try { ctx.ui.notify(message, level); } catch { /* ignore */ }
|
|
937
1048
|
},
|
|
938
|
-
sendMessage: (
|
|
939
|
-
const meta = readMeta(id);
|
|
940
|
-
if (!meta) return;
|
|
941
|
-
const suppressionReason = callbackSuppressionReason(meta);
|
|
942
|
-
if (suppressionReason) {
|
|
943
|
-
markCompletionCallbackSuppressed(id, suppressionReason);
|
|
944
|
-
return;
|
|
945
|
-
}
|
|
946
|
-
pi.sendMessage(message, options);
|
|
947
|
-
},
|
|
1049
|
+
sendMessage: () => enqueueCompletionCallback(pi, id),
|
|
948
1050
|
});
|
|
949
1051
|
}
|
|
950
1052
|
|
|
@@ -1401,6 +1503,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1401
1503
|
catch { mainAgentStartedAt = undefined; }
|
|
1402
1504
|
mainAgentTools.clear();
|
|
1403
1505
|
activeCallbackOrigin = callbackOriginFromContext(ctx);
|
|
1506
|
+
recoverCompletionCallbacks(pi);
|
|
1404
1507
|
// Reload / session switch hardening (#48):
|
|
1405
1508
|
// - Drop any leftover overlay timers/confirm state from a prior session
|
|
1406
1509
|
// (defensive if the host skipped session_shutdown before re-start).
|
|
@@ -1432,6 +1535,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1432
1535
|
|
|
1433
1536
|
pi.on("session_before_switch", () => {
|
|
1434
1537
|
activeCallbackOrigin = undefined;
|
|
1538
|
+
cancelCallbackBatch(pi);
|
|
1435
1539
|
mainAgentStartedAt = undefined;
|
|
1436
1540
|
mainAgentTools.clear();
|
|
1437
1541
|
disposeBackgroundWorkNavigator();
|
|
@@ -1444,6 +1548,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1444
1548
|
// cannot become live process groups with no coordinator.
|
|
1445
1549
|
stopCurrentSessionSubagents(ctx);
|
|
1446
1550
|
activeCallbackOrigin = undefined;
|
|
1551
|
+
cancelCallbackBatch(pi);
|
|
1447
1552
|
mainAgentStartedAt = undefined;
|
|
1448
1553
|
mainAgentTools.clear();
|
|
1449
1554
|
stopTicker();
|
package/package.json
CHANGED
package/parse.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { readBoundedTail, tailTerminalDisplay } from "./shared-log-utils.ts";
|
|
|
30
30
|
import { readAppendedLines, type LogCursor } from "./log-cursor.ts";
|
|
31
31
|
import { logPathFor } from "./registry.ts";
|
|
32
32
|
|
|
33
|
-
interface ContentBlock { type: string; text?: string; name?: string }
|
|
33
|
+
interface ContentBlock { type: string; text?: string; thinking?: string; name?: string }
|
|
34
34
|
interface Cost { total?: number }
|
|
35
35
|
interface MsgUsage { input?: number; output?: number; cacheRead?: number; cost?: Cost }
|
|
36
36
|
interface Msg { role?: string; content?: string | ContentBlock[]; usage?: MsgUsage }
|
|
@@ -130,6 +130,121 @@ export interface ParsedRun {
|
|
|
130
130
|
diagnostics: string[];
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
export type TranscriptEntry =
|
|
134
|
+
| { type: "assistant"; content: ContentBlock[]; streaming: boolean }
|
|
135
|
+
| {
|
|
136
|
+
type: "tool";
|
|
137
|
+
id?: string;
|
|
138
|
+
name: string;
|
|
139
|
+
args?: unknown;
|
|
140
|
+
result?: unknown;
|
|
141
|
+
isError: boolean;
|
|
142
|
+
state: "running" | "completed";
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export interface RunTranscript {
|
|
146
|
+
entries: TranscriptEntry[];
|
|
147
|
+
truncated: boolean;
|
|
148
|
+
diagnostic?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const DEFAULT_TRANSCRIPT_TAIL_BYTES = 2 * 1024 * 1024;
|
|
152
|
+
const DEFAULT_TRANSCRIPT_ENTRIES = 40;
|
|
153
|
+
|
|
154
|
+
function transcriptContent(msg: Msg | undefined): ContentBlock[] {
|
|
155
|
+
if (!msg) return [];
|
|
156
|
+
if (typeof msg.content === "string") {
|
|
157
|
+
return msg.content.trim() ? [{ type: "text", text: msg.content }] : [];
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(msg.content)) return [];
|
|
160
|
+
return msg.content.filter((block) =>
|
|
161
|
+
block && (
|
|
162
|
+
(block.type === "text" && typeof block.text === "string" && block.text.trim()) ||
|
|
163
|
+
(block.type === "thinking" && typeof (block as { thinking?: unknown }).thinking === "string")
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Bounded, display-oriented transcript fold for the subagent detail view. */
|
|
169
|
+
export function readRunTranscript(
|
|
170
|
+
id: string,
|
|
171
|
+
options: { maxBytes?: number; maxEntries?: number } = {},
|
|
172
|
+
): RunTranscript {
|
|
173
|
+
const maxBytes = Math.max(1024, options.maxBytes ?? DEFAULT_TRANSCRIPT_TAIL_BYTES);
|
|
174
|
+
const maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_TRANSCRIPT_ENTRIES);
|
|
175
|
+
const tail = readTail(logPathFor(id), maxBytes);
|
|
176
|
+
if (tail.error) return { entries: [], truncated: false, diagnostic: `Log unreadable: ${tail.error}` };
|
|
177
|
+
|
|
178
|
+
const lines = tail.text.split(/\r?\n/);
|
|
179
|
+
if (tail.truncated) lines.shift();
|
|
180
|
+
const entries: TranscriptEntry[] = [];
|
|
181
|
+
const tools = new Map<string, Extract<TranscriptEntry, { type: "tool" }>>();
|
|
182
|
+
let anonymousTool = 0;
|
|
183
|
+
let liveAssistant: Extract<TranscriptEntry, { type: "assistant" }> | undefined;
|
|
184
|
+
|
|
185
|
+
for (const line of lines) {
|
|
186
|
+
const event = tryParse(line.trim());
|
|
187
|
+
if (!event) continue;
|
|
188
|
+
const type = event.type;
|
|
189
|
+
if (type === "message_end") {
|
|
190
|
+
const message = event.message as Msg | undefined;
|
|
191
|
+
if (message?.role !== "assistant") continue;
|
|
192
|
+
const content = transcriptContent(message);
|
|
193
|
+
if (content.length) entries.push({ type: "assistant", content, streaming: false });
|
|
194
|
+
liveAssistant = undefined;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (type === "message_update") {
|
|
198
|
+
const message = event.message as Msg | undefined;
|
|
199
|
+
if (message?.role !== "assistant") continue;
|
|
200
|
+
const content = transcriptContent(message);
|
|
201
|
+
if (content.length) liveAssistant = { type: "assistant", content, streaming: true };
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (type === "tool_execution_start") {
|
|
205
|
+
const idValue = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
|
|
206
|
+
const key = idValue ?? `anonymous:${anonymousTool++}`;
|
|
207
|
+
const tool: Extract<TranscriptEntry, { type: "tool" }> = {
|
|
208
|
+
type: "tool",
|
|
209
|
+
id: idValue,
|
|
210
|
+
name: typeof event.toolName === "string" ? event.toolName : "unknown",
|
|
211
|
+
args: event.args,
|
|
212
|
+
isError: false,
|
|
213
|
+
state: "running",
|
|
214
|
+
};
|
|
215
|
+
entries.push(tool);
|
|
216
|
+
tools.set(key, tool);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (type === "tool_execution_end") {
|
|
220
|
+
const idValue = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
|
|
221
|
+
let tool = idValue ? tools.get(idValue) : undefined;
|
|
222
|
+
if (!tool && typeof event.toolName === "string") {
|
|
223
|
+
tool = [...tools.values()].reverse().find((candidate) => candidate.name === event.toolName && candidate.state === "running");
|
|
224
|
+
}
|
|
225
|
+
if (!tool) {
|
|
226
|
+
tool = {
|
|
227
|
+
type: "tool",
|
|
228
|
+
id: idValue,
|
|
229
|
+
name: typeof event.toolName === "string" ? event.toolName : "unknown",
|
|
230
|
+
isError: event.isError === true,
|
|
231
|
+
state: "completed",
|
|
232
|
+
};
|
|
233
|
+
entries.push(tool);
|
|
234
|
+
}
|
|
235
|
+
tool.result = event.result;
|
|
236
|
+
tool.isError = event.isError === true;
|
|
237
|
+
tool.state = "completed";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (liveAssistant) entries.push(liveAssistant);
|
|
241
|
+
return {
|
|
242
|
+
entries: entries.slice(-maxEntries),
|
|
243
|
+
truncated: tail.truncated || entries.length > maxEntries,
|
|
244
|
+
diagnostic: tail.truncated ? `Showing the latest ${fmtBytes(maxBytes)} of the transcript.` : undefined,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
133
248
|
/**
|
|
134
249
|
* Authoritative lifecycle evidence scanned from the complete NDJSON stream.
|
|
135
250
|
* Kept separate from parseRun()'s bounded tail so large-log result parsing stays
|
package/registry.ts
CHANGED
|
@@ -102,6 +102,9 @@ export interface RunMeta {
|
|
|
102
102
|
sessionId: string;
|
|
103
103
|
/** Foreground session that is allowed to receive unsolicited callbacks. */
|
|
104
104
|
callbackOrigin?: RunCallbackOrigin;
|
|
105
|
+
/** Durable ordinary-completion callback recovery and successful-handoff markers. */
|
|
106
|
+
completionCallbackPendingAt?: number;
|
|
107
|
+
completionCallbackSentAt?: number;
|
|
105
108
|
completionCallbackSuppressedAt?: number;
|
|
106
109
|
completionCallbackSuppressedReason?: string;
|
|
107
110
|
orphanedCallbackSuppressedAt?: number;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Generated from packages/callback-batcher/index.ts. Do not edit directly.
|
|
2
|
+
export type CallbackSource = "subagent" | "background-task";
|
|
3
|
+
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
4
|
+
|
|
5
|
+
export interface CallbackBatchHost {
|
|
6
|
+
sendMessage(
|
|
7
|
+
message: { customType: string; content: string; display: boolean },
|
|
8
|
+
options: Record<string, unknown>,
|
|
9
|
+
): unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CallbackBatchEvent {
|
|
13
|
+
source: CallbackSource;
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
status: string;
|
|
17
|
+
detailTool: CallbackDetailTool;
|
|
18
|
+
callback?: boolean;
|
|
19
|
+
isDelivered?: () => boolean;
|
|
20
|
+
getSuppressionReason?: () => string | undefined;
|
|
21
|
+
onDelivered?: (at: number) => void;
|
|
22
|
+
onSuppressed?: (reason: string, at: number) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UrgentCallbackEvent {
|
|
26
|
+
source: CallbackSource;
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
status: "orphaned" | "lost" | string;
|
|
30
|
+
customType: string;
|
|
31
|
+
content: string;
|
|
32
|
+
isDelivered?: () => boolean;
|
|
33
|
+
getSuppressionReason?: () => string | undefined;
|
|
34
|
+
onDelivered?: (at: number) => void;
|
|
35
|
+
onSuppressed?: (reason: string, at: number) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CallbackBatcherOptions {
|
|
39
|
+
windowMs?: number;
|
|
40
|
+
retryMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CallbackBatcher {
|
|
44
|
+
enqueue(event: CallbackBatchEvent): boolean;
|
|
45
|
+
flush(): Promise<boolean>;
|
|
46
|
+
deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
|
|
47
|
+
cancel(): void;
|
|
48
|
+
pendingCount(): number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface PendingEvent {
|
|
52
|
+
event: CallbackBatchEvent;
|
|
53
|
+
sequence: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SharedCallbackBatcherState {
|
|
57
|
+
byHost: WeakMap<object, CallbackBatcher>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
|
|
61
|
+
const DEFAULT_WINDOW_MS = 100;
|
|
62
|
+
const DEFAULT_RETRY_MS = 1_000;
|
|
63
|
+
const MAX_LABEL_CHARS = 160;
|
|
64
|
+
const MAX_ID_CHARS = 200;
|
|
65
|
+
const MAX_STATUS_CHARS = 80;
|
|
66
|
+
|
|
67
|
+
export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
|
|
68
|
+
export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
|
|
69
|
+
|
|
70
|
+
export function resolveCallbackBatchWindowMs(
|
|
71
|
+
value: unknown = process.env[CALLBACK_BATCH_WINDOW_ENV],
|
|
72
|
+
): number {
|
|
73
|
+
if (value === undefined || value === null || value === "") return DEFAULT_WINDOW_MS;
|
|
74
|
+
const parsed = Number(value);
|
|
75
|
+
if (!Number.isFinite(parsed)) return DEFAULT_WINDOW_MS;
|
|
76
|
+
return Math.max(0, Math.min(5_000, Math.floor(parsed)));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatCallbackBatch(events: readonly CallbackBatchEvent[]): string {
|
|
80
|
+
const count = events.length;
|
|
81
|
+
const heading = `${count} background completion${count === 1 ? " is" : "s are"} ready:`;
|
|
82
|
+
const rows = events.map((event) => {
|
|
83
|
+
const source = boundedField(event.source, 40);
|
|
84
|
+
const id = boundedField(event.id, MAX_ID_CHARS);
|
|
85
|
+
const label = boundedField(event.label, MAX_LABEL_CHARS);
|
|
86
|
+
const status = boundedField(event.status, MAX_STATUS_CHARS);
|
|
87
|
+
const detail = event.detailTool === "bg_task_status"
|
|
88
|
+
? `bg_task_status id=${id}`
|
|
89
|
+
: `subagent_result id=${JSON.stringify(id)}`;
|
|
90
|
+
return `- source=${source} | id=${id} | label=${JSON.stringify(label)} | status=${status} | inspect: ${detail}`;
|
|
91
|
+
});
|
|
92
|
+
return [
|
|
93
|
+
heading,
|
|
94
|
+
...rows,
|
|
95
|
+
"Retrieve durable results/status with the listed tools. Full results and logs are intentionally omitted.",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function createCallbackBatcher(
|
|
100
|
+
host: CallbackBatchHost,
|
|
101
|
+
options: CallbackBatcherOptions = {},
|
|
102
|
+
): CallbackBatcher {
|
|
103
|
+
const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
|
|
104
|
+
const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
|
|
105
|
+
const pending = new Map<string, PendingEvent>();
|
|
106
|
+
const inFlight = new Set<string>();
|
|
107
|
+
const urgentInFlight = new Set<string>();
|
|
108
|
+
let sequence = 0;
|
|
109
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
110
|
+
let flushPromise: Promise<boolean> | undefined;
|
|
111
|
+
|
|
112
|
+
const cancelTimer = (): void => {
|
|
113
|
+
if (timer) clearTimeout(timer);
|
|
114
|
+
timer = undefined;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const schedule = (delayMs: number): void => {
|
|
118
|
+
if (timer || pending.size === 0) return;
|
|
119
|
+
timer = setTimeout(() => {
|
|
120
|
+
timer = undefined;
|
|
121
|
+
void api.flush();
|
|
122
|
+
}, Math.max(0, delayMs));
|
|
123
|
+
timer.unref?.();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const enqueue = (event: CallbackBatchEvent): boolean => {
|
|
127
|
+
if (event.callback === false) return false;
|
|
128
|
+
const key = eventKey(event);
|
|
129
|
+
if (pending.has(key) || inFlight.has(key)) return false;
|
|
130
|
+
pending.set(key, { event, sequence: sequence++ });
|
|
131
|
+
schedule(windowMs);
|
|
132
|
+
return true;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const performFlush = async (): Promise<boolean> => {
|
|
136
|
+
cancelTimer();
|
|
137
|
+
const snapshot = [...pending.entries()]
|
|
138
|
+
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
139
|
+
pending.clear();
|
|
140
|
+
for (const [key] of snapshot) inFlight.add(key);
|
|
141
|
+
|
|
142
|
+
const deliverable: Array<[string, PendingEvent]> = [];
|
|
143
|
+
for (const item of snapshot) {
|
|
144
|
+
const [key, pendingEvent] = item;
|
|
145
|
+
const disposition = eventDisposition(pendingEvent.event);
|
|
146
|
+
if (disposition.kind === "delivered") {
|
|
147
|
+
inFlight.delete(key);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (disposition.kind === "suppressed") {
|
|
151
|
+
invokeSuppressed(pendingEvent.event, disposition.reason, Date.now());
|
|
152
|
+
inFlight.delete(key);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
deliverable.push(item);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (deliverable.length === 0) {
|
|
159
|
+
if (pending.size > 0) schedule(windowMs);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await host.sendMessage(
|
|
165
|
+
{
|
|
166
|
+
customType: "background-completion-batch",
|
|
167
|
+
content: formatCallbackBatch(deliverable.map(([, item]) => item.event)),
|
|
168
|
+
display: true,
|
|
169
|
+
},
|
|
170
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
171
|
+
);
|
|
172
|
+
} catch {
|
|
173
|
+
for (const [key] of deliverable) inFlight.delete(key);
|
|
174
|
+
const retryItems = [...deliverable, ...pending.entries()]
|
|
175
|
+
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
176
|
+
pending.clear();
|
|
177
|
+
for (const [key, item] of retryItems) {
|
|
178
|
+
if (!pending.has(key)) pending.set(key, item);
|
|
179
|
+
}
|
|
180
|
+
schedule(retryMs);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const deliveredAt = Date.now();
|
|
185
|
+
for (const [key, item] of deliverable) {
|
|
186
|
+
invokeDelivered(item.event, deliveredAt);
|
|
187
|
+
inFlight.delete(key);
|
|
188
|
+
}
|
|
189
|
+
if (pending.size > 0) schedule(windowMs);
|
|
190
|
+
return true;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const flush = (): Promise<boolean> => {
|
|
194
|
+
if (flushPromise) return flushPromise;
|
|
195
|
+
flushPromise = performFlush().finally(() => {
|
|
196
|
+
flushPromise = undefined;
|
|
197
|
+
});
|
|
198
|
+
return flushPromise;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const deliverUrgent = (event: UrgentCallbackEvent): boolean | Promise<boolean> => {
|
|
202
|
+
const key = eventKey(event);
|
|
203
|
+
if (urgentInFlight.has(key)) return false;
|
|
204
|
+
const disposition = eventDisposition(event);
|
|
205
|
+
if (disposition.kind === "delivered") return true;
|
|
206
|
+
if (disposition.kind === "suppressed") {
|
|
207
|
+
invokeSuppressed(event, disposition.reason, Date.now());
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
urgentInFlight.add(key);
|
|
212
|
+
try {
|
|
213
|
+
const handoff = host.sendMessage(
|
|
214
|
+
{ customType: event.customType, content: event.content, display: true },
|
|
215
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
216
|
+
);
|
|
217
|
+
if (isPromiseLike(handoff)) {
|
|
218
|
+
return Promise.resolve(handoff).then(
|
|
219
|
+
() => {
|
|
220
|
+
invokeDelivered(event, Date.now());
|
|
221
|
+
return true;
|
|
222
|
+
},
|
|
223
|
+
() => false,
|
|
224
|
+
).finally(() => urgentInFlight.delete(key));
|
|
225
|
+
}
|
|
226
|
+
invokeDelivered(event, Date.now());
|
|
227
|
+
urgentInFlight.delete(key);
|
|
228
|
+
return true;
|
|
229
|
+
} catch {
|
|
230
|
+
urgentInFlight.delete(key);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const api: CallbackBatcher = {
|
|
236
|
+
enqueue,
|
|
237
|
+
flush,
|
|
238
|
+
deliverUrgent,
|
|
239
|
+
cancel() {
|
|
240
|
+
cancelTimer();
|
|
241
|
+
pending.clear();
|
|
242
|
+
},
|
|
243
|
+
pendingCount() {
|
|
244
|
+
return pending.size;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
return api;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function getCallbackBatcher(
|
|
251
|
+
host: CallbackBatchHost,
|
|
252
|
+
options: CallbackBatcherOptions = {},
|
|
253
|
+
): CallbackBatcher {
|
|
254
|
+
const state = globalState();
|
|
255
|
+
const key = host as object;
|
|
256
|
+
const existing = state.byHost.get(key);
|
|
257
|
+
if (existing) return existing;
|
|
258
|
+
const created = createCallbackBatcher(host, options);
|
|
259
|
+
state.byHost.set(key, created);
|
|
260
|
+
return created;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function cancelCallbackBatch(host: CallbackBatchHost): void {
|
|
264
|
+
globalState().byHost.get(host as object)?.cancel();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function globalState(): SharedCallbackBatcherState {
|
|
268
|
+
const root = globalThis as typeof globalThis & {
|
|
269
|
+
[GLOBAL_STATE_KEY]?: SharedCallbackBatcherState;
|
|
270
|
+
};
|
|
271
|
+
root[GLOBAL_STATE_KEY] ??= { byHost: new WeakMap<object, CallbackBatcher>() };
|
|
272
|
+
return root[GLOBAL_STATE_KEY];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function eventKey(event: Pick<CallbackBatchEvent, "source" | "id" | "status">): string {
|
|
276
|
+
return `${event.source}\u0000${event.id}\u0000${event.status}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function eventDisposition(
|
|
280
|
+
event: Pick<CallbackBatchEvent, "isDelivered" | "getSuppressionReason">,
|
|
281
|
+
): { kind: "deliver" } | { kind: "delivered" } | { kind: "suppressed"; reason: string } {
|
|
282
|
+
try {
|
|
283
|
+
if (event.isDelivered?.()) return { kind: "delivered" };
|
|
284
|
+
} catch {
|
|
285
|
+
return { kind: "suppressed", reason: "durable delivery state could not be verified" };
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const reason = event.getSuppressionReason?.();
|
|
289
|
+
return reason ? { kind: "suppressed", reason } : { kind: "deliver" };
|
|
290
|
+
} catch {
|
|
291
|
+
return { kind: "suppressed", reason: "callback ownership could not be verified" };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function invokeDelivered(
|
|
296
|
+
event: Pick<CallbackBatchEvent, "onDelivered">,
|
|
297
|
+
at: number,
|
|
298
|
+
): void {
|
|
299
|
+
try { event.onDelivered?.(at); } catch { /* handoff already succeeded */ }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function invokeSuppressed(
|
|
303
|
+
event: Pick<CallbackBatchEvent, "onSuppressed">,
|
|
304
|
+
reason: string,
|
|
305
|
+
at: number,
|
|
306
|
+
): void {
|
|
307
|
+
try { event.onSuppressed?.(reason, at); } catch { /* best effort durable suppression */ }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function boundedField(value: unknown, maxChars: number): string {
|
|
311
|
+
const oneLine = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
312
|
+
if (oneLine.length <= maxChars) return oneLine;
|
|
313
|
+
return `${oneLine.slice(0, Math.max(0, maxChars - 3))}...`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
317
|
+
return (typeof value === "object" || typeof value === "function")
|
|
318
|
+
&& value !== null
|
|
319
|
+
&& typeof (value as PromiseLike<unknown>).then === "function";
|
|
320
|
+
}
|
package/shared-navigator.ts
CHANGED
|
@@ -39,6 +39,11 @@ export type BackgroundWorkDetail = {
|
|
|
39
39
|
metadata: Array<{ label: string; value: string }>;
|
|
40
40
|
foldedSections?: Array<{ id: string; label: string; text: string; collapsedText?: string; expandedByDefault?: boolean }>;
|
|
41
41
|
evidence: { label: string; text: string };
|
|
42
|
+
transcript?: Array<
|
|
43
|
+
| { type: "assistant"; content: Array<{ type: string; text?: string; thinking?: string }>; streaming: boolean }
|
|
44
|
+
| { type: "tool"; id?: string; name: string; args?: unknown; result?: unknown; isError: boolean; state: "running" | "completed" }
|
|
45
|
+
>;
|
|
46
|
+
transcriptDiagnostic?: string;
|
|
42
47
|
footerActions?: string[];
|
|
43
48
|
};
|
|
44
49
|
|
|
@@ -54,6 +59,7 @@ export type BackgroundWorkProvider = {
|
|
|
54
59
|
label: string;
|
|
55
60
|
priority: number;
|
|
56
61
|
visibleCount(): number;
|
|
62
|
+
showSection?(rows: BackgroundWorkRow[], now: number): boolean;
|
|
57
63
|
parentRow?(now: number): BackgroundWorkRow | null;
|
|
58
64
|
listRows(now: number): BackgroundWorkRow[];
|
|
59
65
|
detail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null;
|
|
@@ -67,6 +73,7 @@ type HostDeps = {
|
|
|
67
73
|
isOpenTrigger: (data: string) => boolean;
|
|
68
74
|
matchKey: (data: string, keyId: string) => boolean;
|
|
69
75
|
truncate: (s: string, width: number) => string;
|
|
76
|
+
createTranscriptComponent?: (detail: BackgroundWorkDetail, theme: unknown) => Component;
|
|
70
77
|
};
|
|
71
78
|
|
|
72
79
|
type NavigatorState = {
|
|
@@ -83,6 +90,7 @@ type NavigatorState = {
|
|
|
83
90
|
mainListCloseArm?: { id: string; armedAt: number };
|
|
84
91
|
mainListCloseArmTimer?: ReturnType<typeof setTimeout>;
|
|
85
92
|
mainListDeadlineScheduler?: RenderScheduler;
|
|
93
|
+
editorComponent?: Component;
|
|
86
94
|
detailOverlayRows?: number;
|
|
87
95
|
dispose?: () => void;
|
|
88
96
|
};
|
|
@@ -99,8 +107,7 @@ export const CLOSE_ARM_MS = 3000;
|
|
|
99
107
|
export const DEFAULT_LOG_TAIL_ROWS = 10;
|
|
100
108
|
export const LOG_TAIL_ROW_CHOICES = [10, 25] as const;
|
|
101
109
|
const MAIN_LIST_FALLBACK_WIDTH = 100;
|
|
102
|
-
const
|
|
103
|
-
const DETAIL_OVERLAY_FOOTER_MARGIN_ROWS = 3;
|
|
110
|
+
const DETAIL_OVERLAY_FOOTER_ROWS = 3;
|
|
104
111
|
const EVIDENCE_SECTION_ID = "__evidence__";
|
|
105
112
|
const RUNNING_DOT_GLYPH = "●";
|
|
106
113
|
const RUNNING_DOT_FRAMES = ["dim", "accent", "accent", "dim"] as const;
|
|
@@ -315,7 +322,7 @@ function renderWidth(width: number): number {
|
|
|
315
322
|
return Number.isFinite(width) && width > 0 ? Math.floor(width) : MAIN_LIST_FALLBACK_WIDTH;
|
|
316
323
|
}
|
|
317
324
|
|
|
318
|
-
type InternalRow = BackgroundWorkRow & { navigatorId: string; providerLabel: string };
|
|
325
|
+
type InternalRow = BackgroundWorkRow & { navigatorId: string; providerLabel: string; parentRow?: boolean };
|
|
319
326
|
|
|
320
327
|
function rowKey(providerId: string, id: string): string {
|
|
321
328
|
return `${providerId}:${id}`;
|
|
@@ -333,6 +340,19 @@ function listRows(now = Date.now()): InternalRow[] {
|
|
|
333
340
|
let providerRows: BackgroundWorkRow[] = [];
|
|
334
341
|
try { providerRows = provider.listRows(now) ?? []; } catch { providerRows = []; }
|
|
335
342
|
const orderedProviderRows = [...providerRows].sort((a, b) => b.sortStartedAt - a.sortStartedAt || rowDisplayName(a).localeCompare(rowDisplayName(b)));
|
|
343
|
+
let showSection = orderedProviderRows.length > 0 || provider.parentRow !== undefined;
|
|
344
|
+
try { showSection = provider.showSection?.(orderedProviderRows, now) ?? showSection; } catch { showSection = false; }
|
|
345
|
+
if (!showSection) continue;
|
|
346
|
+
let parentRow: BackgroundWorkRow | null = null;
|
|
347
|
+
try { parentRow = provider.parentRow?.(now) ?? null; } catch { parentRow = null; }
|
|
348
|
+
if (parentRow) {
|
|
349
|
+
rows.push({
|
|
350
|
+
...parentRow,
|
|
351
|
+
navigatorId: rowKey(parentRow.providerId, parentRow.id),
|
|
352
|
+
providerLabel: provider.label,
|
|
353
|
+
parentRow: true,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
336
356
|
for (const row of orderedProviderRows) {
|
|
337
357
|
rows.push({ ...row, navigatorId: rowKey(provider.id, row.id), providerLabel: provider.label });
|
|
338
358
|
}
|
|
@@ -378,7 +398,7 @@ function focusMainList(): void {
|
|
|
378
398
|
const rows = listRows();
|
|
379
399
|
if (rows.length === 0) return;
|
|
380
400
|
const s = state();
|
|
381
|
-
|
|
401
|
+
s.mainListSelectedId = rows.find((row) => row.parentRow && row.id === "main")?.navigatorId ?? rows[0]!.navigatorId;
|
|
382
402
|
s.mainListFocused = true;
|
|
383
403
|
refreshMainListWidget();
|
|
384
404
|
}
|
|
@@ -412,16 +432,6 @@ function buildMainListLines(
|
|
|
412
432
|
const group = grouped.get(label)!;
|
|
413
433
|
if (i > 0) lines.push("");
|
|
414
434
|
lines.push(providerGroupLabel(label, fg));
|
|
415
|
-
const provider = state().providers.get(group[0]!.providerId);
|
|
416
|
-
let parentRow: BackgroundWorkRow | null = null;
|
|
417
|
-
try { parentRow = provider?.parentRow?.(Date.now()) ?? null; } catch { parentRow = null; }
|
|
418
|
-
if (parentRow) {
|
|
419
|
-
lines.push(formatMainListRow({
|
|
420
|
-
...parentRow,
|
|
421
|
-
navigatorId: rowKey(parentRow.providerId, parentRow.id),
|
|
422
|
-
providerLabel: label,
|
|
423
|
-
}, false, fg, width));
|
|
424
|
-
}
|
|
425
435
|
for (const row of group) {
|
|
426
436
|
const selected = options.focused && row.navigatorId === options.selectedId;
|
|
427
437
|
lines.push(formatMainListRow(row, selected === true, fg, width));
|
|
@@ -433,13 +443,13 @@ function buildMainListLines(
|
|
|
433
443
|
}
|
|
434
444
|
|
|
435
445
|
function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
|
|
436
|
-
const keys = focused ? "↑↓
|
|
446
|
+
const keys = focused ? "↑↓ switch · Enter detail · x stop · Esc unfocus" : "← to navigate";
|
|
437
447
|
return dim(keys, fg);
|
|
438
448
|
}
|
|
439
449
|
|
|
440
450
|
function providerGroupLabel(label: string, fg: (color: string, value: string) => string): string {
|
|
441
451
|
const normalized = singleLine(label).toLowerCase();
|
|
442
|
-
return
|
|
452
|
+
return fg("warning", normalized);
|
|
443
453
|
}
|
|
444
454
|
|
|
445
455
|
function formatMainListRow(row: InternalRow, selected: boolean, fg: (color: string, value: string) => string, width: number): string {
|
|
@@ -527,13 +537,14 @@ function detailFor(navigatorId: string, now = Date.now(), options?: { logTailLin
|
|
|
527
537
|
}
|
|
528
538
|
|
|
529
539
|
function closeFor(row: InternalRow): BackgroundWorkCloseOutcome {
|
|
540
|
+
if (row.parentRow) return { action: "not-closable", providerId: row.providerId, id: row.id };
|
|
530
541
|
const provider = state().providers.get(row.providerId);
|
|
531
542
|
if (!provider) return { action: "missing", providerId: row.providerId, id: row.id };
|
|
532
543
|
try { return provider.close(row.id); } catch { return { action: "missing", providerId: row.providerId, id: row.id }; }
|
|
533
544
|
}
|
|
534
545
|
|
|
535
546
|
function closeHintFor(row: InternalRow | undefined): string | null {
|
|
536
|
-
if (!row) return null;
|
|
547
|
+
if (!row || row.parentRow) return null;
|
|
537
548
|
const provider = state().providers.get(row.providerId);
|
|
538
549
|
if (!provider) return null;
|
|
539
550
|
try { return `${provider.armCloseLabel(row)} ${row.name || row.id}`; } catch { return null; }
|
|
@@ -558,6 +569,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
|
|
|
558
569
|
}
|
|
559
570
|
|
|
560
571
|
function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
572
|
+
if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
|
|
561
573
|
return new Proxy(inner, {
|
|
562
574
|
get(target, prop) {
|
|
563
575
|
if (prop === "handleInput") {
|
|
@@ -588,16 +600,24 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
588
600
|
}
|
|
589
601
|
if (deps.matchKey(data, "up")) {
|
|
590
602
|
clearMainListCloseArm();
|
|
591
|
-
if (moveMainListSelection(-1))
|
|
603
|
+
if (moveMainListSelection(-1)) {
|
|
604
|
+
refreshMainListWidget();
|
|
605
|
+
if (!selectedMainListRow()?.parentRow) openNavigator();
|
|
606
|
+
}
|
|
592
607
|
return true;
|
|
593
608
|
}
|
|
594
609
|
if (deps.matchKey(data, "down")) {
|
|
595
610
|
clearMainListCloseArm();
|
|
596
|
-
if (moveMainListSelection(1))
|
|
611
|
+
if (moveMainListSelection(1)) {
|
|
612
|
+
refreshMainListWidget();
|
|
613
|
+
if (!selectedMainListRow()?.parentRow) openNavigator();
|
|
614
|
+
}
|
|
597
615
|
return true;
|
|
598
616
|
}
|
|
599
617
|
if (deps.matchKey(data, "enter")) {
|
|
600
|
-
|
|
618
|
+
const selected = selectedMainListRow();
|
|
619
|
+
if (selected?.parentRow) unfocusMainList();
|
|
620
|
+
else openNavigator();
|
|
601
621
|
return true;
|
|
602
622
|
}
|
|
603
623
|
if (data === "x" || data === "X" || deps.matchKey(data, "x") || deps.matchKey(data, "X")) {
|
|
@@ -613,7 +633,7 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
613
633
|
|
|
614
634
|
function handleMainListCloseKey(): void {
|
|
615
635
|
const row = selectedMainListRow();
|
|
616
|
-
if (!row) return;
|
|
636
|
+
if (!row || row.parentRow) return;
|
|
617
637
|
const s = state();
|
|
618
638
|
const now = Date.now();
|
|
619
639
|
const arm = s.mainListCloseArm;
|
|
@@ -689,6 +709,8 @@ function createOverlayComponent(
|
|
|
689
709
|
let closeArm: { id: string; armedAt: number } | undefined;
|
|
690
710
|
let closeArmTimer: ReturnType<typeof setTimeout> | undefined;
|
|
691
711
|
let closed = false;
|
|
712
|
+
let transcriptDetail: BackgroundWorkDetail | null = null;
|
|
713
|
+
let transcriptComponent: Component | null = null;
|
|
692
714
|
|
|
693
715
|
const fg = (color: string, value: string) => theme?.fg ? theme.fg(color, value) : value;
|
|
694
716
|
|
|
@@ -704,12 +726,24 @@ function createOverlayComponent(
|
|
|
704
726
|
});
|
|
705
727
|
|
|
706
728
|
function refreshRows(): void {
|
|
707
|
-
const selectedId = overlayState.rows[overlayState.selected]?.navigatorId;
|
|
729
|
+
const selectedId = state().mainListSelectedId ?? overlayState.rows[overlayState.selected]?.navigatorId;
|
|
708
730
|
overlayState.rows = listRows();
|
|
709
731
|
const nextIdx = selectedId ? overlayState.rows.findIndex((row) => row.navigatorId === selectedId) : -1;
|
|
710
732
|
overlayState.selected = nextIdx >= 0 ? nextIdx : Math.min(overlayState.selected, Math.max(0, overlayState.rows.length - 1));
|
|
711
733
|
}
|
|
712
734
|
|
|
735
|
+
function selectOverlayRow(next: number): void {
|
|
736
|
+
overlayState.selected = Math.min(Math.max(0, overlayState.rows.length - 1), Math.max(0, next));
|
|
737
|
+
state().mainListSelectedId = overlayState.rows[overlayState.selected]?.navigatorId;
|
|
738
|
+
clearCloseArm();
|
|
739
|
+
refreshMainListWidget();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function activateSelectedRow(): void {
|
|
743
|
+
if (selectedRow()?.parentRow) close();
|
|
744
|
+
else openDetail();
|
|
745
|
+
}
|
|
746
|
+
|
|
713
747
|
function clearCloseArm(): void {
|
|
714
748
|
if (closeArmTimer) clearTimeout(closeArmTimer);
|
|
715
749
|
closeArmTimer = undefined;
|
|
@@ -731,13 +765,16 @@ function createOverlayComponent(
|
|
|
731
765
|
}
|
|
732
766
|
|
|
733
767
|
function selectedRow(): InternalRow | undefined {
|
|
734
|
-
if (mode === "detail" && detailId) return overlayState.rows.find((row) => row.navigatorId === detailId);
|
|
735
768
|
return overlayState.rows[overlayState.selected];
|
|
736
769
|
}
|
|
737
770
|
|
|
738
771
|
function openDetail(): void {
|
|
739
772
|
const row = selectedRow();
|
|
740
773
|
if (!row) return;
|
|
774
|
+
if (row.parentRow) {
|
|
775
|
+
close();
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
741
778
|
clearCloseArm();
|
|
742
779
|
detailId = row.navigatorId;
|
|
743
780
|
expandedSections.clear();
|
|
@@ -808,10 +845,43 @@ function createOverlayComponent(
|
|
|
808
845
|
|
|
809
846
|
return {
|
|
810
847
|
render(width: number) {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
848
|
+
refreshRows();
|
|
849
|
+
const railLines = mode === "detail"
|
|
850
|
+
? buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
|
|
851
|
+
selectedId: selectedRow()?.navigatorId,
|
|
852
|
+
focused: true,
|
|
853
|
+
})
|
|
854
|
+
: [];
|
|
855
|
+
const editorLines = mode === "detail" ? renderEditorLines(width) : [];
|
|
856
|
+
const bottomLines = [...railLines, ...editorLines];
|
|
857
|
+
const overlayRows = state().detailOverlayRows;
|
|
858
|
+
const detailRows = overlayRows === undefined ? undefined : Math.max(1, overlayRows - bottomLines.length);
|
|
859
|
+
let contentLines: string[];
|
|
860
|
+
if (mode === "detail" && detail?.transcript && deps.createTranscriptComponent) {
|
|
861
|
+
if (transcriptDetail !== detail || !transcriptComponent) {
|
|
862
|
+
transcriptDetail = detail;
|
|
863
|
+
transcriptComponent = deps.createTranscriptComponent(detail, theme);
|
|
864
|
+
}
|
|
865
|
+
contentLines = buildTranscriptDetailLines(
|
|
866
|
+
detail,
|
|
867
|
+
transcriptComponent.render(width),
|
|
868
|
+
width,
|
|
869
|
+
deps.truncate,
|
|
870
|
+
fg,
|
|
871
|
+
{ minRows: detailRows },
|
|
872
|
+
);
|
|
873
|
+
} else {
|
|
874
|
+
transcriptDetail = null;
|
|
875
|
+
transcriptComponent = null;
|
|
876
|
+
contentLines = mode === "detail"
|
|
877
|
+
? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows })
|
|
878
|
+
: buildListLines(overlayState, width, deps.truncate, fg);
|
|
879
|
+
}
|
|
880
|
+
if (mode !== "detail") return contentLines;
|
|
881
|
+
if (detailRows === undefined) return [...contentLines, ...bottomLines];
|
|
882
|
+
const fittedContent = contentLines.slice(0, detailRows);
|
|
883
|
+
while (fittedContent.length < detailRows) fittedContent.push("");
|
|
884
|
+
return [...fittedContent, ...bottomLines];
|
|
815
885
|
},
|
|
816
886
|
handleInput(data: string) {
|
|
817
887
|
if (closed) return;
|
|
@@ -820,10 +890,25 @@ function createOverlayComponent(
|
|
|
820
890
|
return;
|
|
821
891
|
}
|
|
822
892
|
if (mode === "detail") {
|
|
823
|
-
if (deps.matchKey(data, "
|
|
893
|
+
if (deps.matchKey(data, "up")) {
|
|
894
|
+
selectOverlayRow(overlayState.selected - 1);
|
|
895
|
+
activateSelectedRow();
|
|
896
|
+
}
|
|
897
|
+
else if (deps.matchKey(data, "down")) {
|
|
898
|
+
selectOverlayRow(overlayState.selected + 1);
|
|
899
|
+
activateSelectedRow();
|
|
900
|
+
}
|
|
901
|
+
else if (deps.matchKey(data, "left")) {
|
|
902
|
+
const mainIdx = overlayState.rows.findIndex((row) => row.parentRow && row.id === "main");
|
|
903
|
+
if (mainIdx >= 0) selectOverlayRow(mainIdx);
|
|
904
|
+
close();
|
|
905
|
+
}
|
|
824
906
|
else if (deps.matchKey(data, "enter")) {
|
|
825
|
-
const
|
|
826
|
-
if (
|
|
907
|
+
const row = selectedRow();
|
|
908
|
+
if (row?.parentRow || row?.navigatorId !== detailId) openDetail();
|
|
909
|
+
else {
|
|
910
|
+
const sectionId = firstToggleableSectionId(detail);
|
|
911
|
+
if (!sectionId) return;
|
|
827
912
|
if (expandedSections.has(sectionId)) expandedSections.delete(sectionId);
|
|
828
913
|
else expandedSections.add(sectionId);
|
|
829
914
|
requestRender();
|
|
@@ -834,7 +919,10 @@ function createOverlayComponent(
|
|
|
834
919
|
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
835
920
|
requestRender();
|
|
836
921
|
}
|
|
837
|
-
else if (deps.matchKey(data, "escape"))
|
|
922
|
+
else if (deps.matchKey(data, "escape")) {
|
|
923
|
+
unfocusMainList();
|
|
924
|
+
close();
|
|
925
|
+
}
|
|
838
926
|
return;
|
|
839
927
|
}
|
|
840
928
|
if (deps.matchKey(data, "up")) {
|
|
@@ -851,7 +939,7 @@ function createOverlayComponent(
|
|
|
851
939
|
close();
|
|
852
940
|
}
|
|
853
941
|
},
|
|
854
|
-
invalidate() {},
|
|
942
|
+
invalidate() { transcriptComponent?.invalidate(); },
|
|
855
943
|
dispose() {
|
|
856
944
|
clearCloseArm();
|
|
857
945
|
detailScheduler.dispose();
|
|
@@ -859,26 +947,61 @@ function createOverlayComponent(
|
|
|
859
947
|
};
|
|
860
948
|
}
|
|
861
949
|
|
|
950
|
+
function buildTranscriptDetailLines(
|
|
951
|
+
detail: BackgroundWorkDetail,
|
|
952
|
+
transcriptLines: string[],
|
|
953
|
+
width: number,
|
|
954
|
+
truncate: (s: string, width: number) => string,
|
|
955
|
+
fg: (color: string, value: string) => string,
|
|
956
|
+
options: { minRows?: number } = {},
|
|
957
|
+
): string[] {
|
|
958
|
+
const actions = [...(detail.footerActions ?? ["x close"]), "Esc close"].join(" · ");
|
|
959
|
+
const lines: string[] = [
|
|
960
|
+
fg("accent", rule(detail.title, width)),
|
|
961
|
+
dim(` ← main · ${actions}`, fg),
|
|
962
|
+
"",
|
|
963
|
+
` status ${fg(toneColor(detail.statusTone, detail.status), detail.status)}`,
|
|
964
|
+
];
|
|
965
|
+
if (detail.subtitle) lines.push(` summary ${detail.subtitle}`);
|
|
966
|
+
for (const item of detail.metadata) lines.push(` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`);
|
|
967
|
+
lines.push("", dim(section("transcript", width), fg));
|
|
968
|
+
if (detail.transcriptDiagnostic) lines.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
|
|
969
|
+
lines.push(...(transcriptLines.length ? transcriptLines : [" (no transcript yet)"]));
|
|
970
|
+
lines.push("");
|
|
971
|
+
const footerLines = [dim(` ← main · ${actions}`, fg), dim(rule("", width), fg)];
|
|
972
|
+
padBeforeFooter(lines, footerLines.length, options.minRows);
|
|
973
|
+
lines.push(...footerLines);
|
|
974
|
+
return lines.map((line) => safeTruncate(line, width, truncate));
|
|
975
|
+
}
|
|
976
|
+
|
|
862
977
|
function detailOverlayOptions() {
|
|
863
|
-
const
|
|
864
|
-
const marginBottom = DETAIL_OVERLAY_FOOTER_MARGIN_ROWS + navigatorRows;
|
|
978
|
+
const marginBottom = DETAIL_OVERLAY_FOOTER_ROWS;
|
|
865
979
|
return {
|
|
866
980
|
anchor: "top-left" as const,
|
|
867
981
|
width: "100%" as const,
|
|
868
982
|
maxHeight: "100%" as const,
|
|
869
983
|
margin: {
|
|
870
|
-
top:
|
|
984
|
+
top: 0,
|
|
871
985
|
right: 0,
|
|
872
986
|
bottom: marginBottom,
|
|
873
987
|
left: 0,
|
|
874
988
|
},
|
|
875
989
|
visible: (_termWidth: number, termHeight: number) => {
|
|
876
|
-
state().detailOverlayRows = Math.max(1, termHeight -
|
|
990
|
+
state().detailOverlayRows = Math.max(1, termHeight - marginBottom);
|
|
877
991
|
return true;
|
|
878
992
|
},
|
|
879
993
|
};
|
|
880
994
|
}
|
|
881
995
|
|
|
996
|
+
function renderEditorLines(width: number): string[] {
|
|
997
|
+
try {
|
|
998
|
+
const lines = state().editorComponent?.render(width);
|
|
999
|
+
if (lines?.length) return lines;
|
|
1000
|
+
} catch { /* use an empty editor-shaped fallback */ }
|
|
1001
|
+
const border = "─".repeat(Math.max(1, width));
|
|
1002
|
+
return [border, "", border];
|
|
1003
|
+
}
|
|
1004
|
+
|
|
882
1005
|
function fallbackDetail(row: InternalRow): BackgroundWorkDetail {
|
|
883
1006
|
return {
|
|
884
1007
|
providerId: row.providerId,
|