@narumitw/pi-subagents 0.33.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/package.json +3 -1
- package/src/config-ui.ts +476 -718
- package/src/settings.ts +222 -181
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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 (
|
|
161
|
-
const
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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 (!
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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:
|
|
284
|
+
path: inspected.path,
|
|
306
285
|
value: "all",
|
|
307
286
|
source: "default",
|
|
308
|
-
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
|
|
315
|
-
if (!
|
|
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:
|
|
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:
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
writeSettingsObject(updated);
|
|
408
|
+
interface SettingsObjectForUpdate {
|
|
409
|
+
document: Record<string, unknown>;
|
|
410
|
+
replaceCanonical: boolean;
|
|
407
411
|
}
|
|
408
412
|
|
|
409
|
-
function readSettingsObjectForUpdate():
|
|
410
|
-
const
|
|
411
|
-
if (
|
|
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(
|
|
419
|
+
parsed = JSON.parse(fs.readFileSync(activePath, "utf8"));
|
|
415
420
|
} catch (error) {
|
|
416
|
-
throw new Error(`Cannot update malformed ${
|
|
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 ${
|
|
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
|
}
|