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 +4 -1
- package/src/shared-callback-batcher.ts +199 -15
- package/src/shared-harness-settings.ts +114 -0
- package/src/tools.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-background-tasks",
|
|
3
|
-
"version": "0.
|
|
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
|
|
578
|
+
const deliveryMode = (): "followUp" | "steer" | undefined => {
|
|
579
|
+
if (cancelled) return undefined;
|
|
452
580
|
try {
|
|
453
581
|
if (!isAvailable()) {
|
|
454
582
|
awaitingRun = false;
|
|
455
|
-
return
|
|
583
|
+
return whileBusy === "steer" && foregroundRunning && activeTools.size === 0 ? "steer" : undefined;
|
|
456
584
|
}
|
|
457
|
-
return !awaitingRun;
|
|
458
|
-
} catch { return
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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) => {
|
|
207
|
-
|
|
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);
|