@narumitw/pi-subagents 0.33.0 → 0.35.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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/package.json +2 -1
  3. package/src/settings.ts +222 -181
package/README.md CHANGED
@@ -266,7 +266,7 @@ The direct routes remain predictable: `/subagents settings` changes user complet
266
266
  }
267
267
  ```
268
268
 
269
- The settings UI patches the raw JSON atomically and preserves unknown fields; it refuses to overwrite malformed or invalid settings. `blocking.enabled` defaults to `true`; set it to `false` for async-only delegation. `stateful.enabled` also defaults to `true`; its existing `false` value remains the blocking-only workflow. When stateful tools are enabled, their membership stays fixed across spawn, completion, interrupt, close, and mailbox transitions. This avoids lifecycle-driven tool-schema churn and preserves a stable provider prompt prefix for KV caching.
269
+ The settings UI patches the raw JSON atomically and preserves unknown fields; it refuses to overwrite malformed or invalid settings. Supported Pi writers serialize the latest-document read and same-directory temporary-file rename through `pi-subagents.json.mutation-lock`. Editors and older extension versions do not participate in that lock, so avoid manual edits while a settings save is in progress. `blocking.enabled` defaults to `true`; set it to `false` for async-only delegation. `stateful.enabled` also defaults to `true`; its existing `false` value remains the blocking-only workflow. When stateful tools are enabled, their membership stays fixed across spawn, completion, interrupt, close, and mailbox transitions. This avoids lifecycle-driven tool-schema churn and preserves a stable provider prompt prefix for KV caching.
270
270
 
271
271
  | Tool | Purpose |
272
272
  | --- | --- |
@@ -384,7 +384,7 @@ Built-in agents inherit the active/default Pi model instead of forcing a provide
384
384
 
385
385
  Open `/subagents`, choose **Advanced settings**, then **Agent tool settings** in an interactive Pi session to edit the tools each subagent may use. These are user settings stored in `~/.pi/agent/pi-subagents.json` and affect future sessions.
386
386
 
387
- Compatibility: a valid legacy `pi-subagents-config.json` is migrated automatically to `pi-subagents.json`. If both files exist, the new filename takes precedence.
387
+ Compatibility: a valid legacy `pi-subagents-config.json` remains readable with a warning and is never modified automatically; rename it to `pi-subagents.json`. The first subsequent settings save writes the canonical file. If both files exist, the new filename takes precedence.
388
388
 
389
389
  - Select an agent, then press Enter or Space to toggle tools.
390
390
  - Press `S` to save, or Esc to cancel and return to agent selection.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
+ "proper-lockfile": "^4.1.2",
32
33
  "typebox": "^1.3.8"
33
34
  },
34
35
  "peerDependencies": {
package/src/settings.ts CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import lockfile from "proper-lockfile";
5
6
  import {
6
7
  type AgentConfig,
7
8
  type CompletionDelivery,
@@ -141,13 +142,42 @@ export function normalizeSubagentSettings(value: unknown): SubagentSettings | un
141
142
  const SETTINGS_FILE = "pi-subagents.json";
142
143
  const LEGACY_SETTINGS_FILE = "pi-subagents-config.json";
143
144
  const DEFAULT_COMPLETION_DELIVERY: CompletionDelivery = "next-turn";
145
+ const SETTINGS_LOCK_FS_ADAPTER = {
146
+ mkdir: fs.mkdir,
147
+ mkdirSync: fs.mkdirSync,
148
+ realpath: fs.realpath,
149
+ realpathSync: fs.realpathSync,
150
+ rmdir: fs.rmdir,
151
+ rmdirSync: fs.rmdirSync,
152
+ stat: fs.stat,
153
+ statSync: fs.statSync,
154
+ utimes: fs.utimes,
155
+ utimesSync: fs.utimesSync,
156
+ };
144
157
  let pendingSettingsNotice: string | undefined;
145
158
 
146
- export function readSubagentSettings(): SubagentSettings | undefined {
147
- pendingSettingsNotice = undefined;
159
+ function resolveSubagentSettingsPaths(): {
160
+ canonicalPath: string;
161
+ legacyPath: string;
162
+ activePath?: string;
163
+ } {
148
164
  const canonicalPath = path.join(getAgentDir(), SETTINGS_FILE);
149
165
  const legacyPath = path.join(getAgentDir(), LEGACY_SETTINGS_FILE);
150
- if (fs.existsSync(canonicalPath)) {
166
+ return {
167
+ canonicalPath,
168
+ legacyPath,
169
+ activePath: fs.existsSync(canonicalPath)
170
+ ? canonicalPath
171
+ : fs.existsSync(legacyPath)
172
+ ? legacyPath
173
+ : undefined,
174
+ };
175
+ }
176
+
177
+ export function readSubagentSettings(): SubagentSettings | undefined {
178
+ pendingSettingsNotice = undefined;
179
+ const { canonicalPath, legacyPath, activePath } = resolveSubagentSettingsPaths();
180
+ if (activePath === canonicalPath) {
151
181
  const canonical = readSettingsFile(canonicalPath);
152
182
  const notices: string[] = [];
153
183
  if (!canonical) notices.push(`${SETTINGS_FILE} is invalid and was ignored.`);
@@ -157,89 +187,24 @@ export function readSubagentSettings(): SubagentSettings | undefined {
157
187
  if (notices.length > 0) pendingSettingsNotice = notices.join("\n");
158
188
  return canonical;
159
189
  }
160
- if (!fs.existsSync(legacyPath)) return undefined;
161
- const legacySnapshot = readSettingsSnapshot(legacyPath);
162
- const legacy = legacySnapshot.settings;
190
+ if (activePath === undefined) return undefined;
191
+ const legacy = readSettingsFile(legacyPath);
192
+ if (fs.existsSync(canonicalPath)) {
193
+ const canonical = readSettingsFile(canonicalPath);
194
+ pendingSettingsNotice = [
195
+ ...(!canonical ? [`${SETTINGS_FILE} is invalid and was ignored.`] : []),
196
+ `${LEGACY_SETTINGS_FILE} ignored because ${SETTINGS_FILE} was created concurrently.`,
197
+ ].join("\n");
198
+ return canonical;
199
+ }
163
200
  if (!legacy) {
164
201
  pendingSettingsNotice = `${LEGACY_SETTINGS_FILE} is invalid and was ignored.`;
165
202
  return undefined;
166
203
  }
167
- let installedIdentity: FileIdentity;
168
- try {
169
- installedIdentity = installFileExclusively(canonicalPath, legacySnapshot.contents ?? "");
170
- } catch (error) {
171
- if (fs.existsSync(canonicalPath)) {
172
- const canonical = readSettingsFile(canonicalPath);
173
- pendingSettingsNotice = [
174
- ...(!canonical ? [`${SETTINGS_FILE} is invalid and was ignored.`] : []),
175
- `${LEGACY_SETTINGS_FILE} ignored because ${SETTINGS_FILE} was created concurrently.`,
176
- ].join("\n");
177
- return canonical;
178
- }
179
- pendingSettingsNotice = `Subagent settings migration failed: ${formatError(error)}. The legacy file was used for this session.`;
180
- return legacy;
181
- }
182
- if (!fileContentsEqual(legacyPath, legacySnapshot.contents ?? "")) {
183
- pendingSettingsNotice = removeFileIfIdentityMatches(
184
- canonicalPath,
185
- installedIdentity,
186
- legacySnapshot.contents ?? "",
187
- )
188
- ? `${LEGACY_SETTINGS_FILE} changed during migration; the stale ${SETTINGS_FILE} snapshot was removed.`
189
- : `${LEGACY_SETTINGS_FILE} changed during migration, but ${SETTINGS_FILE} was replaced concurrently and takes precedence on the next load.`;
190
- return legacy;
191
- }
192
- try {
193
- fs.rmSync(legacyPath);
194
- pendingSettingsNotice = `Subagent settings migrated from ${LEGACY_SETTINGS_FILE} to ${SETTINGS_FILE}.`;
195
- } catch (error) {
196
- pendingSettingsNotice = `Subagent settings migrated to ${SETTINGS_FILE}, but ${LEGACY_SETTINGS_FILE} could not be removed: ${formatError(error)}.`;
197
- }
204
+ pendingSettingsNotice = `Using legacy ${LEGACY_SETTINGS_FILE}; rename it to ${SETTINGS_FILE}. Future saves write ${SETTINGS_FILE} without modifying the legacy file.`;
198
205
  return legacy;
199
206
  }
200
207
 
201
- type FileIdentity = { dev: number; ino: number };
202
-
203
- function installFileExclusively(filePath: string, contents: string): FileIdentity {
204
- const tempFile = path.join(path.dirname(filePath), `.${SETTINGS_FILE}.${randomUUID()}.tmp`);
205
- try {
206
- fs.writeFileSync(tempFile, contents, { encoding: "utf8", flag: "wx" });
207
- const identity = fs.lstatSync(tempFile);
208
- fs.linkSync(tempFile, filePath);
209
- return { dev: identity.dev, ino: identity.ino };
210
- } finally {
211
- try {
212
- fs.rmSync(tempFile, { force: true });
213
- } catch {
214
- // Preserve the migration result if best-effort temp cleanup fails.
215
- }
216
- }
217
- }
218
-
219
- function removeFileIfIdentityMatches(
220
- filePath: string,
221
- expected: FileIdentity,
222
- expectedContents: string,
223
- ) {
224
- try {
225
- const current = fs.lstatSync(filePath);
226
- if (current.dev !== expected.dev || current.ino !== expected.ino) return false;
227
- if (fs.readFileSync(filePath, "utf8") !== expectedContents) return false;
228
- fs.rmSync(filePath);
229
- return true;
230
- } catch {
231
- return false;
232
- }
233
- }
234
-
235
- function fileContentsEqual(filePath: string, expected: string) {
236
- try {
237
- return fs.readFileSync(filePath, "utf8") === expected;
238
- } catch {
239
- return false;
240
- }
241
- }
242
-
243
208
  export function consumeSubagentSettingsNotice() {
244
209
  const notice = pendingSettingsNotice;
245
210
  pendingSettingsNotice = undefined;
@@ -280,157 +245,207 @@ export function resolveDelegationWorkflow(
280
245
  return "disabled";
281
246
  }
282
247
 
283
- export function inspectDelegationWorkflowSettings(): DelegationWorkflowSettingsSnapshot {
284
- const configPath = subagentSettingsFilePath();
285
- if (!fs.existsSync(configPath)) {
286
- return { path: configPath, value: "all", source: "default" };
287
- }
248
+ function inspectSubagentSettingsDocument(): {
249
+ path: string;
250
+ raw?: Record<string, unknown>;
251
+ settings?: SubagentSettings;
252
+ error?: string;
253
+ } {
254
+ const { canonicalPath, activePath } = resolveSubagentSettingsPaths();
255
+ if (activePath === undefined) return { path: canonicalPath };
256
+ const inspected = inspectSubagentSettingsPath(activePath);
257
+ return activePath !== canonicalPath && fs.existsSync(canonicalPath)
258
+ ? inspectSubagentSettingsPath(canonicalPath)
259
+ : inspected;
260
+ }
261
+
262
+ function inspectSubagentSettingsPath(configPath: string): {
263
+ path: string;
264
+ raw?: Record<string, unknown>;
265
+ settings?: SubagentSettings;
266
+ error?: string;
267
+ } {
288
268
  try {
289
- const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
269
+ const raw: unknown = JSON.parse(fs.readFileSync(configPath, "utf8"));
290
270
  const settings = normalizeSubagentSettings(raw);
291
- if (!settings) throw new Error(`${SETTINGS_FILE} is not a valid settings object`);
292
- const explicit =
293
- (isPlainObject(raw.blocking) && hasOwn(raw.blocking, "enabled")) ||
294
- (isPlainObject(raw.stateful) && hasOwn(raw.stateful, "enabled"));
295
- return {
296
- path: configPath,
297
- value: resolveDelegationWorkflow(
298
- settings.blocking?.enabled !== false,
299
- settings.stateful?.enabled !== false,
300
- ),
301
- source: explicit ? "user settings" : "default",
302
- };
271
+ if (!isPlainObject(raw) || !settings) {
272
+ throw new Error(`${path.basename(configPath)} is not a valid settings object`);
273
+ }
274
+ return { path: configPath, raw, settings };
303
275
  } catch (error) {
276
+ return { path: configPath, error: formatError(error) };
277
+ }
278
+ }
279
+
280
+ export function inspectDelegationWorkflowSettings(): DelegationWorkflowSettingsSnapshot {
281
+ const inspected = inspectSubagentSettingsDocument();
282
+ if (!inspected.raw || !inspected.settings) {
304
283
  return {
305
- path: configPath,
284
+ path: inspected.path,
306
285
  value: "all",
307
286
  source: "default",
308
- error: formatError(error),
287
+ ...(inspected.error ? { error: inspected.error } : {}),
309
288
  };
310
289
  }
290
+ const explicit =
291
+ (isPlainObject(inspected.raw.blocking) && hasOwn(inspected.raw.blocking, "enabled")) ||
292
+ (isPlainObject(inspected.raw.stateful) && hasOwn(inspected.raw.stateful, "enabled"));
293
+ return {
294
+ path: inspected.path,
295
+ value: resolveDelegationWorkflow(
296
+ inspected.settings.blocking?.enabled !== false,
297
+ inspected.settings.stateful?.enabled !== false,
298
+ ),
299
+ source: explicit ? "user settings" : "default",
300
+ };
311
301
  }
312
302
 
313
303
  export function inspectCompletionDeliverySettings(): CompletionDeliverySettingsSnapshot {
314
- const configPath = subagentSettingsFilePath();
315
- if (!fs.existsSync(configPath)) {
316
- return { path: configPath, value: DEFAULT_COMPLETION_DELIVERY, source: "default" };
317
- }
318
- try {
319
- const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
320
- const settings = normalizeSubagentSettings(raw);
321
- if (!settings) throw new Error(`${SETTINGS_FILE} is not a valid settings object`);
322
- const explicit = isPlainObject(raw.stateful) && hasOwn(raw.stateful, "completionDelivery");
304
+ const inspected = inspectSubagentSettingsDocument();
305
+ if (!inspected.raw || !inspected.settings) {
323
306
  return {
324
- path: configPath,
325
- value: settings.stateful?.completionDelivery ?? DEFAULT_COMPLETION_DELIVERY,
326
- source: explicit ? "user settings" : "default",
327
- };
328
- } catch (error) {
329
- return {
330
- path: configPath,
307
+ path: inspected.path,
331
308
  value: DEFAULT_COMPLETION_DELIVERY,
332
309
  source: "default",
333
- error: formatError(error),
310
+ ...(inspected.error ? { error: inspected.error } : {}),
334
311
  };
335
312
  }
313
+ const explicit =
314
+ isPlainObject(inspected.raw.stateful) && hasOwn(inspected.raw.stateful, "completionDelivery");
315
+ return {
316
+ path: inspected.path,
317
+ value: inspected.settings.stateful?.completionDelivery ?? DEFAULT_COMPLETION_DELIVERY,
318
+ source: explicit ? "user settings" : "default",
319
+ };
336
320
  }
337
321
 
338
322
  export function updateDelegationWorkflowSetting(
339
323
  value: Exclude<DelegationWorkflow, "disabled">,
340
324
  ): void {
341
- const raw = readSettingsObjectForUpdate();
342
- const blocking = raw.blocking;
343
- if (blocking !== undefined && !isPlainObject(blocking)) {
344
- throw new Error(`Cannot update invalid ${SETTINGS_FILE} blocking settings`);
345
- }
346
- const stateful = raw.stateful;
347
- if (stateful !== undefined && !isPlainObject(stateful)) {
348
- throw new Error(`Cannot update invalid ${SETTINGS_FILE} stateful settings`);
349
- }
350
- writeSettingsObject({
351
- ...raw,
352
- blocking: {
353
- ...(blocking ?? {}),
354
- enabled: value !== "async-only",
355
- },
356
- stateful: {
357
- ...(stateful ?? {}),
358
- enabled: value !== "blocking-only",
359
- },
325
+ withSettingsMutationLock(() => {
326
+ const update = readSettingsObjectForUpdate();
327
+ const raw = update.document;
328
+ const blocking = raw.blocking;
329
+ if (blocking !== undefined && !isPlainObject(blocking)) {
330
+ throw new Error(`Cannot update invalid ${SETTINGS_FILE} blocking settings`);
331
+ }
332
+ const stateful = raw.stateful;
333
+ if (stateful !== undefined && !isPlainObject(stateful)) {
334
+ throw new Error(`Cannot update invalid ${SETTINGS_FILE} stateful settings`);
335
+ }
336
+ writeSettingsObjectUnlocked(
337
+ {
338
+ ...raw,
339
+ blocking: {
340
+ ...(blocking ?? {}),
341
+ enabled: value !== "async-only",
342
+ },
343
+ stateful: {
344
+ ...(stateful ?? {}),
345
+ enabled: value !== "blocking-only",
346
+ },
347
+ },
348
+ update.replaceCanonical,
349
+ );
360
350
  });
361
351
  }
362
352
 
363
353
  export function updateCompletionDeliverySetting(value: CompletionDelivery): void {
364
- const raw = readSettingsObjectForUpdate();
365
- const stateful = raw.stateful;
366
- if (stateful !== undefined && !isPlainObject(stateful)) {
367
- throw new Error(`Cannot update invalid ${SETTINGS_FILE} stateful settings`);
368
- }
369
- writeSettingsObject({
370
- ...raw,
371
- stateful: {
372
- ...(stateful ?? {}),
373
- completionDelivery: value,
374
- },
354
+ withSettingsMutationLock(() => {
355
+ const update = readSettingsObjectForUpdate();
356
+ const raw = update.document;
357
+ const stateful = raw.stateful;
358
+ if (stateful !== undefined && !isPlainObject(stateful)) {
359
+ throw new Error(`Cannot update invalid ${SETTINGS_FILE} stateful settings`);
360
+ }
361
+ writeSettingsObjectUnlocked(
362
+ {
363
+ ...raw,
364
+ stateful: {
365
+ ...(stateful ?? {}),
366
+ completionDelivery: value,
367
+ },
368
+ },
369
+ update.replaceCanonical,
370
+ );
375
371
  });
376
372
  }
377
373
 
378
374
  export function updateAgentToolsSetting(name: string, tools: string[] | undefined): void {
379
- const raw = readSettingsObjectForUpdate();
380
- const rawAgents = raw.agents;
381
- if (rawAgents !== undefined && !isPlainObject(rawAgents)) {
382
- throw new Error(`Cannot update invalid ${SETTINGS_FILE} agent settings`);
383
- }
384
- const agents = { ...(rawAgents ?? {}) };
385
- const rawAgent = hasOwn(agents, name) ? agents[name] : undefined;
386
- if (rawAgent !== undefined && !isPlainObject(rawAgent)) {
387
- throw new Error(`Cannot update invalid ${SETTINGS_FILE} settings for ${name}`);
388
- }
389
- const agent = { ...(rawAgent ?? {}) };
390
- if (tools === undefined) delete agent.tools;
391
- else agent.tools = tools;
392
- if (Object.keys(agent).length > 0) {
393
- Object.defineProperty(agents, name, {
394
- value: agent,
395
- enumerable: true,
396
- configurable: true,
397
- writable: true,
398
- });
399
- } else {
400
- delete agents[name];
401
- }
375
+ withSettingsMutationLock(() => {
376
+ const update = readSettingsObjectForUpdate();
377
+ const raw = update.document;
378
+ const rawAgents = raw.agents;
379
+ if (rawAgents !== undefined && !isPlainObject(rawAgents)) {
380
+ throw new Error(`Cannot update invalid ${SETTINGS_FILE} agent settings`);
381
+ }
382
+ const agents = { ...(rawAgents ?? {}) };
383
+ const rawAgent = hasOwn(agents, name) ? agents[name] : undefined;
384
+ if (rawAgent !== undefined && !isPlainObject(rawAgent)) {
385
+ throw new Error(`Cannot update invalid ${SETTINGS_FILE} settings for ${name}`);
386
+ }
387
+ const agent = { ...(rawAgent ?? {}) };
388
+ if (tools === undefined) delete agent.tools;
389
+ else agent.tools = tools;
390
+ if (Object.keys(agent).length > 0) {
391
+ Object.defineProperty(agents, name, {
392
+ value: agent,
393
+ enumerable: true,
394
+ configurable: true,
395
+ writable: true,
396
+ });
397
+ } else {
398
+ delete agents[name];
399
+ }
400
+
401
+ const updated = { ...raw };
402
+ if (Object.keys(agents).length > 0) updated.agents = agents;
403
+ else delete updated.agents;
404
+ writeSettingsObjectUnlocked(updated, update.replaceCanonical);
405
+ });
406
+ }
402
407
 
403
- const updated = { ...raw };
404
- if (Object.keys(agents).length > 0) updated.agents = agents;
405
- else delete updated.agents;
406
- writeSettingsObject(updated);
408
+ interface SettingsObjectForUpdate {
409
+ document: Record<string, unknown>;
410
+ replaceCanonical: boolean;
407
411
  }
408
412
 
409
- function readSettingsObjectForUpdate(): Record<string, unknown> {
410
- const configPath = subagentSettingsFilePath();
411
- if (!fs.existsSync(configPath)) return {};
413
+ function readSettingsObjectForUpdate(): SettingsObjectForUpdate {
414
+ const { canonicalPath, activePath } = resolveSubagentSettingsPaths();
415
+ if (activePath === undefined) return { document: {}, replaceCanonical: false };
416
+ const activeFile = path.basename(activePath);
412
417
  let parsed: unknown;
413
418
  try {
414
- parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
419
+ parsed = JSON.parse(fs.readFileSync(activePath, "utf8"));
415
420
  } catch (error) {
416
- throw new Error(`Cannot update malformed ${SETTINGS_FILE}: ${formatError(error)}`);
421
+ throw new Error(`Cannot update malformed ${activeFile}: ${formatError(error)}`);
417
422
  }
418
423
  if (!isPlainObject(parsed) || !normalizeSubagentSettings(parsed)) {
419
- throw new Error(`Cannot update invalid ${SETTINGS_FILE}`);
424
+ throw new Error(`Cannot update invalid ${activeFile}`);
420
425
  }
421
- return parsed;
426
+ return { document: parsed, replaceCanonical: activePath === canonicalPath };
422
427
  }
423
428
 
424
- function writeSettingsObject(settings: object): void {
429
+ function writeSettingsObject(settings: object, replaceCanonical?: boolean): void {
430
+ withSettingsMutationLock(() => writeSettingsObjectUnlocked(settings, replaceCanonical));
431
+ }
432
+
433
+ function writeSettingsObjectUnlocked(settings: object, replaceCanonical?: boolean): void {
425
434
  const agentDir = getAgentDir();
426
435
  fs.mkdirSync(agentDir, { recursive: true });
427
436
  const configPath = path.join(agentDir, SETTINGS_FILE);
428
437
  const tempFile = path.join(agentDir, `.${SETTINGS_FILE}.${randomUUID()}.tmp`);
438
+ // Updates seeded from a missing or legacy document must remain exclusive even if the
439
+ // canonical path appears after the read and before publication.
440
+ const firstCanonicalPublication = !(replaceCanonical ?? pathEntryExists(configPath));
429
441
  try {
430
442
  fs.writeFileSync(tempFile, `${JSON.stringify(settings, null, "\t")}\n`, {
431
443
  encoding: "utf8",
432
444
  flag: "wx",
433
445
  });
446
+ if (firstCanonicalPublication && pathEntryExists(configPath)) {
447
+ throw new Error(`${SETTINGS_FILE} was created concurrently; reopen settings and retry`);
448
+ }
434
449
  fs.renameSync(tempFile, configPath);
435
450
  } finally {
436
451
  try {
@@ -441,6 +456,32 @@ function writeSettingsObject(settings: object): void {
441
456
  }
442
457
  }
443
458
 
459
+ function withSettingsMutationLock<T>(mutate: () => T): T {
460
+ const agentDir = getAgentDir();
461
+ fs.mkdirSync(agentDir, { recursive: true });
462
+ const configPath = path.join(agentDir, SETTINGS_FILE);
463
+ const release = lockfile.lockSync(configPath, {
464
+ fs: SETTINGS_LOCK_FS_ADAPTER,
465
+ lockfilePath: `${configPath}.mutation-lock`,
466
+ realpath: false,
467
+ });
468
+ try {
469
+ return mutate();
470
+ } finally {
471
+ release();
472
+ }
473
+ }
474
+
475
+ function pathEntryExists(filePath: string): boolean {
476
+ try {
477
+ fs.lstatSync(filePath);
478
+ return true;
479
+ } catch (error) {
480
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
481
+ throw error;
482
+ }
483
+ }
484
+
444
485
  function readSettingsFile(configPath: string): SubagentSettings | undefined {
445
486
  return readSettingsSnapshot(configPath).settings;
446
487
  }