@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.
- package/README.md +2 -2
- package/package.json +2 -1
- 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
|
|
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.
|
|
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
|
-
|
|
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
|
}
|