pi-better-subagents 0.13.0 → 0.13.1

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/docs/usage.md CHANGED
@@ -246,13 +246,17 @@ given.
246
246
  Its context can't clog the parent, its crash can't corrupt parent state, and
247
247
  its output is durable in a log file.
248
248
  - **Completions trigger one batched turn that fetches durable results.** Ordinary
249
- terminal callbacks accumulate for 100 ms and share one
249
+ terminal callbacks stay in the harness queue while the foreground agent is
250
+ busy. Once Pi is idle, they accumulate for 100 ms and share one
250
251
  `pi.sendMessage(..., { deliverAs: "followUp", triggerTurn: true })` notification
251
252
  with background-task completions from the same Pi host. Each row contains only
252
253
  source, id, label, terminal status, and the `subagent_result` lookup. Full
253
254
  results and logs stay in durable tools and are never embedded in the callback.
254
255
  Set `PI_BETTER_CALLBACK_BATCH_MS` to `0` through `5000` milliseconds to tune
255
- the accumulation window; invalid values use 100 ms. A failed handoff keeps the
256
+ the accumulation window; invalid values use 100 ms. `agent_settled` schedules
257
+ the aggregate without starting a model run inside the lifecycle handler.
258
+ Completions arriving during that model run, including bounded overflow,
259
+ stay queued for the next available boundary. A failed handoff keeps the
256
260
  pending records retryable, and `/reload` recovers records explicitly marked
257
261
  pending. `callback:false` sends no model message; read the durable result later
258
262
  with `subagent_result`.
@@ -649,10 +653,11 @@ only after a successful handoff and dedupe across reloads and repeated health ti
649
653
  Persisted unmarked orphaned/lost states are recovered on the health ticker after
650
654
  `/reload` even when process evidence does not produce a fresh transition.
651
655
 
652
- Pi's optional `followUpMode: all` remains useful when completion groups arrive
653
- after an earlier 100 ms batch has already flushed: Pi can consume those queued
654
- follow-ups in one later agent turn. This extension does not change Pi core or
655
- Pi's default follow-up mode.
656
+ Ordinary completion batching does not depend on Pi's `followUpMode`: events
657
+ stay in the harness until availability rather than becoming separate queued
658
+ follow-ups during the foreground run. Urgent health/failure alerts retain their
659
+ existing immediate follow-up behavior. This extension does not change Pi core
660
+ or Pi's default follow-up mode.
656
661
 
657
662
  ### Surfacing health (tools + passive widget)
658
663
 
package/index.ts CHANGED
@@ -116,7 +116,7 @@ import {
116
116
  getSharedCapacityGate,
117
117
  } from "./capacity.mjs";
118
118
  import { buildHealthCallbackDelivery } from "./completion.ts";
119
- import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
119
+ import { cancelCallbackBatch, getCallbackBatcher, setCallbackBatchContext } from "./shared-callback-batcher.ts";
120
120
  import { completionCallbackFields, failureAttentionFields, healthCallbackFields } from "./callback-fields.ts";
121
121
  import { collectRunFailures, failurePath, markFailureAttentionDelivered, pendingFailureAttention } from "./failures.ts";
122
122
  import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
@@ -2340,6 +2340,7 @@ export default function (pi: ExtensionAPI) {
2340
2340
 
2341
2341
  // ---- live-status lifecycle -----------------------------------------
2342
2342
  pi.on("agent_start", async (_event, ctx) => {
2343
+ setCallbackBatchContext(pi, ctx);
2343
2344
  uiCtx = ctx;
2344
2345
  mainAgentStartedAt = Date.now();
2345
2346
  mainAgentTools.clear();
@@ -2372,6 +2373,7 @@ export default function (pi: ExtensionAPI) {
2372
2373
  });
2373
2374
 
2374
2375
  pi.on("agent_settled", async (_event, ctx) => {
2376
+ setCallbackBatchContext(pi, ctx);
2375
2377
  mainAgentStartedAt = undefined;
2376
2378
  mainAgentTools.clear();
2377
2379
  refreshBackgroundWorkNavigator(ctx);
@@ -2387,6 +2389,7 @@ export default function (pi: ExtensionAPI) {
2387
2389
  // alive, resume the ticking widget. Deferred out of the factory per pi's
2388
2390
  // "no background resources at load" rule.
2389
2391
  pi.on("session_start", async (_event, ctx) => {
2392
+ setCallbackBatchContext(pi, ctx);
2390
2393
  agentSessionSettings.restore(ctx);
2391
2394
  restoreDelegationMode(ctx);
2392
2395
  uiCtx = ctx;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
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 {