pi-better-harness 0.17.1 → 0.18.0

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.
@@ -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 {
@@ -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);
@@ -13,7 +13,8 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
13
13
  ## Core Features
14
14
 
15
15
  - `/goal` runtime for starting, pausing, resuming, completing, and clearing the current objective.
16
- - `escape` pauses the active goal, and it stays paused while you talk to the agent. Say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows `goal paused · say "go" or /goal resume`, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
16
+ - `/goal settings` opens an interactive settings page in the TUI. Select a row with Up/Down and toggle with Space/Enter; changes save immediately, and Escape closes the page. Both controls default to `on`. Automatic continuation controls idle and background-drain wakes; conversational resume controls the agent's Escape-pause resume tool. Goal kickoff, `/goal resume`, and `alt+g` remain available with either control off. For scripts or non-interactive sessions, use `/goal settings auto-continue on|off` and `/goal settings conversational-resume on|off`; bare `/goal settings` prints the saved values outside the TUI.
17
+ - `escape` pauses the active goal, and it stays paused while you talk to the agent. With conversational resume enabled, say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows the available resume path, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
17
18
  - Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
18
19
  - A compact goal widget that does not replace Pi's footer.
19
20
  - Background activity tracking for subagents and other registered providers.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,6 +3,8 @@ import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { Type } from "typebox";
5
5
  import { commandAvailable, commandInvocation, resolveGoalCommand } from "./command-binding.js";
6
+ import { goalPreferencesPath, readGoalPreferences, writeGoalPreference, type GoalPreferences } from "./preferences.js";
7
+ import { createGoalSettingsPage } from "./settings-page.js";
6
8
 
7
9
  import {
8
10
  collectActivitySnapshot,
@@ -82,20 +84,20 @@ const GOAL_RESUME_RULE =
82
84
  "or answers a decision you explicitly asked for in your previous message with a choice that means proceed. Never call it for questions, \"why...\", \"what about...\", \"let me think\", or discussion.";
83
85
 
84
86
  /** True when the agent may resume this goal with `goal_resume`. */
85
- export function agentResumable(goal: GoalSnapshot | null): boolean {
86
- return goal?.status === "paused" && goal.pauseReason === "interrupt";
87
+ export function agentResumable(goal: GoalSnapshot | null, conversationalResume = true): boolean {
88
+ return conversationalResume && goal?.status === "paused" && goal.pauseReason === "interrupt";
87
89
  }
88
90
 
89
91
  /** Footer status for a paused goal, telling the user how to resume it. */
90
- export function pausedGoalStatus(goal: GoalSnapshot): string {
91
- return agentResumable(goal) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
92
+ export function pausedGoalStatus(goal: GoalSnapshot, conversationalResume = true): string {
93
+ return agentResumable(goal, conversationalResume) ? 'goal paused · say "go" or /goal resume' : "goal paused · /goal resume";
92
94
  }
93
95
 
94
- function pausedGoalPrompt(goal: GoalSnapshot): string {
96
+ function pausedGoalPrompt(goal: GoalSnapshot, conversationalResume: boolean): string {
95
97
  return [
96
98
  `Pi Better Goal is paused because the user pressed escape. Goal: ${goal.objective}`,
97
99
  "Treat the user's messages as ordinary conversation: answer them, but do not continue the goal's work until it is resumed.",
98
- GOAL_RESUME_RULE,
100
+ conversationalResume ? GOAL_RESUME_RULE : "Only the user can resume this goal, with /goal resume or alt+g. A conversational go-ahead does not resume it.",
99
101
  ].join("\n");
100
102
  }
101
103
 
@@ -106,10 +108,21 @@ const GOAL_ACTIONS: readonly AutocompleteItem[] = [
106
108
  { value: "resume", label: "resume", description: "Resume the paused goal" },
107
109
  { value: "clear", label: "clear", description: "Remove the current goal" },
108
110
  { value: "complete", label: "complete", description: "Mark the current goal complete" },
111
+ { value: "settings", label: "settings", description: "Inspect or persist goal continuation and conversational resume controls" },
109
112
  ];
110
113
 
114
+ const GOAL_SETTINGS = ["auto-continue", "conversational-resume"] as const;
115
+
111
116
  export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
112
117
  const prefix = argumentPrefix.trimStart().toLowerCase();
118
+ if (prefix.startsWith("settings ")) {
119
+ const settingPrefix = prefix.slice("settings ".length);
120
+ const choices = GOAL_SETTINGS.flatMap((setting) => settingPrefix.startsWith(`${setting} `)
121
+ ? ["on", "off"].map((mode) => `settings ${setting} ${mode}`)
122
+ : [`settings ${setting}`]);
123
+ const matches = choices.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
124
+ return matches.length > 0 ? matches : null;
125
+ }
113
126
  const matches = GOAL_ACTIONS.filter((action) => action.value.startsWith(prefix));
114
127
  return matches.length > 0 ? [...matches] : null;
115
128
  }
@@ -214,9 +227,11 @@ function formatGoal(
214
227
  goal: GoalSnapshot | null,
215
228
  continuation: ReturnType<typeof currentContinuationState> = null,
216
229
  stall = observeGoalStall(goal, continuation),
230
+ preferences: GoalPreferences = { autoContinue: true, conversationalResume: true },
217
231
  ): string {
232
+ const settings = formatPreferences(preferences);
218
233
  if (!goal) {
219
- return "No goal is set.";
234
+ return `No goal is set.\n${settings}`;
220
235
  }
221
236
  const budget = goal.tokenBudget === null ? "none" : String(goal.tokenBudget);
222
237
  const timing = goalTiming(goal);
@@ -232,14 +247,22 @@ function formatGoal(
232
247
  `Elapsed time: ${timing.elapsedSeconds}s`,
233
248
  `Observable progress: ${stall?.state ?? "unknown"}`,
234
249
  continuationStatus,
250
+ settings,
235
251
  ...(goal.status === "paused"
236
- ? [agentResumable(goal)
252
+ ? [agentResumable(goal, preferences.conversationalResume)
237
253
  ? 'Resume: say "go" (the agent calls goal_resume), /goal resume, or alt+g'
238
254
  : "Resume: /goal resume or alt+g"]
239
255
  : []),
240
256
  ].join("\n");
241
257
  }
242
258
 
259
+ function formatPreferences(preferences: GoalPreferences): string {
260
+ return [
261
+ `Automatic continuation setting: ${preferences.autoContinue ? "on" : "off"}${WAKE_DISABLED ? " (disabled by environment)" : ""}`,
262
+ `Conversational resume setting: ${preferences.conversationalResume ? "on" : "off"}`,
263
+ ].join("\n");
264
+ }
265
+
243
266
  function formatSnapshot(snapshot: ActivitySnapshot): string {
244
267
  const providers = snapshot.providers.map((provider) => {
245
268
  const active = provider.items.filter((item) => item.active).length;
@@ -284,6 +307,7 @@ export default function (pi: ExtensionAPI): void {
284
307
  let idleContinuationSignature = "";
285
308
  let refreshGoalWidget: ((force?: boolean) => void) | undefined;
286
309
  let lastAgentEvidence: ContinuationEvidence | null = null;
310
+ let preferences: GoalPreferences = { autoContinue: true, conversationalResume: true };
287
311
  /** Background items active when each pending blocking question started, keyed by tool call id. */
288
312
  const pendingQuestions = new Map<string, Map<string, BackgroundWorkItem>>();
289
313
 
@@ -387,7 +411,9 @@ export default function (pi: ExtensionAPI): void {
387
411
  return;
388
412
  }
389
413
  setGoal(goalWithStatus(goal, "paused", undefined, "interrupt"), ctx, "runtime");
390
- notifyGoal(ctx, 'Goal paused. Say "go" to resume it, or use /goal resume.');
414
+ notifyGoal(ctx, preferences.conversationalResume
415
+ ? 'Goal paused. Say "go" to resume it, or use /goal resume.'
416
+ : "Goal paused. Use /goal resume or alt+g to resume it.");
391
417
  };
392
418
 
393
419
  /** Why a paused goal cannot become active again, or null when it can. */
@@ -470,7 +496,7 @@ export default function (pi: ExtensionAPI): void {
470
496
  kind: "continuation" | "background-drained",
471
497
  snapshot?: ActivitySnapshot,
472
498
  ): void => {
473
- if (WAKE_DISABLED || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
499
+ if (WAKE_DISABLED || !preferences.autoContinue || !isPokeable(goal) || continuationQueuedFor === goal.goalId) {
474
500
  return;
475
501
  }
476
502
  if (kind === "continuation" && currentContinuationState(ctx, goal.goalId)?.blocked) {
@@ -500,7 +526,7 @@ export default function (pi: ExtensionAPI): void {
500
526
  const execution = executionGeneration;
501
527
  const wake = wakeGeneration;
502
528
  const auditIsCurrent = (): boolean => {
503
- if (execution !== executionGeneration || wake !== wakeGeneration) return false;
529
+ if (WAKE_DISABLED || !preferences.autoContinue || execution !== executionGeneration || wake !== wakeGeneration) return false;
504
530
  const current = currentGoalSnapshot(ctx);
505
531
  return isPokeable(current) && current.goalId === goalId &&
506
532
  continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
@@ -603,7 +629,7 @@ export default function (pi: ExtensionAPI): void {
603
629
  const background = snapshot?.backgroundRunning
604
630
  ? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
605
631
  : undefined;
606
- if (goal?.status === "paused") return background ? `${pausedGoalStatus(goal)} · ${background}` : pausedGoalStatus(goal);
632
+ if (goal?.status === "paused") return background ? `${pausedGoalStatus(goal, preferences.conversationalResume)} · ${background}` : pausedGoalStatus(goal, preferences.conversationalResume);
607
633
  if (background) return background;
608
634
  const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
609
635
  if (continuation?.blocked) return "waiting: no progress";
@@ -633,7 +659,7 @@ export default function (pi: ExtensionAPI): void {
633
659
  if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
634
660
  try {
635
661
  const active = pi.getActiveTools();
636
- const wanted = agentResumable(goal);
662
+ const wanted = agentResumable(goal, preferences.conversationalResume);
637
663
  if (active.includes(GOAL_RESUME_TOOL) === wanted) return;
638
664
  pi.setActiveTools(wanted ? [...active, GOAL_RESUME_TOOL] : active.filter((name) => name !== GOAL_RESUME_TOOL));
639
665
  } catch {
@@ -760,17 +786,57 @@ export default function (pi: ExtensionAPI): void {
760
786
  });
761
787
 
762
788
  pi.registerCommand("goal", {
763
- description: "Create, inspect, pause, resume, clear, or complete the active goal",
789
+ description: "Create, inspect, pause, resume, clear, or complete the active goal; configure persistent goal settings",
764
790
  getArgumentCompletions: goalArgumentCompletions,
765
791
  handler: async (args, ctx) => {
766
792
  currentCtx = ctx;
767
793
  const trimmed = args.trim();
768
794
  const current = getGoal(ctx);
769
795
 
796
+ if (trimmed === "settings" || trimmed.startsWith("settings ")) {
797
+ const changePreference = async (key: keyof GoalPreferences, enabled: boolean): Promise<void> => {
798
+ const previous = preferences;
799
+ preferences = writeGoalPreference(key, enabled);
800
+ if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
801
+ syncResumeTool(getGoal(ctx));
802
+ applyStatus(ctx);
803
+ if (!previous.autoContinue && preferences.autoContinue) {
804
+ const snapshot = await publishSnapshot(ctx);
805
+ const goal = getGoal(ctx);
806
+ if (snapshot && isPokeable(goal) && !isForegroundBusy(ctx) && !snapshot.backgroundRunning) {
807
+ scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
808
+ }
809
+ }
810
+ };
811
+ if (trimmed === "settings" && ctx.hasUI && ctx.mode !== "rpc" && typeof ctx.ui.custom === "function") {
812
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => createGoalSettingsPage(theme, {
813
+ get: () => ({ ...preferences }),
814
+ change: changePreference,
815
+ }, () => tui.requestRender(), () => done()));
816
+ return;
817
+ }
818
+ const [, setting, mode, ...extra] = trimmed.split(/\s+/);
819
+ if (setting !== undefined && (!GOAL_SETTINGS.includes(setting as typeof GOAL_SETTINGS[number]) ||
820
+ (mode !== "on" && mode !== "off") || extra.length > 0)) {
821
+ notifyGoal(ctx, "Usage: /goal settings [auto-continue|conversational-resume on|off]", "warning");
822
+ return;
823
+ }
824
+ if (setting !== undefined) {
825
+ try {
826
+ await changePreference(setting === "auto-continue" ? "autoContinue" : "conversationalResume", mode === "on");
827
+ } catch (error) {
828
+ notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
829
+ return;
830
+ }
831
+ }
832
+ ctx.ui.notify(`${formatPreferences(preferences)}\nPreferences: ${goalPreferencesPath()}`, "info");
833
+ return;
834
+ }
835
+
770
836
  if (!trimmed) {
771
837
  const continuation = current ? currentContinuationState(ctx, current.goalId) : null;
772
838
  // Inspection is always explicit user intent; show the full summary even while busy.
773
- ctx.ui.notify(formatGoal(current, continuation, observeGoalStall(current, continuation, { foregroundRunning })), "info");
839
+ ctx.ui.notify(formatGoal(current, continuation, observeGoalStall(current, continuation, { foregroundRunning }), preferences), "info");
774
840
  return;
775
841
  }
776
842
 
@@ -847,8 +913,8 @@ export default function (pi: ExtensionAPI): void {
847
913
  backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
848
914
  });
849
915
  return {
850
- content: [{ type: "text", text: formatGoal(goal, continuation, stall) }],
851
- details: { goal, continuation, stall, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
916
+ content: [{ type: "text", text: formatGoal(goal, continuation, stall, preferences) }],
917
+ details: { goal, continuation, stall, preferences: { ...preferences }, timing: goal ? goalTiming(goal) : null, hasGoal: goal !== null },
852
918
  };
853
919
  },
854
920
  });
@@ -904,7 +970,13 @@ export default function (pi: ExtensionAPI): void {
904
970
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
905
971
  const reason = (params as { reason?: string }).reason;
906
972
  const current = getGoal(ctx);
907
- if (!agentResumable(current)) {
973
+ if (!preferences.conversationalResume) {
974
+ return {
975
+ content: [{ type: "text", text: "Conversational goal resume is disabled. Only the user can resume with /goal resume or alt+g." }],
976
+ details: { ok: false, goal: current },
977
+ };
978
+ }
979
+ if (!agentResumable(current, preferences.conversationalResume)) {
908
980
  const text = current?.status === "paused"
909
981
  ? "This goal was paused with /goal pause or by the runtime. Only the user can resume it, with /goal resume."
910
982
  : "No goal is paused, so there is nothing to resume.";
@@ -996,6 +1068,11 @@ export default function (pi: ExtensionAPI): void {
996
1068
  collectionPending = false;
997
1069
  currentCtx = ctx;
998
1070
  foregroundRunning = !ctx.isIdle();
1071
+ try {
1072
+ preferences = readGoalPreferences();
1073
+ } catch (error) {
1074
+ notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
1075
+ }
999
1076
  const restoredGoal = getGoal(ctx);
1000
1077
  if (restoredGoal?.status === "active" && !restoredGoal.command && skillCommandName(restoredGoal.objective)) {
1001
1078
  setGoal(goalWithStatus(restoredGoal, "paused"), ctx, "runtime");
@@ -1046,7 +1123,8 @@ export default function (pi: ExtensionAPI): void {
1046
1123
  const owner = getWorkflow(ctx);
1047
1124
  const snapshot = await publishSnapshot(ctx);
1048
1125
  if (!snapshot) return;
1049
- const pausedInstruction = agentResumable(goal) ? pausedGoalPrompt(goal!) : "";
1126
+ const pausedInstruction = goal?.status === "paused" && goal.pauseReason === "interrupt"
1127
+ ? pausedGoalPrompt(goal, preferences.conversationalResume) : "";
1050
1128
  if (!isPokeable(goal) && !owner) {
1051
1129
  return pausedInstruction ? { systemPrompt: `${event.systemPrompt}\n\n${pausedInstruction}` } : undefined;
1052
1130
  }
@@ -0,0 +1,70 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export interface GoalPreferences {
6
+ autoContinue: boolean;
7
+ conversationalResume: boolean;
8
+ }
9
+
10
+ export interface GoalPreferenceSeams {
11
+ agentDir?: () => string;
12
+ }
13
+
14
+ export function goalPreferencesPath(seams: GoalPreferenceSeams = {}): string {
15
+ return join((seams.agentDir ?? getAgentDir)(), "extensions", "pi-better-goal-preferences.json");
16
+ }
17
+
18
+ export function readGoalPreferences(seams: GoalPreferenceSeams = {}): GoalPreferences {
19
+ const path = goalPreferencesPath(seams);
20
+ let raw: string;
21
+ try {
22
+ raw = readFileSync(path, "utf8");
23
+ } catch (error) {
24
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
25
+ return { autoContinue: true, conversationalResume: true };
26
+ }
27
+ throw new Error(`Goal preferences at ${path} could not be read: ${messageOf(error)}`);
28
+ }
29
+ let parsed: unknown;
30
+ try {
31
+ parsed = JSON.parse(raw);
32
+ } catch (error) {
33
+ throw new Error(`Goal preferences at ${path} are not valid JSON: ${messageOf(error)}`);
34
+ }
35
+ const value = parsed as Partial<GoalPreferences> & { version?: unknown } | null;
36
+ if (value?.version !== 1 ||
37
+ (value.autoContinue !== undefined && typeof value.autoContinue !== "boolean") ||
38
+ (value.conversationalResume !== undefined && typeof value.conversationalResume !== "boolean")) {
39
+ throw new Error(`Goal preferences at ${path} must contain version 1 and boolean autoContinue/conversationalResume settings.`);
40
+ }
41
+ return {
42
+ autoContinue: value.autoContinue ?? true,
43
+ conversationalResume: value.conversationalResume ?? true,
44
+ };
45
+ }
46
+
47
+ /** Persist one control without replacing the other session's saved choice. */
48
+ export function writeGoalPreference(
49
+ key: keyof GoalPreferences,
50
+ enabled: boolean,
51
+ seams: GoalPreferenceSeams = {},
52
+ ): GoalPreferences {
53
+ const preferences = { ...readGoalPreferences(seams), [key]: enabled };
54
+ const path = goalPreferencesPath(seams);
55
+ const pending = `${path}.${process.pid}.tmp`;
56
+ try {
57
+ mkdirSync(dirname(path), { recursive: true });
58
+ writeFileSync(pending, `${JSON.stringify({ version: 1, ...preferences }, null, 2)}\n`, "utf8");
59
+ renameSync(pending, path);
60
+ } catch (error) {
61
+ throw new Error(`Goal preferences at ${path} could not be written: ${messageOf(error)}`);
62
+ } finally {
63
+ rmSync(pending, { force: true });
64
+ }
65
+ return preferences;
66
+ }
67
+
68
+ function messageOf(error: unknown): string {
69
+ return error instanceof Error ? error.message : String(error);
70
+ }
@@ -0,0 +1,67 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable } from "@earendil-works/pi-tui";
3
+ import type { GoalPreferences } from "./preferences.js";
4
+
5
+ const rows: { key: keyof GoalPreferences; label: string }[] = [
6
+ { key: "autoContinue", label: "Automatic continuation" },
7
+ { key: "conversationalResume", label: "Conversational resume" },
8
+ ];
9
+
10
+ export function createGoalSettingsPage(
11
+ theme: Theme,
12
+ handlers: { get(): GoalPreferences; change(key: keyof GoalPreferences, enabled: boolean): Promise<void> },
13
+ requestRender: () => void,
14
+ close: () => void,
15
+ ): Component & Focusable {
16
+ let row = 0;
17
+ let busy = false;
18
+ let message = "";
19
+ let error = false;
20
+ function change(): void {
21
+ const selected = rows[row]!;
22
+ busy = true;
23
+ message = "Saving...";
24
+ error = false;
25
+ void Promise.resolve().then(() => handlers.change(selected.key, !handlers.get()[selected.key])).then(() => {
26
+ message = "Saved.";
27
+ }, (cause: unknown) => {
28
+ message = cause instanceof Error ? cause.message : String(cause);
29
+ error = true;
30
+ }).finally(() => {
31
+ busy = false;
32
+ requestRender();
33
+ });
34
+ }
35
+ return {
36
+ focused: false,
37
+ invalidate() {},
38
+ handleInput(data: string) {
39
+ if (matchesKey(data, Key.escape)) { close(); return; }
40
+ if (busy) return;
41
+ if (matchesKey(data, Key.up)) row = Math.max(0, row - 1);
42
+ else if (matchesKey(data, Key.down)) row = Math.min(rows.length - 1, row + 1);
43
+ else if (matchesKey(data, Key.enter) || data === " ") change();
44
+ requestRender();
45
+ },
46
+ render(width: number) {
47
+ const w = Math.max(1, Math.floor(width));
48
+ const preferences = handlers.get();
49
+ const lines = [theme.fg("accent", theme.bold(" Goal settings")), ""];
50
+ rows.forEach(({ key, label }, index) => {
51
+ const selected = row === index;
52
+ const title = theme.fg(selected ? "accent" : "text", (selected ? "> " : " ") + label);
53
+ const value = preferences[key] ? "On" : "Off";
54
+ const active = selected ? theme.inverse(value) : theme.fg("text", value);
55
+ if (w < 40) {
56
+ lines.push(title, " " + active);
57
+ } else {
58
+ const line = title + " ".repeat(Math.max(1, 30 - visibleWidth(title))) + active;
59
+ lines.push(selected ? theme.bg("selectedBg", theme.bold(line)) : line);
60
+ }
61
+ });
62
+ if (message) lines.push("", theme.fg(error ? "error" : "muted", message));
63
+ lines.push("", theme.fg("dim", "Up/Down Select · Space/Enter Toggle · Esc Back"));
64
+ return lines.map((line) => truncateToWidth(line, w, ""));
65
+ },
66
+ };
67
+ }
@@ -2,6 +2,8 @@
2
2
 
3
3
  `pi-better-plan` keeps a structured execution plan visible while Pi works.
4
4
 
5
+ ![Pi Better Plan package preview](https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-plan.png)
6
+
5
7
  ## What It Does
6
8
 
7
9
  - Gives models `update_plan` and `get_plan` tools for atomic, explicit progress updates.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,8 @@
31
31
  "pi": {
32
32
  "extensions": [
33
33
  "./src/index.ts"
34
- ]
34
+ ],
35
+ "image": "https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-plan.png"
35
36
  },
36
37
  "scripts": {
37
38
  "test": "node --import tsx --test test/*.test.ts",
@@ -2,6 +2,8 @@
2
2
 
3
3
  Sandbox permissions for Pi's foreground tools and detached subagents.
4
4
 
5
+ ![Pi Better Sandbox package preview](https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-sandbox.png)
6
+
5
7
  It is installed by default with [`pi-better-harness`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-harness#readme), and can be installed on its own:
6
8
 
7
9
  ```sh
@@ -386,6 +388,41 @@ credential stores under a read-only outside root, a writable outside root
386
388
  The macOS permission combinations are covered by real-kernel tests; Linux
387
389
  mount behavior requires a Linux runner with Bubblewrap and user namespaces.
388
390
 
391
+ ### macOS setuid executable limitation
392
+
393
+ Seatbelt-sandboxed processes cannot execute setuid/setgid binaries, even with
394
+ an `(allow default)` profile. On the verified host, `/bin/ps` and `/usr/bin/top`
395
+ are setuid-root binaries. A shell attempting to run `/bin/ps` reports
396
+ `Operation not permitted` and exits 126. This is an OS-level execution
397
+ restriction, not a harness command deny rule, a filesystem permission failure,
398
+ or evidence that Run commands & applications is Off.
399
+
400
+ Read-only reproduction (verified on 2026-10-04):
401
+
402
+ ```sh
403
+ ls -l /bin/ps /usr/bin/top
404
+ /usr/bin/sandbox-exec -p '(version 1) (allow default)' \
405
+ /bin/bash -c '/bin/ps -p $$ -o pid,ppid,comm'
406
+ ```
407
+
408
+ The second command fails with `/bin/bash: /bin/ps: Operation not permitted`
409
+ and exit 126; the same `ps` query outside Seatbelt succeeds. Launching `ps`
410
+ directly through `sandbox-exec` instead reports `execvp()` failure and exit 71.
411
+ Changing the requested output fields or adding a profile allowance for `ps`
412
+ does not remove this limitation.
413
+
414
+ When process inspection is a prerequisite for safe work, do not interpret the
415
+ denial as an empty process list. Hold the dependent operation until an
416
+ authorized coordinator or environment custodian supplies a fresh snapshot
417
+ and explicit ownership handoff from outside the task sandbox. Prefer
418
+ `pid,ppid,pgid,lstart,comm` over full command arguments, which may expose
419
+ secrets. Do not disable the sandbox or modify/copy privileged executables to
420
+ work around the denial. A trusted process-inspection adapter would require
421
+ separate implementation; this package does not currently provide one.
422
+
423
+ See the [session evidence](../../docs/sandbox-session-validation.md#macos-process-inspection-follow-up)
424
+ and [documented setuid/setgid limitation](https://github.com/navikt/cplt/blob/main/docs/known-impacts.md).
425
+
389
426
  ## For other extensions
390
427
 
391
428
  The effective policy is published as a frozen snapshot on Pi's extension event
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Pi extension with independent Main and Subagents permission profiles, file guards, and OS sandbox enforcement.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,7 +16,8 @@
16
16
  "pi": {
17
17
  "extensions": [
18
18
  "./index.ts"
19
- ]
19
+ ],
20
+ "image": "https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-sandbox.png"
20
21
  },
21
22
  "repository": {
22
23
  "type": "git",
@@ -2,6 +2,8 @@
2
2
 
3
3
  `pi-better-ssh` runs short synchronous remote commands over safe, reusable SSH connections. It adds explicit remote tools; Pi's built-in `bash` remains local and is never overridden.
4
4
 
5
+ ![Pi Better SSH package preview](https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-ssh.png)
6
+
5
7
  ## Install
6
8
 
7
9
  ```sh
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-ssh",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Pi extension for safe synchronous remote bash commands over reusable SSH connections.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,8 @@
14
14
  "pi": {
15
15
  "extensions": [
16
16
  "./src/index.ts"
17
- ]
17
+ ],
18
+ "image": "https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-ssh.png"
18
19
  },
19
20
  "repository": {
20
21
  "type": "git",
@@ -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
 
@@ -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;
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -52,12 +52,12 @@
52
52
  "test": "node --test test/*.test.mjs"
53
53
  },
54
54
  "dependencies": {
55
- "pi-better-background-tasks": "0.6.3",
56
- "pi-better-goal": "0.6.1",
57
- "pi-better-plan": "0.5.3",
58
- "pi-better-sandbox": "0.9.0",
59
- "pi-better-ssh": "0.1.1",
60
- "pi-better-subagents": "0.13.0",
55
+ "pi-better-background-tasks": "0.6.4",
56
+ "pi-better-goal": "0.7.0",
57
+ "pi-better-plan": "0.5.4",
58
+ "pi-better-sandbox": "0.9.1",
59
+ "pi-better-ssh": "0.1.2",
60
+ "pi-better-subagents": "0.13.1",
61
61
  "smol-toml": "1.9.0",
62
62
  "yaml": "^2.9.1"
63
63
  },