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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.6.2",
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 {
@@ -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(`tool ${singleLine(row.tool)}`);
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
- const selected = selectedMainListRow();
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
- let detail: BackgroundWorkDetail | null = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? null;
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
- if (selectedRow()?.parentRow) close();
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
- if (overlayState.rows[idx]!.parentRow) close();
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) transcriptHeader.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
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);