pi-better-subagents 0.14.1 → 0.15.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/docs/usage.md CHANGED
@@ -67,7 +67,7 @@ is present; without this extension it uses adaptive guidance.
67
67
  `subagent_spawn` starts a detached `pi -p` child and returns immediately,
68
68
  leaving the session free for the human. When the child finishes, it sends a
69
69
  lightweight trigger; the foreground calls `subagent_result` and presents the
70
- result (as a `followUp`, never cutting into work in progress). The foreground
70
+ result (as a `followUp` by default; optional busy steering is described below). The foreground
71
71
  is nudged once, at completion — never on a wait/poll loop.
72
72
  - **Subagents are autonomous; communication is one-way (parent → child).** The
73
73
  parent front-loads everything the child needs into the spawn; the child runs to
@@ -247,7 +247,7 @@ given.
247
247
  its output is durable in a log file.
248
248
  - **Completions trigger one batched turn that fetches durable results.** Ordinary
249
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
+ busy by default. Once Pi is idle, they accumulate for 100 ms and share one
251
251
  `pi.sendMessage(..., { deliverAs: "followUp", triggerTurn: true })` notification
252
252
  with background-task completions from the same Pi host. Each row contains only
253
253
  source, id, label, terminal status, and the `subagent_result` lookup. Full
@@ -260,6 +260,23 @@ given.
260
260
  pending records retryable, and `/reload` recovers records explicitly marked
261
261
  pending. `callback:false` sends no model message; read the durable result later
262
262
  with `subagent_result`.
263
+ - **Optional busy steering.** In `/harness-settings`, set **Completions while
264
+ busy** to **Steer active run** to receive ordinary completions during
265
+ foreground work. Changes autosave to the current session branch and apply
266
+ immediately; **Ctrl+S** saves the choice as the user default for future
267
+ sessions, including standalone callback packages. **Wait until idle** is the
268
+ default. The former `PI_BETTER_CALLBACK_WHILE_BUSY` environment variable is
269
+ no longer supported. Completions spanning multiple accumulation windows during
270
+ one tool call stay together until the final active tool ends, then share one
271
+ compact `steer` notification without triggering a new run. Parallel and nested
272
+ tools are tracked together by both extensions. While busy with no active tool,
273
+ the normal accumulation window applies. Manual compaction and branch
274
+ summarization hold callbacks until an agent run starts or Pi becomes idle.
275
+ Idle delivery remains
276
+ `{ deliverAs: "followUp", triggerTurn: true }`. The same byte budget, queued overflow,
277
+ dedupe, delivery receipts, retry, ownership checks, and `callback:false` rules
278
+ apply to both modes; a completion handed off by steer is not sent again at
279
+ idle. Urgent health/failure alerts are unchanged.
263
280
  - **The prompt guidelines forbid polling.** The foreground agent is told, in the
264
281
  tool guidelines, that spawning is done and it must not loop on `output`/`result`
265
282
  or sleep to wait.
package/index.ts CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { execSync } from "node:child_process";
15
15
  import { writeFileSync, mkdirSync, renameSync, statSync } from "node:fs";
16
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
17
  import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
18
18
  import * as PiTui from "@earendil-works/pi-tui";
19
19
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
@@ -2283,6 +2283,27 @@ export default function (pi: ExtensionAPI) {
2283
2283
  },
2284
2284
  enrich: createLaunchEnricher(),
2285
2285
  });
2286
+ const openSettings = async (ctx: ExtensionCommandContext): Promise<void> => {
2287
+ if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.custom === "function") {
2288
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => createSubagentSettingsPage(theme, {
2289
+ get: () => ({
2290
+ mode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap(),
2291
+ modeSource: delegationOverride ? "session" : "config",
2292
+ capSource: capOverride !== undefined ? "session" : "config",
2293
+ defaultMode: normalizeDelegationMode(loadConfig().delegationMode),
2294
+ defaultCap: normalizeConcurrencyCap(loadConfig().maxConcurrent),
2295
+ }),
2296
+ changeMode, changeCap, save: saveSettings, reset: resetSettings,
2297
+ }, () => { refreshBackgroundWorkNavigator(ctx); tui.requestRender(); }, () => done()));
2298
+ } else {
2299
+ ctx.ui.notify(`Delegation mode: ${activeDelegationMode()}. Concurrent subagents: ${activeConcurrencyCap()}. Use /subagents mode <mode> or /subagents cap <number>; /subagents save persists both.`, "info");
2300
+ }
2301
+ };
2302
+ const contribution = { id: "subagents", label: "Subagents", command: "/subagents settings", open: openSettings };
2303
+ const registerSettings = () => pi.events?.emit("harness-settings:register", contribution);
2304
+ const unsubscribeSettingsRequest = pi.events?.on?.("harness-settings:request", registerSettings);
2305
+ registerSettings();
2306
+
2286
2307
  if (typeof pi.registerCommand === "function") {
2287
2308
  agentOperations.registerCommands(pi);
2288
2309
  pi.registerCommand("subagents", {
@@ -2291,20 +2312,7 @@ export default function (pi: ExtensionAPI) {
2291
2312
  async handler(args, ctx) {
2292
2313
  const tokens = args.trim().split(/\s+/).filter(Boolean);
2293
2314
  if (tokens.length === 0 || (tokens.length === 1 && tokens[0] === "settings")) {
2294
- if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.custom === "function") {
2295
- await ctx.ui.custom<void>((tui, theme, _kb, done) => createSubagentSettingsPage(theme, {
2296
- get: () => ({
2297
- mode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap(),
2298
- modeSource: delegationOverride ? "session" : "config",
2299
- capSource: capOverride !== undefined ? "session" : "config",
2300
- defaultMode: normalizeDelegationMode(loadConfig().delegationMode),
2301
- defaultCap: normalizeConcurrencyCap(loadConfig().maxConcurrent),
2302
- }),
2303
- changeMode, changeCap, save: saveSettings, reset: resetSettings,
2304
- }, () => { refreshBackgroundWorkNavigator(ctx); tui.requestRender(); }, () => done()));
2305
- } else {
2306
- ctx.ui.notify(`Delegation mode: ${activeDelegationMode()}. Concurrent subagents: ${activeConcurrencyCap()}. Use /subagents mode <mode> or /subagents cap <number>; /subagents save persists both.`, "info");
2307
- }
2315
+ await openSettings(ctx);
2308
2316
  return;
2309
2317
  }
2310
2318
  const requested = tokens[1];
@@ -2346,18 +2354,23 @@ export default function (pi: ExtensionAPI) {
2346
2354
  // ---- live-status lifecycle -----------------------------------------
2347
2355
  pi.on("agent_start", async (_event, ctx) => {
2348
2356
  setCallbackBatchContext(pi, ctx);
2357
+ getCallbackBatcher(pi).setForegroundRunning(true);
2349
2358
  uiCtx = ctx;
2350
2359
  mainAgentStartedAt = Date.now();
2351
2360
  mainAgentTools.clear();
2352
2361
  refreshBackgroundWorkNavigator(ctx);
2353
2362
  });
2354
2363
 
2364
+ pi.on("agent_end", () => { getCallbackBatcher(pi).setForegroundRunning(false); });
2365
+
2355
2366
  pi.on("tool_execution_start", async (event, ctx) => {
2367
+ getCallbackBatcher(pi).toolStarted(event.toolCallId);
2356
2368
  mainAgentTools.set(event.toolCallId, event.toolName);
2357
2369
  refreshBackgroundWorkNavigator(ctx);
2358
2370
  });
2359
2371
 
2360
2372
  pi.on("tool_execution_end", async (event, ctx) => {
2373
+ await getCallbackBatcher(pi).toolEnded(event.toolCallId);
2361
2374
  mainAgentTools.delete(event.toolCallId);
2362
2375
  refreshBackgroundWorkNavigator(ctx);
2363
2376
  });
@@ -2378,6 +2391,7 @@ export default function (pi: ExtensionAPI) {
2378
2391
  });
2379
2392
 
2380
2393
  pi.on("agent_settled", async (_event, ctx) => {
2394
+ getCallbackBatcher(pi).setForegroundRunning(false);
2381
2395
  setCallbackBatchContext(pi, ctx);
2382
2396
  mainAgentStartedAt = undefined;
2383
2397
  mainAgentTools.clear();
@@ -2385,6 +2399,7 @@ export default function (pi: ExtensionAPI) {
2385
2399
  });
2386
2400
 
2387
2401
  pi.on("session_tree", async (_event, ctx) => {
2402
+ setCallbackBatchContext(pi, ctx);
2388
2403
  agentSessionSettings.restore(ctx);
2389
2404
  try { noteCatalogHost(catalogHostFrom(ctx)); } catch { /* catalog inspection stays undecided */ }
2390
2405
  restoreDelegationMode(ctx);
@@ -2446,6 +2461,7 @@ export default function (pi: ExtensionAPI) {
2446
2461
 
2447
2462
  // Tear down the timer and clear the widget when the session ends.
2448
2463
  pi.on("session_shutdown", async (_event, ctx) => {
2464
+ if (typeof unsubscribeSettingsRequest === "function") unsubscribeSettingsRequest();
2449
2465
  // The foreground Pi session owns this work. Stop current-session
2450
2466
  // running/orphaned children before dropping the session origin, so they
2451
2467
  // cannot become live process groups with no coordinator.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
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 {
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * - Guarded tools are harness adapters that go through the task's guarded file
6
6
  * operations, so the file rules govern them. Today: apply_patch.
7
- * - Trusted tools are third-party tools admitted by name AND owning package.
7
+ * - Trusted tools are extension tools admitted by name AND owning package.
8
8
  * They run in the child Pi process, outside the file rules; a human ticks each
9
9
  * one in /sandbox. Network tools are refused while Network is Off.
10
10
  */
@@ -36,15 +36,17 @@ export function defaultSubagentTools(): SubagentToolSettings {
36
36
 
37
37
  /** Tools that reach the network; refused while the profile's Network access is Off. */
38
38
  export function isNetworkTool(name: string): boolean {
39
- return ["web_fetch", "web_search", "firecrawl_scrape", "firecrawl_extract", "mcp", "mcpScript", "remote_bash"].includes(name) ||
39
+ return ["web_fetch", "web_search", "firecrawl_scrape", "firecrawl_extract", "mcp", "mcpScript", ...SSH_TOOL_NAMES].includes(name) ||
40
40
  name.startsWith("mcp__");
41
41
  }
42
42
 
43
43
  /** Names never offered as trusted: builtins and the guarded adapters. */
44
44
  export const RESERVED_TOOL_NAMES = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls", "powershell", "apply_patch", "process_list"]);
45
45
 
46
- /** Packages that are this harness: their tools are never offered as trusted tools. */
46
+ /** Harness tools stay excluded except for the explicitly supported SSH tools. */
47
47
  const HARNESS_PACKAGES = /(^|[/:])pi-better-[a-z-]+$/;
48
+ const SSH_PACKAGE = /(^|[/:])pi-better-ssh$/;
49
+ const SSH_TOOL_NAMES = ["remote_bash", "ssh_profile", "ssh_mux"];
48
50
 
49
51
  /** Strict decoding: a malformed tool list must not silently admit anything. */
50
52
  export function parseSubagentTools(value: unknown): SubagentToolSettings {
@@ -95,13 +97,16 @@ export function toolPackage(tool: ToolSource): DiscoveredTool | undefined {
95
97
  return { name: tool.name, package: pkg, root: base };
96
98
  }
97
99
 
98
- /** Candidate trusted tools in a running Pi, excluding core builtins, guarded names and this harness. */
100
+ /** Candidate trusted tools, excluding core builtins, guarded names and non-SSH harness tools. */
99
101
  export function discoverTrustedTools(tools: readonly ToolSource[]): DiscoveredTool[] {
100
102
  const found: DiscoveredTool[] = [];
101
103
  for (const tool of tools) {
102
104
  if (RESERVED_TOOL_NAMES.includes(tool.name)) continue;
103
105
  const owner = toolPackage(tool);
104
- if (!owner || [owner.package, owner.root].some((id) => HARNESS_PACKAGES.test(id.replace(/\/+$/, "")))) continue;
106
+ if (!owner) continue;
107
+ const harnessOwners = [owner.package, owner.root].map((id) => id.replace(/\/+$/, ""))
108
+ .filter((id) => HARNESS_PACKAGES.test(id));
109
+ if (harnessOwners.length && !(SSH_TOOL_NAMES.includes(tool.name) && harnessOwners.every((id) => SSH_PACKAGE.test(id)))) continue;
105
110
  if (!found.some((t) => t.name === owner.name && t.package === owner.package)) {
106
111
  found.push(tool.sourceInfo?.source === "builtin" ? { ...owner, source: "builtin" } : owner);
107
112
  }