pi-better-subagents 0.1.16 → 0.1.18
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 +158 -31
- package/package.json +1 -1
- package/registry.ts +3 -0
- package/shared-callback-batcher.ts +320 -0
- package/shared-navigator.ts +91 -1
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
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
getSharedCapacityGate,
|
|
85
85
|
} from "./capacity.mjs";
|
|
86
86
|
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
87
|
+
import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
|
|
87
88
|
import {
|
|
88
89
|
text,
|
|
89
90
|
subagentListTool,
|
|
@@ -96,6 +97,7 @@ import {
|
|
|
96
97
|
WIDGET_CLEAR,
|
|
97
98
|
fmtElapsed,
|
|
98
99
|
fmtSpend,
|
|
100
|
+
fmtTokens,
|
|
99
101
|
shortModel,
|
|
100
102
|
isSpendCacheFresh,
|
|
101
103
|
resolveHealthLogExtraction,
|
|
@@ -236,12 +238,58 @@ function stopCurrentSessionSubagents(ctx: ExtensionContext): void {
|
|
|
236
238
|
|
|
237
239
|
function markCompletionCallbackSuppressed(id: string, reason: string, now: number = Date.now()): void {
|
|
238
240
|
const meta = readMeta(id);
|
|
239
|
-
if (!meta || meta.completionCallbackSuppressedAt !== undefined) return;
|
|
241
|
+
if (!meta || meta.completionCallbackSentAt !== undefined || meta.completionCallbackSuppressedAt !== undefined) return;
|
|
240
242
|
meta.completionCallbackSuppressedAt = now;
|
|
241
243
|
meta.completionCallbackSuppressedReason = reason;
|
|
242
244
|
writeMeta(meta);
|
|
243
245
|
}
|
|
244
246
|
|
|
247
|
+
function markCompletionCallbackSent(id: string, now: number): void {
|
|
248
|
+
const meta = readMeta(id);
|
|
249
|
+
if (!meta || meta.completionCallbackSentAt !== undefined || meta.completionCallbackSuppressedAt !== undefined) return;
|
|
250
|
+
meta.completionCallbackSentAt = now;
|
|
251
|
+
writeMeta(meta);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Queue one durable ordinary terminal event on the host-shared batch. */
|
|
255
|
+
function enqueueCompletionCallback(pi: ExtensionAPI, id: string): void {
|
|
256
|
+
const meta = readMeta(id);
|
|
257
|
+
if (!meta
|
|
258
|
+
|| meta.callback === false
|
|
259
|
+
|| meta.completionCallbackPendingAt === undefined
|
|
260
|
+
|| meta.completionCallbackSentAt !== undefined
|
|
261
|
+
|| meta.completionCallbackSuppressedAt !== undefined) return;
|
|
262
|
+
const label = meta.name ? `${meta.name} (${id})` : id;
|
|
263
|
+
getCallbackBatcher(pi).enqueue({
|
|
264
|
+
source: "subagent",
|
|
265
|
+
id,
|
|
266
|
+
label,
|
|
267
|
+
status: meta.status,
|
|
268
|
+
detailTool: "subagent_result",
|
|
269
|
+
callback: true,
|
|
270
|
+
isDelivered: () => {
|
|
271
|
+
const current = readMeta(id);
|
|
272
|
+
return current?.completionCallbackSentAt !== undefined
|
|
273
|
+
|| current?.completionCallbackSuppressedAt !== undefined;
|
|
274
|
+
},
|
|
275
|
+
getSuppressionReason: () => {
|
|
276
|
+
const current = readMeta(id);
|
|
277
|
+
if (!current) return "subagent metadata is unavailable";
|
|
278
|
+
return callbackSuppressionReason(current);
|
|
279
|
+
},
|
|
280
|
+
onDelivered: (at) => markCompletionCallbackSent(id, at),
|
|
281
|
+
onSuppressed: (reason, at) => markCompletionCallbackSuppressed(id, reason, at),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Recover only records explicitly marked pending; legacy terminal runs never replay. */
|
|
286
|
+
function recoverCompletionCallbacks(pi: ExtensionAPI): void {
|
|
287
|
+
for (const meta of listMetas()) {
|
|
288
|
+
if (!ownedByThisParent(meta)) continue;
|
|
289
|
+
enqueueCompletionCallback(pi, meta.id);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
245
293
|
function markHealthCallbackSuppressed(meta: RunMeta, status: "orphaned" | "lost", reason: string, now: number): void {
|
|
246
294
|
if (status === "orphaned") {
|
|
247
295
|
if (meta.orphanedCallbackSuppressedAt !== undefined) return;
|
|
@@ -408,12 +456,6 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
|
|
|
408
456
|
if (!pi) return;
|
|
409
457
|
if (isHealthCallbackHandled(meta, status)) return;
|
|
410
458
|
|
|
411
|
-
const suppressionReason = callbackSuppressionReason(meta);
|
|
412
|
-
if (suppressionReason) {
|
|
413
|
-
markHealthCallbackSuppressed(meta, status, suppressionReason, now);
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
459
|
const callback = meta.callback !== false;
|
|
418
460
|
const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
419
461
|
const delivery = buildHealthCallbackDelivery({ id: meta.id, label, status, callback });
|
|
@@ -425,20 +467,35 @@ function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, stat
|
|
|
425
467
|
writeMeta(meta);
|
|
426
468
|
return;
|
|
427
469
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
470
|
+
void getCallbackBatcher(pi).deliverUrgent({
|
|
471
|
+
source: "subagent",
|
|
472
|
+
id: meta.id,
|
|
473
|
+
label,
|
|
474
|
+
status,
|
|
475
|
+
customType: "subagent-health",
|
|
476
|
+
content: delivery.content,
|
|
477
|
+
isDelivered: () => {
|
|
478
|
+
const current = readMeta(meta.id);
|
|
479
|
+
return current ? isHealthCallbackHandled(current, status) : true;
|
|
480
|
+
},
|
|
481
|
+
getSuppressionReason: () => {
|
|
482
|
+
const current = readMeta(meta.id);
|
|
483
|
+
if (!current) return "subagent metadata is unavailable";
|
|
484
|
+
return callbackSuppressionReason(current);
|
|
485
|
+
},
|
|
486
|
+
onDelivered: (at) => {
|
|
487
|
+
const current = readMeta(meta.id);
|
|
488
|
+
if (!current || isHealthCallbackHandled(current, status)) return;
|
|
489
|
+
if (status === "orphaned") current.orphanedCallbackSentAt = at;
|
|
490
|
+
else current.lostCallbackSentAt = at;
|
|
491
|
+
writeMeta(current);
|
|
492
|
+
},
|
|
493
|
+
onSuppressed: (reason, at) => {
|
|
494
|
+
const current = readMeta(meta.id);
|
|
495
|
+
if (!current || isHealthCallbackHandled(current, status)) return;
|
|
496
|
+
markHealthCallbackSuppressed(current, status, reason, at);
|
|
497
|
+
},
|
|
498
|
+
});
|
|
442
499
|
}
|
|
443
500
|
|
|
444
501
|
/** One reconciliation + durable health-callback recovery pass. */
|
|
@@ -587,6 +644,8 @@ export function setIdentityProbeForTests(probe: ProcessProbe | undefined): void
|
|
|
587
644
|
|
|
588
645
|
let unregisterSubagentProvider: (() => void) | undefined;
|
|
589
646
|
const TERMINAL_NAVIGATOR_RETENTION_MS = 30_000;
|
|
647
|
+
let mainAgentStartedAt: number | undefined;
|
|
648
|
+
const mainAgentTools = new Map<string, string>();
|
|
590
649
|
|
|
591
650
|
/**
|
|
592
651
|
* Observe health for a navigator row/detail (#69). Reuses the size/mtime-gated
|
|
@@ -743,6 +802,37 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
|
|
|
743
802
|
});
|
|
744
803
|
}
|
|
745
804
|
|
|
805
|
+
function mainAgentWorkRow(now: number): BackgroundWorkRow {
|
|
806
|
+
let running = mainAgentStartedAt !== undefined;
|
|
807
|
+
try { running ||= uiCtx?.isIdle() === false; } catch { /* use event state */ }
|
|
808
|
+
const model = shortModel(uiCtx?.model?.id);
|
|
809
|
+
const effort = uiCtx?.thinkingLevel;
|
|
810
|
+
const tool = [...mainAgentTools.values()].at(-1);
|
|
811
|
+
let contextTokens: number | null | undefined;
|
|
812
|
+
try { contextTokens = uiCtx?.getContextUsage()?.tokens; } catch { contextTokens = undefined; }
|
|
813
|
+
const tokens = typeof contextTokens === "number" ? `${fmtTokens(contextTokens)} tok` : undefined;
|
|
814
|
+
const bits = [
|
|
815
|
+
effort ? `${model} ${effort}` : model,
|
|
816
|
+
tool ? `tool ${tool}` : undefined,
|
|
817
|
+
tokens,
|
|
818
|
+
].filter((bit): bit is string => Boolean(bit));
|
|
819
|
+
return {
|
|
820
|
+
providerId: "subagents",
|
|
821
|
+
id: "main",
|
|
822
|
+
name: "main",
|
|
823
|
+
model,
|
|
824
|
+
effort,
|
|
825
|
+
tool,
|
|
826
|
+
tokens,
|
|
827
|
+
status: running ? "running" : "idle",
|
|
828
|
+
statusTone: running ? "running" : "muted",
|
|
829
|
+
kind: "main agent",
|
|
830
|
+
elapsed: running && mainAgentStartedAt !== undefined ? fmtElapsed(now - mainAgentStartedAt) : "idle",
|
|
831
|
+
primary: bits.join(" · "),
|
|
832
|
+
sortStartedAt: mainAgentStartedAt ?? now,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
746
836
|
function subagentWorkDetail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
|
|
747
837
|
const detail = navigatorDetail(id, now);
|
|
748
838
|
if (!detail) return null;
|
|
@@ -780,6 +870,7 @@ function ensureSubagentProvider(): void {
|
|
|
780
870
|
label: "Subagents",
|
|
781
871
|
priority: 10,
|
|
782
872
|
visibleCount: () => navigatorRunningCount(),
|
|
873
|
+
parentRow: (now) => mainAgentWorkRow(now),
|
|
783
874
|
listRows: (now) => subagentWorkRows(now),
|
|
784
875
|
detail: (id, now, options) => subagentWorkDetail(id, now, options),
|
|
785
876
|
armCloseLabel: (row) => row.status === "running" || row.status === "orphaned" ? "x again to stop" : "x again to dismiss",
|
|
@@ -900,16 +991,7 @@ function finalizeRun(pi: ExtensionAPI, ctx: ExtensionContext, id: string, code:
|
|
|
900
991
|
notify: (message, level) => {
|
|
901
992
|
try { ctx.ui.notify(message, level); } catch { /* ignore */ }
|
|
902
993
|
},
|
|
903
|
-
sendMessage: (
|
|
904
|
-
const meta = readMeta(id);
|
|
905
|
-
if (!meta) return;
|
|
906
|
-
const suppressionReason = callbackSuppressionReason(meta);
|
|
907
|
-
if (suppressionReason) {
|
|
908
|
-
markCompletionCallbackSuppressed(id, suppressionReason);
|
|
909
|
-
return;
|
|
910
|
-
}
|
|
911
|
-
pi.sendMessage(message, options);
|
|
912
|
-
},
|
|
994
|
+
sendMessage: () => enqueueCompletionCallback(pi, id),
|
|
913
995
|
});
|
|
914
996
|
}
|
|
915
997
|
|
|
@@ -1322,12 +1404,51 @@ export default function (pi: ExtensionAPI) {
|
|
|
1322
1404
|
pi.registerTool(subagentStopTool(Type, { onStopped: renderWidget }));
|
|
1323
1405
|
|
|
1324
1406
|
// ---- live-status lifecycle -----------------------------------------
|
|
1407
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
1408
|
+
uiCtx = ctx;
|
|
1409
|
+
mainAgentStartedAt = Date.now();
|
|
1410
|
+
mainAgentTools.clear();
|
|
1411
|
+
refreshBackgroundWorkNavigator(ctx);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
pi.on("tool_execution_start", async (event, ctx) => {
|
|
1415
|
+
mainAgentTools.set(event.toolCallId, event.toolName);
|
|
1416
|
+
refreshBackgroundWorkNavigator(ctx);
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
pi.on("tool_execution_end", async (event, ctx) => {
|
|
1420
|
+
mainAgentTools.delete(event.toolCallId);
|
|
1421
|
+
refreshBackgroundWorkNavigator(ctx);
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
pi.on("message_end", async (event, ctx) => {
|
|
1425
|
+
if (event.message.role === "assistant") refreshBackgroundWorkNavigator(ctx);
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
1429
|
+
refreshBackgroundWorkNavigator(ctx);
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
pi.on("thinking_level_select", async (_event, ctx) => {
|
|
1433
|
+
refreshBackgroundWorkNavigator(ctx);
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
1437
|
+
mainAgentStartedAt = undefined;
|
|
1438
|
+
mainAgentTools.clear();
|
|
1439
|
+
refreshBackgroundWorkNavigator(ctx);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1325
1442
|
// Capture a UI-bearing context and, if runs from a prior session are still
|
|
1326
1443
|
// alive, resume the ticking widget. Deferred out of the factory per pi's
|
|
1327
1444
|
// "no background resources at load" rule.
|
|
1328
1445
|
pi.on("session_start", async (_event, ctx) => {
|
|
1329
1446
|
uiCtx = ctx;
|
|
1447
|
+
try { mainAgentStartedAt = ctx.isIdle() ? undefined : Date.now(); }
|
|
1448
|
+
catch { mainAgentStartedAt = undefined; }
|
|
1449
|
+
mainAgentTools.clear();
|
|
1330
1450
|
activeCallbackOrigin = callbackOriginFromContext(ctx);
|
|
1451
|
+
recoverCompletionCallbacks(pi);
|
|
1331
1452
|
// Reload / session switch hardening (#48):
|
|
1332
1453
|
// - Drop any leftover overlay timers/confirm state from a prior session
|
|
1333
1454
|
// (defensive if the host skipped session_shutdown before re-start).
|
|
@@ -1359,6 +1480,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1359
1480
|
|
|
1360
1481
|
pi.on("session_before_switch", () => {
|
|
1361
1482
|
activeCallbackOrigin = undefined;
|
|
1483
|
+
cancelCallbackBatch(pi);
|
|
1484
|
+
mainAgentStartedAt = undefined;
|
|
1485
|
+
mainAgentTools.clear();
|
|
1362
1486
|
disposeBackgroundWorkNavigator();
|
|
1363
1487
|
});
|
|
1364
1488
|
|
|
@@ -1369,6 +1493,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1369
1493
|
// cannot become live process groups with no coordinator.
|
|
1370
1494
|
stopCurrentSessionSubagents(ctx);
|
|
1371
1495
|
activeCallbackOrigin = undefined;
|
|
1496
|
+
cancelCallbackBatch(pi);
|
|
1497
|
+
mainAgentStartedAt = undefined;
|
|
1498
|
+
mainAgentTools.clear();
|
|
1372
1499
|
stopTicker();
|
|
1373
1500
|
stopHealthTicker();
|
|
1374
1501
|
spendCache.clear();
|
package/package.json
CHANGED
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
|
@@ -54,6 +54,7 @@ export type BackgroundWorkProvider = {
|
|
|
54
54
|
label: string;
|
|
55
55
|
priority: number;
|
|
56
56
|
visibleCount(): number;
|
|
57
|
+
parentRow?(now: number): BackgroundWorkRow | null;
|
|
57
58
|
listRows(now: number): BackgroundWorkRow[];
|
|
58
59
|
detail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null;
|
|
59
60
|
armCloseLabel(row: BackgroundWorkRow): string;
|
|
@@ -411,6 +412,16 @@ function buildMainListLines(
|
|
|
411
412
|
const group = grouped.get(label)!;
|
|
412
413
|
if (i > 0) lines.push("");
|
|
413
414
|
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
|
+
}
|
|
414
425
|
for (const row of group) {
|
|
415
426
|
const selected = options.focused && row.navigatorId === options.selectedId;
|
|
416
427
|
lines.push(formatMainListRow(row, selected === true, fg, width));
|
|
@@ -976,7 +987,10 @@ function buildDetailLines(
|
|
|
976
987
|
} else {
|
|
977
988
|
const evidenceLabel = /log/i.test(detail.evidence.label) ? `${detail.evidence.label} · latest ${tailRows} rows` : detail.evidence.label;
|
|
978
989
|
lines.push(dim(section(evidenceLabel, width), fg));
|
|
979
|
-
|
|
990
|
+
const wrapped = /log/i.test(detail.evidence.label)
|
|
991
|
+
? wrapLogText(body, width - 6)
|
|
992
|
+
: body.split(/\r?\n/);
|
|
993
|
+
for (const raw of wrapped) lines.push(raw ? ` ${raw}` : " ");
|
|
980
994
|
}
|
|
981
995
|
lines.push("");
|
|
982
996
|
const footerLines = [dim(` ← back · ${actions}`, fg), dim(rule("", width), fg)];
|
|
@@ -1042,6 +1056,82 @@ function wrapEvidenceText(text: string, width: number): string[] {
|
|
|
1042
1056
|
return rows.length ? rows : ["(no output yet)"];
|
|
1043
1057
|
}
|
|
1044
1058
|
|
|
1059
|
+
/**
|
|
1060
|
+
* Wrap terminal log rows without flattening entries together. Continuations
|
|
1061
|
+
* retain the source indentation, and long paths/JSON tokens are hard-wrapped
|
|
1062
|
+
* so the final width guard never has to discard their suffix.
|
|
1063
|
+
*/
|
|
1064
|
+
export function wrapLogText(text: string, width: number): string[] {
|
|
1065
|
+
const max = Math.max(8, Math.floor(width));
|
|
1066
|
+
const rows: string[] = [];
|
|
1067
|
+
for (const source of String(text ?? "").split(/\r?\n/)) {
|
|
1068
|
+
if (!source) {
|
|
1069
|
+
rows.push("");
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
const sourceIndent = source.match(/^[ \t]*/)?.[0] ?? "";
|
|
1073
|
+
const indent = sourceIndent.replace(/\t/g, " ");
|
|
1074
|
+
let remaining = source.slice(sourceIndent.length);
|
|
1075
|
+
const contentWidth = Math.max(1, max - visibleWidth(indent));
|
|
1076
|
+
if (!remaining) {
|
|
1077
|
+
rows.push(indent);
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
while (visibleWidth(remaining) > contentWidth) {
|
|
1081
|
+
let [head, tail] = splitVisiblePrefix(remaining, contentWidth);
|
|
1082
|
+
const minSoftBreak = Math.floor(contentWidth * 0.45);
|
|
1083
|
+
const delimiterBreak = lastDelimiterBreak(head);
|
|
1084
|
+
const whitespace = head.match(/\s+\S*$/)?.index;
|
|
1085
|
+
if (delimiterBreak > minSoftBreak) {
|
|
1086
|
+
tail = head.slice(delimiterBreak) + tail;
|
|
1087
|
+
head = head.slice(0, delimiterBreak);
|
|
1088
|
+
} else if (whitespace !== undefined && whitespace > minSoftBreak) {
|
|
1089
|
+
tail = head.slice(whitespace).trimStart() + tail;
|
|
1090
|
+
head = head.slice(0, whitespace).trimEnd();
|
|
1091
|
+
}
|
|
1092
|
+
rows.push(indent + head);
|
|
1093
|
+
remaining = tail.trimStart();
|
|
1094
|
+
}
|
|
1095
|
+
rows.push(indent + remaining);
|
|
1096
|
+
}
|
|
1097
|
+
return rows.length ? rows : [""];
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function lastDelimiterBreak(value: string): number {
|
|
1101
|
+
let last = -1;
|
|
1102
|
+
for (const match of value.matchAll(/[\/,}:]/g)) last = match.index + 1;
|
|
1103
|
+
return last;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function splitVisiblePrefix(value: string, width: number): [string, string] {
|
|
1107
|
+
const max = Math.max(0, Math.floor(width));
|
|
1108
|
+
let visible = 0;
|
|
1109
|
+
let index = 0;
|
|
1110
|
+
while (index < value.length && visible < max) {
|
|
1111
|
+
if (value[index] === "\u001b" || value[index] === "\u009b") {
|
|
1112
|
+
const match = value.slice(index).match(ANSI_RE);
|
|
1113
|
+
if (match?.index === 0) {
|
|
1114
|
+
index += match[0].length;
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (value[index] === "<") {
|
|
1119
|
+
const close = value.indexOf(">", index);
|
|
1120
|
+
if (close !== -1) {
|
|
1121
|
+
const tag = value.slice(index, close + 1);
|
|
1122
|
+
if (/^<\/?[a-zA-Z][\w-]*>$/.test(tag) || tag === "</>") {
|
|
1123
|
+
index = close + 1;
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
const codePoint = value.codePointAt(index)!;
|
|
1129
|
+
index += codePoint > 0xFFFF ? 2 : 1;
|
|
1130
|
+
visible += 1;
|
|
1131
|
+
}
|
|
1132
|
+
return [value.slice(0, index), value.slice(index)];
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1045
1135
|
function cycleLogTailRows(current: number): number {
|
|
1046
1136
|
const idx = LOG_TAIL_ROW_CHOICES.findIndex((value) => value === current);
|
|
1047
1137
|
return LOG_TAIL_ROW_CHOICES[(idx + 1) % LOG_TAIL_ROW_CHOICES.length];
|