pi-better-background-tasks 0.6.6 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.6.6",
3
+ "version": "0.7.1",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,5 +58,8 @@
58
58
  "typebox": "^1.1.38",
59
59
  "typescript": "^5.7.0",
60
60
  "vitest": "^3.0.0"
61
+ },
62
+ "dependencies": {
63
+ "proper-lockfile": "^4.1.2"
61
64
  }
62
65
  }
@@ -1,6 +1,119 @@
1
1
  // Generated from packages/callback-batcher/index.ts. Do not edit directly.
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ harnessSettingsPath,
6
+ readHarnessSetting,
7
+ updateHarnessSetting,
8
+ type HarnessSettingsSeams,
9
+ } from "./shared-harness-settings.ts";
10
+
2
11
  export type CallbackSource = "subagent" | "background-task";
3
12
  export type CallbackDetailTool = "subagent_result" | "bg_task_status";
13
+ export type CallbackDeliveryMode = "hold" | "steer";
14
+
15
+ export interface CallbackSettingsContext {
16
+ sessionManager?: { getBranch?(): readonly unknown[]; getSessionId?(): string };
17
+ ui?: { notify(message: string, type: "error"): void };
18
+ }
19
+
20
+ export type CallbackSettingsSeams = HarnessSettingsSeams;
21
+
22
+ export interface CallbackSettings {
23
+ mode: CallbackDeliveryMode;
24
+ source: "session" | "default";
25
+ }
26
+
27
+ export const CALLBACK_SETTINGS_ENTRY = "pi-better-callback-settings";
28
+ const CALLBACK_PREFERENCES_FILE = "pi-better-callback-preferences.json";
29
+
30
+ function isDeliveryMode(mode: unknown): mode is CallbackDeliveryMode {
31
+ return mode === "hold" || mode === "steer";
32
+ }
33
+
34
+ function validCallbackPreferences(value: unknown): value is { version: 1; mode: CallbackDeliveryMode } {
35
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
36
+ const data = value as Record<string, unknown>;
37
+ return Object.keys(data).length === 2 && Object.hasOwn(data, "version") && Object.hasOwn(data, "mode")
38
+ && data.version === 1 && isDeliveryMode(data.mode);
39
+ }
40
+
41
+ function parseCallbackPreferences(value: unknown): { version: 1; mode: CallbackDeliveryMode } {
42
+ if (!validCallbackPreferences(value)) throw new Error('Expected { version: 1, mode: "hold" | "steer" }');
43
+ return value;
44
+ }
45
+
46
+ function legacyCallbackPreferencesPath(seams: CallbackSettingsSeams): string {
47
+ return join((seams.agentDir ?? getAgentDir)(), "extensions", CALLBACK_PREFERENCES_FILE);
48
+ }
49
+
50
+ /** Branch history is authoritative; abandoned branches and invalid entries are ignored. */
51
+ export function getCallbackSettings(
52
+ ctx: CallbackSettingsContext,
53
+ seams: CallbackSettingsSeams = {},
54
+ ): CallbackSettings {
55
+ const branch = ctx.sessionManager?.getBranch?.() ?? [];
56
+ for (let i = branch.length - 1; i >= 0; i--) {
57
+ const entry = branch[i] as { type?: unknown; customType?: unknown; data?: unknown } | null;
58
+ if (entry?.type === "custom" && entry.customType === CALLBACK_SETTINGS_ENTRY && validCallbackPreferences(entry.data)) {
59
+ return { mode: entry.data.mode, source: "session" };
60
+ }
61
+ }
62
+ return getSessionDefault(ctx, seams);
63
+ }
64
+
65
+ function getSessionDefault(ctx: CallbackSettingsContext, seams: CallbackSettingsSeams): CallbackSettings {
66
+ const sessionId = ctx.sessionManager?.getSessionId?.();
67
+ const defaults = globalState().sessionDefaults ??= new WeakMap<object, { sessionId?: string; mode: CallbackDeliveryMode }>();
68
+ const initial = ctx.sessionManager ? defaults.get(ctx.sessionManager) : undefined;
69
+ if (initial && initial.sessionId === sessionId) return { mode: initial.mode, source: "default" };
70
+ const snapshot = (mode: CallbackDeliveryMode): CallbackSettings => {
71
+ if (ctx.sessionManager) defaults.set(ctx.sessionManager, { sessionId, mode });
72
+ return { mode, source: "default" };
73
+ };
74
+ const path = harnessSettingsPath(seams);
75
+ try {
76
+ const data = readHarnessSetting<{ version: 1; mode: CallbackDeliveryMode }>("callbacks", seams, {
77
+ path: legacyCallbackPreferencesPath(seams),
78
+ parse: parseCallbackPreferences,
79
+ });
80
+ return snapshot(data === undefined ? "hold" : parseCallbackPreferences(data).mode);
81
+ } catch (error) {
82
+ const prefix = (error as NodeJS.ErrnoException).code ? "Cannot read" : "Invalid";
83
+ throw new Error(`${prefix} callback default at ${path}: ${String(error)}`, { cause: error });
84
+ }
85
+ }
86
+
87
+ /** Saves only the future-session default; never changes the active branch or batcher. */
88
+ export function saveCallbackDefault(mode: CallbackDeliveryMode, seams: CallbackSettingsSeams = {}): void {
89
+ if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing saved.");
90
+ const path = harnessSettingsPath(seams);
91
+ try {
92
+ updateHarnessSetting("callbacks", () => ({ version: 1, mode }), seams);
93
+ } catch (error) {
94
+ throw new Error(`Cannot save callback default at ${path}: ${String(error)}`, { cause: error });
95
+ }
96
+ }
97
+
98
+ /** Persist first so a failed append cannot become a transient delivery-mode change. */
99
+ export function changeCallbackSetting(
100
+ host: CallbackBatchHost & { appendEntry(customType: string, data: unknown): void },
101
+ ctx: CallbackSettingsContext & { isIdle(): boolean },
102
+ mode: CallbackDeliveryMode,
103
+ ): void {
104
+ if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing changed.");
105
+ const data = { version: 1, mode };
106
+ try { host.appendEntry(CALLBACK_SETTINGS_ENTRY, data); }
107
+ catch (error) {
108
+ // Pi advances its in-memory branch before disk persistence. Invalidate
109
+ // our payload so that failed entry cannot become a restored setting.
110
+ data.version = 0;
111
+ throw error;
112
+ }
113
+ const batcher = bindCallbackBatcher(host, ctx);
114
+ batcher.setWhileBusy(mode);
115
+ batcher.setAvailability(() => ctx.isIdle());
116
+ }
4
117
 
5
118
  export interface CallbackBatchHost {
6
119
  /** Older Pi shares this object; newer Pi supplies per-extension wrappers. */
@@ -70,6 +183,8 @@ export interface CallbackBatcherOptions {
70
183
  retryMs?: number;
71
184
  /** Ordinary callbacks stay here, not in Pi's follow-up queue, while unavailable. */
72
185
  isAvailable?: () => boolean;
186
+ /** Internal initial mode. Session/default settings are restored with context. */
187
+ whileBusy?: CallbackDeliveryMode;
73
188
  /** UTF-8 byte cap for one sendMessage payload. Defaults to 2 KiB. */
74
189
  maxBytes?: number;
75
190
  }
@@ -81,6 +196,14 @@ export interface CallbackBatcher {
81
196
  setHost(host: CallbackBatchHost): void;
82
197
  /** Update the session predicate and schedule an asynchronous drain when available. */
83
198
  setAvailability(isAvailable: () => boolean): void;
199
+ /** Change delivery immediately without clearing pending callbacks or handoff receipts. */
200
+ setWhileBusy(mode: CallbackDeliveryMode): void;
201
+ /** Non-idle context operations are not foreground agent runs. */
202
+ setForegroundRunning(running: boolean): void;
203
+ /** Shared IDs make duplicate notifications from both extensions idempotent. */
204
+ toolStarted(toolCallId: string): void;
205
+ /** Await the final active tool's handoff before Pi checks its steering queue. */
206
+ toolEnded(toolCallId: string): Promise<boolean>;
84
207
  deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
85
208
  cancel(): void;
86
209
  pendingCount(): number;
@@ -104,6 +227,7 @@ interface PendingEvent {
104
227
  interface SharedCallbackBatcherState {
105
228
  byHost: WeakMap<object, CallbackBatcher>;
106
229
  bySession?: WeakMap<object, CallbackBatcher>;
230
+ sessionDefaults?: WeakMap<object, { sessionId?: string; mode: CallbackDeliveryMode }>;
107
231
  }
108
232
 
109
233
  const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
@@ -131,7 +255,6 @@ const RETRIEVAL_FOOTER =
131
255
 
132
256
  export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
133
257
  export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
134
-
135
258
  export function resolveCallbackBatchWindowMs(
136
259
  value: unknown = process.env[CALLBACK_BATCH_WINDOW_ENV],
137
260
  ): number {
@@ -436,26 +559,31 @@ export function createCallbackBatcher(
436
559
  ): CallbackBatcher {
437
560
  const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
438
561
  const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
562
+ let whileBusy = options.whileBusy ?? "hold";
439
563
  const maxBytes = callbackBatchBudget(options.maxBytes);
440
564
  const pending = new Map<string, PendingEvent>();
441
565
  const inFlight = new Set<string>();
442
566
  const urgentInFlight = new Set<string>();
443
567
  const handedOff = new Map<string, number>();
568
+ const activeTools = new Set<string>();
444
569
  let sequence = 0;
445
570
  let timer: ReturnType<typeof setTimeout> | undefined;
446
571
  let flushPromise: Promise<boolean> | undefined;
447
572
  let isAvailable = options.isAvailable ?? (() => true);
448
573
  let gated = options.isAvailable !== undefined;
449
574
  let awaitingRun = false;
575
+ let cancelled = false;
576
+ let foregroundRunning = false;
450
577
 
451
- const available = (): boolean => {
578
+ const deliveryMode = (): "followUp" | "steer" | undefined => {
579
+ if (cancelled) return undefined;
452
580
  try {
453
581
  if (!isAvailable()) {
454
582
  awaitingRun = false;
455
- return false;
583
+ return whileBusy === "steer" && foregroundRunning && activeTools.size === 0 ? "steer" : undefined;
456
584
  }
457
- return !awaitingRun;
458
- } catch { return false; }
585
+ return !awaitingRun ? "followUp" : undefined;
586
+ } catch { return undefined; }
459
587
  };
460
588
 
461
589
  const cancelTimer = (): void => {
@@ -466,7 +594,7 @@ export function createCallbackBatcher(
466
594
  const schedule = (delayMs: number): void => {
467
595
  if (timer || pending.size === 0) return;
468
596
  // Some idle transitions (manual compaction) have no agent_settled event.
469
- const delay = available() ? delayMs : retryMs;
597
+ const delay = deliveryMode() ? delayMs : retryMs;
470
598
  timer = setTimeout(() => {
471
599
  timer = undefined;
472
600
  void api.flush();
@@ -485,7 +613,8 @@ export function createCallbackBatcher(
485
613
 
486
614
  const performFlush = async (): Promise<boolean> => {
487
615
  cancelTimer();
488
- if (!available()) {
616
+ const mode = deliveryMode();
617
+ if (!mode) {
489
618
  schedule(retryMs);
490
619
  return false;
491
620
  }
@@ -539,14 +668,16 @@ export function createCallbackBatcher(
539
668
  try {
540
669
  // Pi may defer the requested run while settled handlers finish, leaving
541
670
  // isIdle() true. Permit no second handoff until a run starts.
542
- awaitingRun = gated;
671
+ awaitingRun = mode === "followUp" && gated;
543
672
  await host.sendMessage(
544
673
  {
545
674
  customType: "background-completion-batch",
546
675
  content: packed.text,
547
676
  display: true,
548
677
  },
549
- { deliverAs: "followUp", triggerTurn: true },
678
+ // Newer Pi requires triggerTurn for steering too; false only appends
679
+ // a custom message at the turn boundary without continuing the agent.
680
+ { deliverAs: mode, triggerTurn: true },
550
681
  );
551
682
  } catch {
552
683
  awaitingRun = false;
@@ -632,15 +763,42 @@ export function createCallbackBatcher(
632
763
  setAvailability(check) {
633
764
  gated = true;
634
765
  isAvailable = check;
635
- available();
766
+ cancelled = false;
767
+ deliveryMode();
636
768
  cancelTimer();
637
769
  schedule(windowMs);
638
770
  },
771
+ setWhileBusy(mode) {
772
+ if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing changed.");
773
+ whileBusy = mode;
774
+ cancelTimer();
775
+ schedule(windowMs);
776
+ },
777
+ setForegroundRunning(running) {
778
+ foregroundRunning = running;
779
+ cancelTimer();
780
+ schedule(windowMs);
781
+ },
782
+ toolStarted(toolCallId) {
783
+ activeTools.add(toolCallId);
784
+ },
785
+ async toolEnded(toolCallId) {
786
+ const ended = activeTools.delete(toolCallId);
787
+ if (activeTools.size > 0 || whileBusy !== "steer" || cancelled) return false;
788
+ if (!ended) return flushPromise ?? false;
789
+ // A timer may still be completing a deferred flush. Recheck after it so
790
+ // the tool boundary cannot lose the pending batch to that older attempt.
791
+ if (flushPromise) await flushPromise;
792
+ return pending.size > 0 ? api.flush() : true;
793
+ },
639
794
  deliverUrgent,
640
795
  cancel() {
641
796
  cancelTimer();
642
797
  pending.clear();
643
798
  awaitingRun = false;
799
+ activeTools.clear();
800
+ foregroundRunning = false;
801
+ cancelled = true;
644
802
  },
645
803
  pendingCount() {
646
804
  return pending.size;
@@ -671,11 +829,39 @@ export function cancelCallbackBatch(host: CallbackBatchHost): void {
671
829
  batcher?.cancel();
672
830
  }
673
831
 
674
- /** Called on session_start, agent_start and agent_settled by both extensions. */
832
+ /** Restore current branch/default mode without resetting foreground tools or receipts. */
675
833
  export function setCallbackBatchContext(
676
834
  host: CallbackBatchHost,
677
- ctx: { isIdle(): boolean; sessionManager?: object },
835
+ ctx: CallbackSettingsContext & { isIdle(): boolean },
836
+ seams: CallbackSettingsSeams = {},
678
837
  ): void {
838
+ const batcher = bindCallbackBatcher(host, ctx);
839
+ let mode: CallbackDeliveryMode = "hold";
840
+ try {
841
+ const settings = getCallbackSettings(ctx, seams);
842
+ if (settings.source === "session") {
843
+ // Capture the initial default even when an override currently hides it,
844
+ // so navigating to an unconfigured branch cannot inherit a later save.
845
+ try { getSessionDefault(ctx, seams); }
846
+ catch {
847
+ if (ctx.sessionManager) {
848
+ const defaults = globalState().sessionDefaults ??= new WeakMap();
849
+ defaults.set(ctx.sessionManager, { sessionId: ctx.sessionManager.getSessionId?.(), mode: "hold" });
850
+ }
851
+ }
852
+ }
853
+ mode = settings.mode;
854
+ } catch (error) {
855
+ try { ctx.ui?.notify(`Callback delivery restored to hold: ${String(error)}`, "error"); }
856
+ catch { /* A failing UI must not retain the prior session's delivery mode. */ }
857
+ }
858
+ batcher.setWhileBusy(mode);
859
+ // Schedule rather than await delivery inside agent_settled: its handlers are
860
+ // still settling the previous run, and sendMessage may start a new one.
861
+ batcher.setAvailability(() => ctx.isIdle());
862
+ }
863
+
864
+ function bindCallbackBatcher(host: CallbackBatchHost, ctx: CallbackSettingsContext): CallbackBatcher {
679
865
  const state = globalState();
680
866
  const bySession = state.bySession ??= new WeakMap<object, CallbackBatcher>();
681
867
  const shared = ctx.sessionManager ? bySession.get(ctx.sessionManager) : undefined;
@@ -683,9 +869,7 @@ export function setCallbackBatchContext(
683
869
  if (ctx.sessionManager) bySession.set(ctx.sessionManager, batcher);
684
870
  state.byHost.set(host.events ?? host, batcher);
685
871
  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());
872
+ return batcher;
689
873
  }
690
874
 
691
875
  function globalState(): SharedCallbackBatcherState {
@@ -0,0 +1,114 @@
1
+ // Generated from packages/harness-settings/index.ts. Do not edit directly.
2
+ import { existsSync, lstatSync, realpathSync, readFileSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { createRequire } from "node:module";
5
+ import { join } from "node:path";
6
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
7
+
8
+ // Match Pi's settings lock without depending on a private SDK import.
9
+ const lockfile = createRequire(import.meta.url)("proper-lockfile") as {
10
+ lockSync(path: string, options: { realpath: boolean }): () => void;
11
+ };
12
+ const NAMESPACE = "piBetterHarness";
13
+ export interface HarnessSettingsSeams { agentDir?: () => string }
14
+
15
+ export function harnessSettingsPath(seams: HarnessSettingsSeams = {}): string {
16
+ return join((seams.agentDir ?? getAgentDir)(), "settings.json");
17
+ }
18
+
19
+ function object(value: unknown, label: string): Record<string, unknown> {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be a JSON object.`);
21
+ return value as Record<string, unknown>;
22
+ }
23
+
24
+ function readSettings(path: string): Record<string, unknown> {
25
+ try { return object(JSON.parse(readFileSync(path, "utf8")), `Settings at ${path}`); }
26
+ catch (error) {
27
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ function section(settings: Record<string, unknown>): Record<string, unknown> {
33
+ return Object.hasOwn(settings, NAMESPACE) ? object(settings[NAMESPACE], NAMESPACE) : {};
34
+ }
35
+
36
+ function acquire(path: string): () => void {
37
+ for (let attempt = 0; attempt < 10; attempt++) {
38
+ try { return lockfile.lockSync(path, { realpath: false }); }
39
+ catch (error) {
40
+ if ((error as NodeJS.ErrnoException).code !== "ELOCKED" || attempt === 9) throw error;
41
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
42
+ }
43
+ }
44
+ throw new Error(`Cannot lock settings at ${path}`);
45
+ }
46
+
47
+ function lockedRead(path: string): Record<string, unknown> {
48
+ if (!existsSync(path)) return {};
49
+ const release = acquire(path);
50
+ try { return readSettings(path); } finally { release(); }
51
+ }
52
+
53
+ function writeDestination(path: string): string {
54
+ let stat;
55
+ try { stat = lstatSync(path); }
56
+ catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return path;
58
+ throw error;
59
+ }
60
+ // Keep managed dotfiles links intact. A dangling link fails rather than being replaced.
61
+ return stat.isSymbolicLink() ? realpathSync(path) : path;
62
+ }
63
+
64
+ function mutate<T>(seams: HarnessSettingsSeams, change: (values: Record<string, unknown>) => T): T {
65
+ const path = harnessSettingsPath(seams);
66
+ mkdirSync((seams.agentDir ?? getAgentDir)(), { recursive: true });
67
+ const release = acquire(path);
68
+ let pending: string | undefined;
69
+ try {
70
+ // Read inside the lock so another session's settings are never replaced by a stale snapshot.
71
+ const settings = readSettings(path);
72
+ const values = section(settings);
73
+ const result = change(values);
74
+ settings[NAMESPACE] = values;
75
+ const destination = writeDestination(path);
76
+ pending = `${destination}.${randomUUID()}.tmp`;
77
+ writeFileSync(pending, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600, flag: "wx" });
78
+ renameSync(pending, destination);
79
+ return result;
80
+ } finally {
81
+ try { if (pending) rmSync(pending, { force: true }); } finally { release(); }
82
+ }
83
+ }
84
+
85
+ export function readHarnessSetting<T = unknown>(
86
+ key: string,
87
+ seams: HarnessSettingsSeams = {},
88
+ legacy?: { path: string; parse: (value: unknown) => T },
89
+ ): T | undefined {
90
+ const values = section(lockedRead(harnessSettingsPath(seams)));
91
+ if (Object.hasOwn(values, key)) return values[key] as T;
92
+ if (!legacy) return undefined;
93
+ let raw: string;
94
+ try { raw = readFileSync(legacy.path, "utf8"); }
95
+ catch (error) {
96
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
97
+ throw error;
98
+ }
99
+ const migrated = legacy.parse(JSON.parse(raw));
100
+ return mutate(seams, current => {
101
+ if (!Object.hasOwn(current, key)) current[key] = migrated;
102
+ return current[key] as T;
103
+ });
104
+ }
105
+
106
+ export function updateHarnessSetting<T>(
107
+ key: string, update: (current: unknown) => T, seams: HarnessSettingsSeams = {},
108
+ ): T {
109
+ return mutate(seams, values => {
110
+ const next = update(values[key]);
111
+ values[key] = next;
112
+ return next;
113
+ });
114
+ }
package/src/tools.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  type OutputOptions,
14
14
  } from "./output.js";
15
15
  import { readOutputControls } from "./shared-log-utils.js";
16
- import { cancelCallbackBatch, 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);