pi-better-harness 0.20.0 → 0.21.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.
package/README.md CHANGED
@@ -86,6 +86,39 @@ Load the bundled harness or run it directly from a checkout:
86
86
  pi -e ./packages/pi-better-harness/extensions/minimal-output/index.ts
87
87
  ```
88
88
 
89
+ ## Harness Settings
90
+
91
+ The bundled harness provides `/harness-settings`, using Pi's native settings
92
+ list. It opens the settings screens of loaded Sandbox, Subagents, and Goal
93
+ packages without duplicating their configuration. `/sandbox`, `/subagents
94
+ settings`, and `/goal settings` remain available in standalone installations.
95
+ Packages without a settings screen are not listed. Pi's `/settings` is unchanged.
96
+
97
+ The hub also owns **Completions while busy**, shared by Subagents and Background
98
+ Tasks. Choose **Wait until idle** (the default) or **Steer active run**. Changes
99
+ apply immediately and autosave to the current session branch, including across
100
+ reloads. Press **Ctrl+S** to save the current choice as your default for future
101
+ Pi sessions; changing a session afterward does not change that saved default.
102
+ Saving a default leaves already-open sessions unchanged, including when
103
+ navigating to a branch without a session override.
104
+ The user default is stored in
105
+ `<agent-dir>/extensions/pi-better-callback-preferences.json` and also applies
106
+ when either callback package is loaded standalone. The former
107
+ `PI_BETTER_CALLBACK_WHILE_BUSY` environment variable is no longer supported.
108
+
109
+ Next-prompt inference is deferred pending safe public auth/header resolution in
110
+ Pi's SDK. It is not loaded by the bundle, has no toggle or preference store in
111
+ this version, and makes no auxiliary model requests. The integration blocker is
112
+ [issue #426](https://github.com/1aboveio/pi-better-harness/issues/426).
113
+
114
+ The hub is TUI-only. The standalone-package installer does not install
115
+ Harness-only extensions; load the bundle or the settings extension from the
116
+ checkout to use the hub:
117
+
118
+ ```sh
119
+ pi -e ./packages/pi-better-harness/extensions/settings/index.ts
120
+ ```
121
+
89
122
  ## When To Use
90
123
 
91
124
  Use the installer when you want every core extension with standalone package identities. Install an individual package instead when you only need the sandbox, subagents, shell task supervision, synchronous SSH, goal tracking, or plans.
@@ -0,0 +1,46 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { chooseHarnessSetting } from "./page.ts";
3
+ import { createSettingsRegistry } from "./registry.ts";
4
+ import { changeCallbackSetting, getCallbackSettings, saveCallbackDefault } from "pi-better-background-tasks/src/shared-callback-batcher.ts";
5
+
6
+ export default function harnessSettingsExtension(pi: ExtensionAPI): void {
7
+ const registry = createSettingsRegistry(pi);
8
+ pi.on("session_start", () => registry.refresh());
9
+ pi.on("session_shutdown", () => registry.dispose());
10
+
11
+ pi.registerCommand("harness-settings", {
12
+ description: "Open loaded package settings",
13
+ handler: async (_args, ctx) => {
14
+ if (ctx.mode !== "tui") { ctx.ui.notify("Harness settings requires the interactive TUI.", "warning"); return; }
15
+ registry.refresh();
16
+ let selected: string | undefined;
17
+ const reported = new Set<string>();
18
+ const readSettings = (): ReturnType<typeof getCallbackSettings> => {
19
+ try { return getCallbackSettings(ctx); }
20
+ catch (error) {
21
+ const message = `Callback default unavailable; using Wait until idle: ${error instanceof Error ? error.message : String(error)}`;
22
+ if (!reported.has(message)) { ctx.ui.notify(message, "error"); reported.add(message); }
23
+ return { mode: "hold", source: "default" };
24
+ }
25
+ };
26
+ while (true) {
27
+ const links = registry.list();
28
+ try {
29
+ selected = await chooseHarnessSetting(ctx, links, selected, {
30
+ get: readSettings,
31
+ change: mode => changeCallbackSetting(pi, ctx, mode),
32
+ save: () => saveCallbackDefault(readSettings().mode),
33
+ });
34
+ } catch (error) {
35
+ ctx.ui.notify(`Settings unavailable: ${error instanceof Error ? error.message : String(error)}`, "error");
36
+ return;
37
+ }
38
+ if (!selected) return;
39
+ const link = links.find(item => `link:${item.id}` === selected);
40
+ if (!link) continue;
41
+ try { await link.open(ctx); }
42
+ catch (error) { ctx.ui.notify(`Settings unavailable: ${error instanceof Error ? error.message : String(error)}`, "error"); }
43
+ }
44
+ },
45
+ });
46
+ }
@@ -0,0 +1,79 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Container, SettingsList, Text, getKeybindings, setKeybindings, KeybindingsManager, TUI_KEYBINDINGS, matchesKey, type SettingItem } from "@earendil-works/pi-tui";
3
+ import type { SettingsLink } from "./registry.ts";
4
+
5
+ export interface CallbackSettingsControl {
6
+ get(): { mode: "hold" | "steer"; source: "session" | "default" };
7
+ change(mode: "hold" | "steer"): void;
8
+ save(): void;
9
+ }
10
+
11
+ const CALLBACK_SETTING_ID = "callbacks:while-busy";
12
+ const callbackLabel = (mode: "hold" | "steer") => mode === "steer" ? "Steer active run" : "Wait until idle";
13
+
14
+ export async function chooseHarnessSetting(ctx: any, links: SettingsLink[], selected?: string, callbacks?: CallbackSettingsControl): Promise<string | undefined> {
15
+ return ctx.ui.custom((tui: any, theme: Theme, _kb: unknown, done: (id?: string) => void) => {
16
+ let callbackMode = callbacks?.get().mode ?? "hold";
17
+ const callbackSetting: SettingItem | undefined = callbacks ? {
18
+ id: CALLBACK_SETTING_ID,
19
+ label: "Completions while busy",
20
+ currentValue: callbackLabel(callbackMode),
21
+ description: callbacks.get().source === "session" ? "Session setting" : "User default",
22
+ values: ["Wait until idle", "Steer active run"],
23
+ } : undefined;
24
+ const items: SettingItem[] = [
25
+ ...(callbackSetting ? [callbackSetting] : []),
26
+ ...links.map(link => ({ id: `link:${link.id}`, label: link.label, currentValue: link.command, description: "Open package settings", values: [link.command] })),
27
+ ];
28
+ const feedback = new Text("", 0, 0);
29
+ const report = (message: string, error = false) => {
30
+ feedback.setText(theme.fg(error ? "error" : "muted", message));
31
+ tui.requestRender?.();
32
+ };
33
+ const list = new SettingsList(items, 10, {
34
+ label: (text, active) => theme.fg(active ? "accent" : "text", text),
35
+ value: (text, active) => active ? theme.inverse(text) : theme.fg("muted", text),
36
+ description: text => theme.fg("muted", text), cursor: "> ", hint: text => theme.fg("dim", text),
37
+ }, (id, value) => {
38
+ if (id !== CALLBACK_SETTING_ID || !callbacks || !callbackSetting) { done(id); return; }
39
+ try {
40
+ const nextMode = value === "Steer active run" ? "steer" : "hold";
41
+ callbacks.change(nextMode);
42
+ callbackMode = nextMode;
43
+ callbackSetting.description = "Session setting";
44
+ report("Session setting saved.");
45
+ } catch (error) {
46
+ list.updateValue(CALLBACK_SETTING_ID, callbackLabel(callbackMode));
47
+ report(error instanceof Error ? error.message : String(error), true);
48
+ }
49
+ }, () => done(), { enableSearch: false });
50
+
51
+ const index = selected ? items.findIndex(item => item.id === selected) : 0;
52
+ if (index > 0) {
53
+ // SettingsList has no public selection setter. Restore synchronously using
54
+ // default navigation, then return the untouched user bindings before input.
55
+ const bindings = getKeybindings();
56
+ try {
57
+ setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
58
+ for (let i = 0; i < index; i++) list.handleInput("\x1b[B");
59
+ } finally { setKeybindings(bindings); }
60
+ }
61
+ const page = new Container();
62
+ page.addChild(new Text(theme.fg("accent", theme.bold("Harness settings")), 0, 0));
63
+ page.addChild(list);
64
+ if (callbacks) page.addChild(new Text(theme.fg("dim", "Callbacks autosave to this session. Ctrl+S saves the default for future sessions."), 0, 0));
65
+ page.addChild(feedback);
66
+ return {
67
+ render: (width: number) => page.render(width),
68
+ invalidate: () => page.invalidate(),
69
+ handleInput(data: string) {
70
+ if (callbacks && matchesKey(data, "ctrl+s")) {
71
+ try { callbacks.save(); report("Callback default saved."); }
72
+ catch (error) { report(error instanceof Error ? error.message : String(error), true); }
73
+ return;
74
+ }
75
+ list.handleInput(data);
76
+ },
77
+ };
78
+ });
79
+ }
@@ -0,0 +1,27 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface SettingsLink {
4
+ id: string;
5
+ label: string;
6
+ command: string;
7
+ open(ctx: ExtensionCommandContext): Promise<void> | void;
8
+ }
9
+
10
+ export function createSettingsRegistry(pi: ExtensionAPI) {
11
+ const links = new Map<string, SettingsLink>();
12
+ const conflicts = new Set<string>();
13
+ const stop = pi.events.on("harness-settings:register", (candidate: unknown) => {
14
+ const link = candidate as Partial<SettingsLink> | null;
15
+ if (!link || !/^[a-z][a-z0-9-]*$/.test(link.id ?? "") || typeof link.label !== "string" ||
16
+ typeof link.command !== "string" || !link.command.startsWith("/") || typeof link.open !== "function") return;
17
+ if (conflicts.has(link.id!)) return;
18
+ const prior = links.get(link.id!);
19
+ if (prior && prior.open !== link.open) { links.delete(link.id!); conflicts.add(link.id!); return; }
20
+ links.set(link.id!, link as SettingsLink);
21
+ });
22
+ return {
23
+ refresh() { links.clear(); conflicts.clear(); pi.events.emit("harness-settings:request", undefined); },
24
+ list() { return [...links.values()].sort((a, b) => a.label.localeCompare(b.label)); },
25
+ dispose() { stop(); links.clear(); conflicts.clear(); },
26
+ };
27
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.6.6",
3
+ "version": "0.7.0",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,126 @@
1
1
  // Generated from packages/callback-batcher/index.ts. Do not edit directly.
2
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+
2
7
  export type CallbackSource = "subagent" | "background-task";
3
8
  export type CallbackDetailTool = "subagent_result" | "bg_task_status";
9
+ export type CallbackDeliveryMode = "hold" | "steer";
10
+
11
+ export interface CallbackSettingsContext {
12
+ sessionManager?: { getBranch?(): readonly unknown[]; getSessionId?(): string };
13
+ ui?: { notify(message: string, type: "error"): void };
14
+ }
15
+
16
+ export interface CallbackSettingsSeams {
17
+ agentDir?: () => string;
18
+ }
19
+
20
+ export interface CallbackSettings {
21
+ mode: CallbackDeliveryMode;
22
+ source: "session" | "default";
23
+ }
24
+
25
+ export const CALLBACK_SETTINGS_ENTRY = "pi-better-callback-settings";
26
+ const CALLBACK_PREFERENCES_FILE = "pi-better-callback-preferences.json";
27
+
28
+ function isDeliveryMode(mode: unknown): mode is CallbackDeliveryMode {
29
+ return mode === "hold" || mode === "steer";
30
+ }
31
+
32
+ function validCallbackPreferences(value: unknown): value is { version: 1; mode: CallbackDeliveryMode } {
33
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
34
+ const data = value as Record<string, unknown>;
35
+ return Object.keys(data).length === 2 && Object.hasOwn(data, "version") && Object.hasOwn(data, "mode")
36
+ && data.version === 1 && isDeliveryMode(data.mode);
37
+ }
38
+
39
+ function callbackPreferencesPath(seams: CallbackSettingsSeams): string {
40
+ return join((seams.agentDir ?? getAgentDir)(), "extensions", CALLBACK_PREFERENCES_FILE);
41
+ }
42
+
43
+ /** Branch history is authoritative; abandoned branches and invalid entries are ignored. */
44
+ export function getCallbackSettings(
45
+ ctx: CallbackSettingsContext,
46
+ seams: CallbackSettingsSeams = {},
47
+ ): CallbackSettings {
48
+ const branch = ctx.sessionManager?.getBranch?.() ?? [];
49
+ for (let i = branch.length - 1; i >= 0; i--) {
50
+ const entry = branch[i] as { type?: unknown; customType?: unknown; data?: unknown } | null;
51
+ if (entry?.type === "custom" && entry.customType === CALLBACK_SETTINGS_ENTRY && validCallbackPreferences(entry.data)) {
52
+ return { mode: entry.data.mode, source: "session" };
53
+ }
54
+ }
55
+ return getSessionDefault(ctx, seams);
56
+ }
57
+
58
+ function getSessionDefault(ctx: CallbackSettingsContext, seams: CallbackSettingsSeams): CallbackSettings {
59
+ const sessionId = ctx.sessionManager?.getSessionId?.();
60
+ const defaults = globalState().sessionDefaults ??= new WeakMap<object, { sessionId?: string; mode: CallbackDeliveryMode }>();
61
+ const initial = ctx.sessionManager ? defaults.get(ctx.sessionManager) : undefined;
62
+ if (initial && initial.sessionId === sessionId) return { mode: initial.mode, source: "default" };
63
+ const snapshot = (mode: CallbackDeliveryMode): CallbackSettings => {
64
+ if (ctx.sessionManager) defaults.set(ctx.sessionManager, { sessionId, mode });
65
+ return { mode, source: "default" };
66
+ };
67
+ const path = callbackPreferencesPath(seams);
68
+ let text: string;
69
+ try {
70
+ text = readFileSync(path, "utf8");
71
+ } catch (error) {
72
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return snapshot("hold");
73
+ throw new Error(`Cannot read callback default at ${path}: ${String(error)}`, { cause: error });
74
+ }
75
+ try {
76
+ const data: unknown = JSON.parse(text);
77
+ if (!validCallbackPreferences(data)) throw new Error('Expected { version: 1, mode: "hold" | "steer" }');
78
+ return snapshot(data.mode);
79
+ } catch (error) {
80
+ throw new Error(`Invalid callback default at ${path}: ${String(error)}`, { cause: error });
81
+ }
82
+ }
83
+
84
+ /** Saves only the future-session default; never changes the active branch or batcher. */
85
+ export function saveCallbackDefault(mode: CallbackDeliveryMode, seams: CallbackSettingsSeams = {}): void {
86
+ if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing saved.");
87
+ const dir = join((seams.agentDir ?? getAgentDir)(), "extensions");
88
+ const path = join(dir, CALLBACK_PREFERENCES_FILE);
89
+ const tmp = join(dir, `.${CALLBACK_PREFERENCES_FILE}.${randomUUID()}.tmp`);
90
+ let created = false;
91
+ try {
92
+ mkdirSync(dir, { recursive: true });
93
+ const fd = openSync(tmp, "wx", 0o600);
94
+ created = true;
95
+ try { writeFileSync(fd, `${JSON.stringify({ version: 1, mode })}\n`); }
96
+ finally { closeSync(fd); }
97
+ renameSync(tmp, path);
98
+ } catch (error) {
99
+ throw new Error(`Cannot save callback default at ${path}: ${String(error)}`, { cause: error });
100
+ } finally {
101
+ if (created) rmSync(tmp, { force: true });
102
+ }
103
+ }
104
+
105
+ /** Persist first so a failed append cannot become a transient delivery-mode change. */
106
+ export function changeCallbackSetting(
107
+ host: CallbackBatchHost & { appendEntry(customType: string, data: unknown): void },
108
+ ctx: CallbackSettingsContext & { isIdle(): boolean },
109
+ mode: CallbackDeliveryMode,
110
+ ): void {
111
+ if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing changed.");
112
+ const data = { version: 1, mode };
113
+ try { host.appendEntry(CALLBACK_SETTINGS_ENTRY, data); }
114
+ catch (error) {
115
+ // Pi advances its in-memory branch before disk persistence. Invalidate
116
+ // our payload so that failed entry cannot become a restored setting.
117
+ data.version = 0;
118
+ throw error;
119
+ }
120
+ const batcher = bindCallbackBatcher(host, ctx);
121
+ batcher.setWhileBusy(mode);
122
+ batcher.setAvailability(() => ctx.isIdle());
123
+ }
4
124
 
5
125
  export interface CallbackBatchHost {
6
126
  /** Older Pi shares this object; newer Pi supplies per-extension wrappers. */
@@ -70,6 +190,8 @@ export interface CallbackBatcherOptions {
70
190
  retryMs?: number;
71
191
  /** Ordinary callbacks stay here, not in Pi's follow-up queue, while unavailable. */
72
192
  isAvailable?: () => boolean;
193
+ /** Internal initial mode. Session/default settings are restored with context. */
194
+ whileBusy?: CallbackDeliveryMode;
73
195
  /** UTF-8 byte cap for one sendMessage payload. Defaults to 2 KiB. */
74
196
  maxBytes?: number;
75
197
  }
@@ -81,6 +203,14 @@ export interface CallbackBatcher {
81
203
  setHost(host: CallbackBatchHost): void;
82
204
  /** Update the session predicate and schedule an asynchronous drain when available. */
83
205
  setAvailability(isAvailable: () => boolean): void;
206
+ /** Change delivery immediately without clearing pending callbacks or handoff receipts. */
207
+ setWhileBusy(mode: CallbackDeliveryMode): void;
208
+ /** Non-idle context operations are not foreground agent runs. */
209
+ setForegroundRunning(running: boolean): void;
210
+ /** Shared IDs make duplicate notifications from both extensions idempotent. */
211
+ toolStarted(toolCallId: string): void;
212
+ /** Await the final active tool's handoff before Pi checks its steering queue. */
213
+ toolEnded(toolCallId: string): Promise<boolean>;
84
214
  deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
85
215
  cancel(): void;
86
216
  pendingCount(): number;
@@ -104,6 +234,7 @@ interface PendingEvent {
104
234
  interface SharedCallbackBatcherState {
105
235
  byHost: WeakMap<object, CallbackBatcher>;
106
236
  bySession?: WeakMap<object, CallbackBatcher>;
237
+ sessionDefaults?: WeakMap<object, { sessionId?: string; mode: CallbackDeliveryMode }>;
107
238
  }
108
239
 
109
240
  const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
@@ -131,7 +262,6 @@ const RETRIEVAL_FOOTER =
131
262
 
132
263
  export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
133
264
  export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
134
-
135
265
  export function resolveCallbackBatchWindowMs(
136
266
  value: unknown = process.env[CALLBACK_BATCH_WINDOW_ENV],
137
267
  ): number {
@@ -436,26 +566,31 @@ export function createCallbackBatcher(
436
566
  ): CallbackBatcher {
437
567
  const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
438
568
  const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
569
+ let whileBusy = options.whileBusy ?? "hold";
439
570
  const maxBytes = callbackBatchBudget(options.maxBytes);
440
571
  const pending = new Map<string, PendingEvent>();
441
572
  const inFlight = new Set<string>();
442
573
  const urgentInFlight = new Set<string>();
443
574
  const handedOff = new Map<string, number>();
575
+ const activeTools = new Set<string>();
444
576
  let sequence = 0;
445
577
  let timer: ReturnType<typeof setTimeout> | undefined;
446
578
  let flushPromise: Promise<boolean> | undefined;
447
579
  let isAvailable = options.isAvailable ?? (() => true);
448
580
  let gated = options.isAvailable !== undefined;
449
581
  let awaitingRun = false;
582
+ let cancelled = false;
583
+ let foregroundRunning = false;
450
584
 
451
- const available = (): boolean => {
585
+ const deliveryMode = (): "followUp" | "steer" | undefined => {
586
+ if (cancelled) return undefined;
452
587
  try {
453
588
  if (!isAvailable()) {
454
589
  awaitingRun = false;
455
- return false;
590
+ return whileBusy === "steer" && foregroundRunning && activeTools.size === 0 ? "steer" : undefined;
456
591
  }
457
- return !awaitingRun;
458
- } catch { return false; }
592
+ return !awaitingRun ? "followUp" : undefined;
593
+ } catch { return undefined; }
459
594
  };
460
595
 
461
596
  const cancelTimer = (): void => {
@@ -466,7 +601,7 @@ export function createCallbackBatcher(
466
601
  const schedule = (delayMs: number): void => {
467
602
  if (timer || pending.size === 0) return;
468
603
  // Some idle transitions (manual compaction) have no agent_settled event.
469
- const delay = available() ? delayMs : retryMs;
604
+ const delay = deliveryMode() ? delayMs : retryMs;
470
605
  timer = setTimeout(() => {
471
606
  timer = undefined;
472
607
  void api.flush();
@@ -485,7 +620,8 @@ export function createCallbackBatcher(
485
620
 
486
621
  const performFlush = async (): Promise<boolean> => {
487
622
  cancelTimer();
488
- if (!available()) {
623
+ const mode = deliveryMode();
624
+ if (!mode) {
489
625
  schedule(retryMs);
490
626
  return false;
491
627
  }
@@ -539,14 +675,16 @@ export function createCallbackBatcher(
539
675
  try {
540
676
  // Pi may defer the requested run while settled handlers finish, leaving
541
677
  // isIdle() true. Permit no second handoff until a run starts.
542
- awaitingRun = gated;
678
+ awaitingRun = mode === "followUp" && gated;
543
679
  await host.sendMessage(
544
680
  {
545
681
  customType: "background-completion-batch",
546
682
  content: packed.text,
547
683
  display: true,
548
684
  },
549
- { deliverAs: "followUp", triggerTurn: true },
685
+ // Newer Pi requires triggerTurn for steering too; false only appends
686
+ // a custom message at the turn boundary without continuing the agent.
687
+ { deliverAs: mode, triggerTurn: true },
550
688
  );
551
689
  } catch {
552
690
  awaitingRun = false;
@@ -632,15 +770,42 @@ export function createCallbackBatcher(
632
770
  setAvailability(check) {
633
771
  gated = true;
634
772
  isAvailable = check;
635
- available();
773
+ cancelled = false;
774
+ deliveryMode();
636
775
  cancelTimer();
637
776
  schedule(windowMs);
638
777
  },
778
+ setWhileBusy(mode) {
779
+ if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing changed.");
780
+ whileBusy = mode;
781
+ cancelTimer();
782
+ schedule(windowMs);
783
+ },
784
+ setForegroundRunning(running) {
785
+ foregroundRunning = running;
786
+ cancelTimer();
787
+ schedule(windowMs);
788
+ },
789
+ toolStarted(toolCallId) {
790
+ activeTools.add(toolCallId);
791
+ },
792
+ async toolEnded(toolCallId) {
793
+ const ended = activeTools.delete(toolCallId);
794
+ if (activeTools.size > 0 || whileBusy !== "steer" || cancelled) return false;
795
+ if (!ended) return flushPromise ?? false;
796
+ // A timer may still be completing a deferred flush. Recheck after it so
797
+ // the tool boundary cannot lose the pending batch to that older attempt.
798
+ if (flushPromise) await flushPromise;
799
+ return pending.size > 0 ? api.flush() : true;
800
+ },
639
801
  deliverUrgent,
640
802
  cancel() {
641
803
  cancelTimer();
642
804
  pending.clear();
643
805
  awaitingRun = false;
806
+ activeTools.clear();
807
+ foregroundRunning = false;
808
+ cancelled = true;
644
809
  },
645
810
  pendingCount() {
646
811
  return pending.size;
@@ -671,11 +836,39 @@ export function cancelCallbackBatch(host: CallbackBatchHost): void {
671
836
  batcher?.cancel();
672
837
  }
673
838
 
674
- /** Called on session_start, agent_start and agent_settled by both extensions. */
839
+ /** Restore current branch/default mode without resetting foreground tools or receipts. */
675
840
  export function setCallbackBatchContext(
676
841
  host: CallbackBatchHost,
677
- ctx: { isIdle(): boolean; sessionManager?: object },
842
+ ctx: CallbackSettingsContext & { isIdle(): boolean },
843
+ seams: CallbackSettingsSeams = {},
678
844
  ): void {
845
+ const batcher = bindCallbackBatcher(host, ctx);
846
+ let mode: CallbackDeliveryMode = "hold";
847
+ try {
848
+ const settings = getCallbackSettings(ctx, seams);
849
+ if (settings.source === "session") {
850
+ // Capture the initial default even when an override currently hides it,
851
+ // so navigating to an unconfigured branch cannot inherit a later save.
852
+ try { getSessionDefault(ctx, seams); }
853
+ catch {
854
+ if (ctx.sessionManager) {
855
+ const defaults = globalState().sessionDefaults ??= new WeakMap();
856
+ defaults.set(ctx.sessionManager, { sessionId: ctx.sessionManager.getSessionId?.(), mode: "hold" });
857
+ }
858
+ }
859
+ }
860
+ mode = settings.mode;
861
+ } catch (error) {
862
+ try { ctx.ui?.notify(`Callback delivery restored to hold: ${String(error)}`, "error"); }
863
+ catch { /* A failing UI must not retain the prior session's delivery mode. */ }
864
+ }
865
+ batcher.setWhileBusy(mode);
866
+ // Schedule rather than await delivery inside agent_settled: its handlers are
867
+ // still settling the previous run, and sendMessage may start a new one.
868
+ batcher.setAvailability(() => ctx.isIdle());
869
+ }
870
+
871
+ function bindCallbackBatcher(host: CallbackBatchHost, ctx: CallbackSettingsContext): CallbackBatcher {
679
872
  const state = globalState();
680
873
  const bySession = state.bySession ??= new WeakMap<object, CallbackBatcher>();
681
874
  const shared = ctx.sessionManager ? bySession.get(ctx.sessionManager) : undefined;
@@ -683,9 +876,7 @@ export function setCallbackBatchContext(
683
876
  if (ctx.sessionManager) bySession.set(ctx.sessionManager, batcher);
684
877
  state.byHost.set(host.events ?? host, batcher);
685
878
  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());
879
+ return batcher;
689
880
  }
690
881
 
691
882
  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, setCallbackBatchContext } from "./shared-callback-batcher.js";
16
+ import { cancelCallbackBatch, getCallbackBatcher, 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";
@@ -203,8 +203,18 @@ export function registerTools(pi: ExtensionAPI): void {
203
203
  }
204
204
  runTaskMaintenance({ activeOrigin: activeSession });
205
205
  });
206
- pi.on("agent_start", (_event, ctx) => { setCallbackBatchContext(pi, ctx); });
207
- pi.on("agent_settled", (_event, ctx) => { setCallbackBatchContext(pi, ctx); });
206
+ pi.on("agent_start", (_event, ctx) => {
207
+ setCallbackBatchContext(pi, ctx);
208
+ getCallbackBatcher(pi).setForegroundRunning(true);
209
+ });
210
+ pi.on("session_tree", (_event, ctx) => { setCallbackBatchContext(pi, ctx); });
211
+ pi.on("agent_end", () => { getCallbackBatcher(pi).setForegroundRunning(false); });
212
+ pi.on("agent_settled", (_event, ctx) => {
213
+ getCallbackBatcher(pi).setForegroundRunning(false);
214
+ setCallbackBatchContext(pi, ctx);
215
+ });
216
+ pi.on("tool_execution_start", (event) => { getCallbackBatcher(pi).toolStarted(event.toolCallId); });
217
+ pi.on("tool_execution_end", async (event) => { await getCallbackBatcher(pi).toolEnded(event.toolCallId); });
208
218
  pi.on("session_before_switch", () => {
209
219
  activeSession = undefined;
210
220
  cancelCallbackBatch(pi);
@@ -1,4 +1,4 @@
1
- import type { CustomEditor, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { CustomEditor, ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
3
  import { isKeyRelease, matchesKey } from "@earendil-works/pi-tui";
4
4
  import { readFileSync } from "node:fs";
@@ -842,6 +842,36 @@ export default function (pi: ExtensionAPI): void {
842
842
  if (latestSnapshot) collectIfPossible();
843
843
  });
844
844
 
845
+ const changePreference = async (ctx: ExtensionCommandContext, key: keyof GoalPreferences, enabled: boolean): Promise<void> => {
846
+ const previous = preferences;
847
+ preferences = writeGoalPreference(key, enabled);
848
+ if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
849
+ syncResumeTool(getGoal(ctx));
850
+ applyStatus(ctx);
851
+ if (!previous.autoContinue && preferences.autoContinue) {
852
+ const snapshot = await publishSnapshot(ctx);
853
+ const goal = getGoal(ctx);
854
+ if (snapshot && isPokeable(goal) && !isForegroundBusy(ctx) && !snapshot.backgroundRunning) {
855
+ scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
856
+ }
857
+ }
858
+ };
859
+ const openSettings = async (ctx: ExtensionCommandContext): Promise<void> => {
860
+ currentCtx = ctx;
861
+ if (ctx.hasUI && ctx.mode !== "rpc" && typeof ctx.ui.custom === "function") {
862
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => createGoalSettingsPage(theme, {
863
+ get: () => ({ ...preferences }),
864
+ change: (key, enabled) => changePreference(ctx, key, enabled),
865
+ }, () => tui.requestRender(), () => done()));
866
+ } else {
867
+ ctx.ui.notify(`${formatPreferences(preferences)}\nPreferences: ${goalPreferencesPath()}`, "info");
868
+ }
869
+ };
870
+ const contribution = { id: "goal", label: "Goal", command: "/goal settings", open: openSettings };
871
+ const registerSettings = () => pi.events.emit("harness-settings:register", contribution);
872
+ const unsubscribeSettingsRequest = pi.events.on("harness-settings:request", registerSettings);
873
+ registerSettings();
874
+
845
875
  pi.registerCommand("goal", {
846
876
  description: "Create, inspect, pause, resume, clear, or complete the active goal; configure persistent goal settings",
847
877
  getArgumentCompletions: goalArgumentCompletions,
@@ -851,25 +881,8 @@ export default function (pi: ExtensionAPI): void {
851
881
  const current = getGoal(ctx);
852
882
 
853
883
  if (trimmed === "settings" || trimmed.startsWith("settings ")) {
854
- const changePreference = async (key: keyof GoalPreferences, enabled: boolean): Promise<void> => {
855
- const previous = preferences;
856
- preferences = writeGoalPreference(key, enabled);
857
- if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
858
- syncResumeTool(getGoal(ctx));
859
- applyStatus(ctx);
860
- if (!previous.autoContinue && preferences.autoContinue) {
861
- const snapshot = await publishSnapshot(ctx);
862
- const goal = getGoal(ctx);
863
- if (snapshot && isPokeable(goal) && !isForegroundBusy(ctx) && !snapshot.backgroundRunning) {
864
- scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
865
- }
866
- }
867
- };
868
- if (trimmed === "settings" && ctx.hasUI && ctx.mode !== "rpc" && typeof ctx.ui.custom === "function") {
869
- await ctx.ui.custom<void>((tui, theme, _kb, done) => createGoalSettingsPage(theme, {
870
- get: () => ({ ...preferences }),
871
- change: changePreference,
872
- }, () => tui.requestRender(), () => done()));
884
+ if (trimmed === "settings") {
885
+ await openSettings(ctx);
873
886
  return;
874
887
  }
875
888
  const [, setting, mode, ...extra] = trimmed.split(/\s+/);
@@ -882,7 +895,7 @@ export default function (pi: ExtensionAPI): void {
882
895
  try {
883
896
  const key = setting === "auto-continue" ? "autoContinue"
884
897
  : setting === "conversational-resume" ? "conversationalResume" : "pauseOnEscape";
885
- await changePreference(key, mode === "on");
898
+ await changePreference(ctx, key, mode === "on");
886
899
  } catch (error) {
887
900
  notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
888
901
  return;
@@ -1425,6 +1438,8 @@ export default function (pi: ExtensionAPI): void {
1425
1438
  });
1426
1439
 
1427
1440
  pi.on("session_shutdown", async (_event, ctx) => {
1441
+ if (typeof unsubscribeSettingsRequest === "function") unsubscribeSettingsRequest();
1442
+
1428
1443
  executionGeneration += 1;
1429
1444
  sessionGeneration += 1;
1430
1445
  escapeAbortSuppression = undefined;