pi-better-background-tasks 0.6.2 → 0.6.4
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/package.json +1 -1
- package/src/shared-callback-batcher.ts +67 -4
- package/src/shared-navigator.ts +22 -17
- package/src/tools.ts +4 -1
package/package.json
CHANGED
|
@@ -3,6 +3,8 @@ export type CallbackSource = "subagent" | "background-task";
|
|
|
3
3
|
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
4
4
|
|
|
5
5
|
export interface CallbackBatchHost {
|
|
6
|
+
/** Older Pi shares this object; newer Pi supplies per-extension wrappers. */
|
|
7
|
+
events?: object;
|
|
6
8
|
sendMessage(
|
|
7
9
|
message: { customType: string; content: string; display: boolean },
|
|
8
10
|
options: Record<string, unknown>,
|
|
@@ -66,6 +68,8 @@ export interface UrgentCallbackEvent {
|
|
|
66
68
|
export interface CallbackBatcherOptions {
|
|
67
69
|
windowMs?: number;
|
|
68
70
|
retryMs?: number;
|
|
71
|
+
/** Ordinary callbacks stay here, not in Pi's follow-up queue, while unavailable. */
|
|
72
|
+
isAvailable?: () => boolean;
|
|
69
73
|
/** UTF-8 byte cap for one sendMessage payload. Defaults to 2 KiB. */
|
|
70
74
|
maxBytes?: number;
|
|
71
75
|
}
|
|
@@ -73,6 +77,10 @@ export interface CallbackBatcherOptions {
|
|
|
73
77
|
export interface CallbackBatcher {
|
|
74
78
|
enqueue(event: CallbackBatchEvent): boolean;
|
|
75
79
|
flush(): Promise<boolean>;
|
|
80
|
+
/** Refresh the API wrapper after reload without losing handoff receipts. */
|
|
81
|
+
setHost(host: CallbackBatchHost): void;
|
|
82
|
+
/** Update the session predicate and schedule an asynchronous drain when available. */
|
|
83
|
+
setAvailability(isAvailable: () => boolean): void;
|
|
76
84
|
deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
|
|
77
85
|
cancel(): void;
|
|
78
86
|
pendingCount(): number;
|
|
@@ -95,6 +103,7 @@ interface PendingEvent {
|
|
|
95
103
|
|
|
96
104
|
interface SharedCallbackBatcherState {
|
|
97
105
|
byHost: WeakMap<object, CallbackBatcher>;
|
|
106
|
+
bySession?: WeakMap<object, CallbackBatcher>;
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
|
|
@@ -435,6 +444,19 @@ export function createCallbackBatcher(
|
|
|
435
444
|
let sequence = 0;
|
|
436
445
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
437
446
|
let flushPromise: Promise<boolean> | undefined;
|
|
447
|
+
let isAvailable = options.isAvailable ?? (() => true);
|
|
448
|
+
let gated = options.isAvailable !== undefined;
|
|
449
|
+
let awaitingRun = false;
|
|
450
|
+
|
|
451
|
+
const available = (): boolean => {
|
|
452
|
+
try {
|
|
453
|
+
if (!isAvailable()) {
|
|
454
|
+
awaitingRun = false;
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
return !awaitingRun;
|
|
458
|
+
} catch { return false; }
|
|
459
|
+
};
|
|
438
460
|
|
|
439
461
|
const cancelTimer = (): void => {
|
|
440
462
|
if (timer) clearTimeout(timer);
|
|
@@ -443,10 +465,12 @@ export function createCallbackBatcher(
|
|
|
443
465
|
|
|
444
466
|
const schedule = (delayMs: number): void => {
|
|
445
467
|
if (timer || pending.size === 0) return;
|
|
468
|
+
// Some idle transitions (manual compaction) have no agent_settled event.
|
|
469
|
+
const delay = available() ? delayMs : retryMs;
|
|
446
470
|
timer = setTimeout(() => {
|
|
447
471
|
timer = undefined;
|
|
448
472
|
void api.flush();
|
|
449
|
-
}, Math.max(
|
|
473
|
+
}, Math.max(1, delay));
|
|
450
474
|
timer.unref?.();
|
|
451
475
|
};
|
|
452
476
|
|
|
@@ -461,6 +485,10 @@ export function createCallbackBatcher(
|
|
|
461
485
|
|
|
462
486
|
const performFlush = async (): Promise<boolean> => {
|
|
463
487
|
cancelTimer();
|
|
488
|
+
if (!available()) {
|
|
489
|
+
schedule(retryMs);
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
464
492
|
const snapshot = [...pending.entries()]
|
|
465
493
|
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
466
494
|
pending.clear();
|
|
@@ -509,6 +537,9 @@ export function createCallbackBatcher(
|
|
|
509
537
|
const overflowItems = deliverable.filter(([, item]) => !representedSet.has(item.event));
|
|
510
538
|
|
|
511
539
|
try {
|
|
540
|
+
// Pi may defer the requested run while settled handlers finish, leaving
|
|
541
|
+
// isIdle() true. Permit no second handoff until a run starts.
|
|
542
|
+
awaitingRun = gated;
|
|
512
543
|
await host.sendMessage(
|
|
513
544
|
{
|
|
514
545
|
customType: "background-completion-batch",
|
|
@@ -518,6 +549,7 @@ export function createCallbackBatcher(
|
|
|
518
549
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
519
550
|
);
|
|
520
551
|
} catch {
|
|
552
|
+
awaitingRun = false;
|
|
521
553
|
for (const [key] of deliverable) inFlight.delete(key);
|
|
522
554
|
const retryItems = [...deliverable, ...pending.entries()]
|
|
523
555
|
.sort((a, b) => a[1].sequence - b[1].sequence);
|
|
@@ -596,10 +628,19 @@ export function createCallbackBatcher(
|
|
|
596
628
|
const api: CallbackBatcher = {
|
|
597
629
|
enqueue,
|
|
598
630
|
flush,
|
|
631
|
+
setHost(next) { host = next; },
|
|
632
|
+
setAvailability(check) {
|
|
633
|
+
gated = true;
|
|
634
|
+
isAvailable = check;
|
|
635
|
+
available();
|
|
636
|
+
cancelTimer();
|
|
637
|
+
schedule(windowMs);
|
|
638
|
+
},
|
|
599
639
|
deliverUrgent,
|
|
600
640
|
cancel() {
|
|
601
641
|
cancelTimer();
|
|
602
642
|
pending.clear();
|
|
643
|
+
awaitingRun = false;
|
|
603
644
|
},
|
|
604
645
|
pendingCount() {
|
|
605
646
|
return pending.size;
|
|
@@ -613,16 +654,38 @@ export function getCallbackBatcher(
|
|
|
613
654
|
options: CallbackBatcherOptions = {},
|
|
614
655
|
): CallbackBatcher {
|
|
615
656
|
const state = globalState();
|
|
616
|
-
const key = host
|
|
657
|
+
const key = host.events ?? host;
|
|
617
658
|
const existing = state.byHost.get(key);
|
|
618
|
-
if (existing)
|
|
659
|
+
if (existing) {
|
|
660
|
+
existing.setHost(host);
|
|
661
|
+
return existing;
|
|
662
|
+
}
|
|
619
663
|
const created = createCallbackBatcher(host, options);
|
|
620
664
|
state.byHost.set(key, created);
|
|
621
665
|
return created;
|
|
622
666
|
}
|
|
623
667
|
|
|
624
668
|
export function cancelCallbackBatch(host: CallbackBatchHost): void {
|
|
625
|
-
globalState().byHost.get(host
|
|
669
|
+
const batcher = globalState().byHost.get(host.events ?? host);
|
|
670
|
+
batcher?.setAvailability(() => false);
|
|
671
|
+
batcher?.cancel();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Called on session_start, agent_start and agent_settled by both extensions. */
|
|
675
|
+
export function setCallbackBatchContext(
|
|
676
|
+
host: CallbackBatchHost,
|
|
677
|
+
ctx: { isIdle(): boolean; sessionManager?: object },
|
|
678
|
+
): void {
|
|
679
|
+
const state = globalState();
|
|
680
|
+
const bySession = state.bySession ??= new WeakMap<object, CallbackBatcher>();
|
|
681
|
+
const shared = ctx.sessionManager ? bySession.get(ctx.sessionManager) : undefined;
|
|
682
|
+
const batcher = shared ?? getCallbackBatcher(host);
|
|
683
|
+
if (ctx.sessionManager) bySession.set(ctx.sessionManager, batcher);
|
|
684
|
+
state.byHost.set(host.events ?? host, batcher);
|
|
685
|
+
batcher.setHost(host);
|
|
686
|
+
// Schedule rather than await delivery inside agent_settled: its handlers are
|
|
687
|
+
// still settling the previous run, and sendMessage may start a new one.
|
|
688
|
+
batcher.setAvailability(() => ctx.isIdle());
|
|
626
689
|
}
|
|
627
690
|
|
|
628
691
|
function globalState(): SharedCallbackBatcherState {
|
package/src/shared-navigator.ts
CHANGED
|
@@ -66,6 +66,8 @@ export type BackgroundWorkProvider = {
|
|
|
66
66
|
detail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null;
|
|
67
67
|
armCloseLabel(row: BackgroundWorkRow): string;
|
|
68
68
|
close(id: string): BackgroundWorkCloseOutcome;
|
|
69
|
+
/** Return true when this detail handled the key. The navigator refreshes the sheet. */
|
|
70
|
+
handleDetailInput?(id: string, data: string): boolean;
|
|
69
71
|
onVisibleChanged?(notify: () => void): () => void;
|
|
70
72
|
};
|
|
71
73
|
|
|
@@ -559,7 +561,7 @@ function rowSummary(row: InternalRow): string {
|
|
|
559
561
|
const parts: string[] = [];
|
|
560
562
|
if (row.statusTone !== "running" && row.kind !== "main agent") parts.push(singleLine(row.status));
|
|
561
563
|
if (row.model) parts.push(row.effort ? `${singleLine(row.model)} ${singleLine(row.effort)}` : singleLine(row.model));
|
|
562
|
-
if (row.tool) parts.push(
|
|
564
|
+
if (row.tool) parts.push(singleLine(row.tool));
|
|
563
565
|
if (row.tokens) parts.push(singleLine(row.tokens));
|
|
564
566
|
else if (row.primary) parts.push(singleLine(row.primary));
|
|
565
567
|
if (parts.length) return parts.join(" · ");
|
|
@@ -703,12 +705,7 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
703
705
|
return true;
|
|
704
706
|
}
|
|
705
707
|
if (deps.matchKey(data, "enter")) {
|
|
706
|
-
|
|
707
|
-
if (selected?.parentRow) {
|
|
708
|
-
unfocusMainList();
|
|
709
|
-
dismissOverlay();
|
|
710
|
-
}
|
|
711
|
-
else openNavigator();
|
|
708
|
+
openNavigator();
|
|
712
709
|
return true;
|
|
713
710
|
}
|
|
714
711
|
if (data === "x" || data === "X" || deps.matchKey(data, "x") || deps.matchKey(data, "X")) {
|
|
@@ -851,7 +848,8 @@ function createOverlayComponent(
|
|
|
851
848
|
let logTailRows: number = DEFAULT_LOG_TAIL_ROWS;
|
|
852
849
|
const expandedSections = new Set<string>();
|
|
853
850
|
let detailId: string = initialDetailId;
|
|
854
|
-
|
|
851
|
+
const initialRow = initialRows.find((row) => row.navigatorId === detailId);
|
|
852
|
+
let detail: BackgroundWorkDetail | null = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? (initialRow ? fallbackDetail(initialRow) : null);
|
|
855
853
|
applyDefaultExpandedSections(detail, expandedSections);
|
|
856
854
|
{
|
|
857
855
|
const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
|
|
@@ -891,8 +889,7 @@ function createOverlayComponent(
|
|
|
891
889
|
}
|
|
892
890
|
|
|
893
891
|
function activateSelectedRow(): void {
|
|
894
|
-
|
|
895
|
-
else openDetail();
|
|
892
|
+
openDetail();
|
|
896
893
|
}
|
|
897
894
|
|
|
898
895
|
function clearCloseArm(): void {
|
|
@@ -922,10 +919,6 @@ function createOverlayComponent(
|
|
|
922
919
|
function openDetail(): void {
|
|
923
920
|
const row = selectedRow();
|
|
924
921
|
if (!row) return;
|
|
925
|
-
if (row.parentRow) {
|
|
926
|
-
close();
|
|
927
|
-
return;
|
|
928
|
-
}
|
|
929
922
|
clearCloseArm();
|
|
930
923
|
detailId = row.navigatorId;
|
|
931
924
|
expandedSections.clear();
|
|
@@ -1073,6 +1066,15 @@ function createOverlayComponent(
|
|
|
1073
1066
|
requestRender();
|
|
1074
1067
|
}
|
|
1075
1068
|
}
|
|
1069
|
+
else if (data === "m" || data === "M" || data === "\x13" || deps.matchKey(data, "ctrl+s")) {
|
|
1070
|
+
const row = selectedRow();
|
|
1071
|
+
const provider = row ? state().providers.get(row.providerId) : undefined;
|
|
1072
|
+
const key = data === "m" || data === "M" ? "m" : "ctrl+s";
|
|
1073
|
+
if (!row || !provider?.handleDetailInput?.(row.id, key)) return;
|
|
1074
|
+
detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
1075
|
+
refreshMainListWidget();
|
|
1076
|
+
requestRender();
|
|
1077
|
+
}
|
|
1076
1078
|
else if (data === "l" || data === "L") {
|
|
1077
1079
|
logTailRows = cycleLogTailRows(logTailRows);
|
|
1078
1080
|
if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
|
|
@@ -1091,8 +1093,7 @@ function createOverlayComponent(
|
|
|
1091
1093
|
if (idx < 0) return;
|
|
1092
1094
|
overlayState.selected = idx;
|
|
1093
1095
|
state().mainListSelectedId = navigatorId;
|
|
1094
|
-
|
|
1095
|
-
else openDetail();
|
|
1096
|
+
openDetail();
|
|
1096
1097
|
},
|
|
1097
1098
|
/** Close the overlay (done), e.g. when the navigator is disposed. */
|
|
1098
1099
|
dismiss() { close(); },
|
|
@@ -1150,7 +1151,11 @@ function buildTranscriptDetailLines(
|
|
|
1150
1151
|
...detail.metadata.map((item) => ` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`),
|
|
1151
1152
|
];
|
|
1152
1153
|
const transcriptHeader = ["", dim(section(`transcript · latest ${tailRows} rows`, width), fg)];
|
|
1153
|
-
if (detail.transcriptDiagnostic)
|
|
1154
|
+
if (detail.transcriptDiagnostic) {
|
|
1155
|
+
for (const diagnosticRow of detail.transcriptDiagnostic.split(/\r\n|\r|\n/)) {
|
|
1156
|
+
transcriptHeader.push(` ${dim(diagnosticRow, fg)}`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1154
1159
|
// Metadata always renders; the transcript tail takes only the rows left over (the tail size is a cap, not a guarantee).
|
|
1155
1160
|
lines.push(...optionalMetadata);
|
|
1156
1161
|
if (options.maxRows !== undefined) {
|
package/src/tools.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type OutputOptions,
|
|
14
14
|
} from "./output.js";
|
|
15
15
|
import { readOutputControls } from "./shared-log-utils.js";
|
|
16
|
-
import { cancelCallbackBatch } from "./shared-callback-batcher.js";
|
|
16
|
+
import { cancelCallbackBatch, setCallbackBatchContext } from "./shared-callback-batcher.js";
|
|
17
17
|
import { inspectMeta, listMetasForOrigin, readMeta, writeMeta } from "./registry.js";
|
|
18
18
|
import { awaitFirstWatchCheck, FIRST_WATCH_CHECK_WAIT_MS, resumeRunningTask, spawnTask, startWatchTask, stopTask, type WatchTaskParams } from "./runtime.js";
|
|
19
19
|
import { runTaskMaintenance } from "./maintenance.js";
|
|
@@ -194,6 +194,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
194
194
|
const getActiveSession = () => activeSession;
|
|
195
195
|
|
|
196
196
|
pi.on("session_start", async (_event, ctx) => {
|
|
197
|
+
setCallbackBatchContext(pi, ctx);
|
|
197
198
|
activeSession = getCallbackOrigin(ctx);
|
|
198
199
|
for (const meta of listMetasForOrigin(activeSession)) {
|
|
199
200
|
if (meta.status === "running" || (meta.callback !== false && !meta.callbackSentAt && !meta.callbackSuppressedAt)) {
|
|
@@ -202,6 +203,8 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
202
203
|
}
|
|
203
204
|
runTaskMaintenance({ activeOrigin: activeSession });
|
|
204
205
|
});
|
|
206
|
+
pi.on("agent_start", (_event, ctx) => { setCallbackBatchContext(pi, ctx); });
|
|
207
|
+
pi.on("agent_settled", (_event, ctx) => { setCallbackBatchContext(pi, ctx); });
|
|
205
208
|
pi.on("session_before_switch", () => {
|
|
206
209
|
activeSession = undefined;
|
|
207
210
|
cancelCallbackBatch(pi);
|