pi-better-background-tasks 0.6.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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(0, delayMs));
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 as object;
657
+ const key = host.events ?? host;
617
658
  const existing = state.byHost.get(key);
618
- if (existing) return 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 as object)?.cancel();
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/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);