@narumitw/pi-subagents 0.31.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 +32 -16
- package/package.json +2 -1
- package/src/agents.ts +6 -1
- package/src/config-ui.ts +368 -66
- package/src/settings.ts +263 -139
- package/src/stateful.ts +25 -47
- package/src/subagents.ts +30 -12
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,
|
|
@@ -80,6 +81,15 @@ export function normalizeSubagentSettings(value: unknown): SubagentSettings | un
|
|
|
80
81
|
}
|
|
81
82
|
if (Object.keys(agents).length > 0) settings.agents = agents;
|
|
82
83
|
}
|
|
84
|
+
if (hasOwn(value, "blocking")) {
|
|
85
|
+
if (!isPlainObject(value.blocking)) return undefined;
|
|
86
|
+
const blocking: NonNullable<SubagentSettings["blocking"]> = {};
|
|
87
|
+
if (hasOwn(value.blocking, "enabled")) {
|
|
88
|
+
if (typeof value.blocking.enabled !== "boolean") return undefined;
|
|
89
|
+
blocking.enabled = value.blocking.enabled;
|
|
90
|
+
}
|
|
91
|
+
settings.blocking = blocking;
|
|
92
|
+
}
|
|
83
93
|
if (hasOwn(value, "stateful")) {
|
|
84
94
|
if (!isPlainObject(value.stateful)) return undefined;
|
|
85
95
|
const runtime: NonNullable<SubagentSettings["stateful"]> = {};
|
|
@@ -132,13 +142,42 @@ export function normalizeSubagentSettings(value: unknown): SubagentSettings | un
|
|
|
132
142
|
const SETTINGS_FILE = "pi-subagents.json";
|
|
133
143
|
const LEGACY_SETTINGS_FILE = "pi-subagents-config.json";
|
|
134
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
|
+
};
|
|
135
157
|
let pendingSettingsNotice: string | undefined;
|
|
136
158
|
|
|
137
|
-
|
|
138
|
-
|
|
159
|
+
function resolveSubagentSettingsPaths(): {
|
|
160
|
+
canonicalPath: string;
|
|
161
|
+
legacyPath: string;
|
|
162
|
+
activePath?: string;
|
|
163
|
+
} {
|
|
139
164
|
const canonicalPath = path.join(getAgentDir(), SETTINGS_FILE);
|
|
140
165
|
const legacyPath = path.join(getAgentDir(), LEGACY_SETTINGS_FILE);
|
|
141
|
-
|
|
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) {
|
|
142
181
|
const canonical = readSettingsFile(canonicalPath);
|
|
143
182
|
const notices: string[] = [];
|
|
144
183
|
if (!canonical) notices.push(`${SETTINGS_FILE} is invalid and was ignored.`);
|
|
@@ -148,89 +187,24 @@ export function readSubagentSettings(): SubagentSettings | undefined {
|
|
|
148
187
|
if (notices.length > 0) pendingSettingsNotice = notices.join("\n");
|
|
149
188
|
return canonical;
|
|
150
189
|
}
|
|
151
|
-
if (
|
|
152
|
-
const
|
|
153
|
-
|
|
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
|
+
}
|
|
154
200
|
if (!legacy) {
|
|
155
201
|
pendingSettingsNotice = `${LEGACY_SETTINGS_FILE} is invalid and was ignored.`;
|
|
156
202
|
return undefined;
|
|
157
203
|
}
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
installedIdentity = installFileExclusively(canonicalPath, legacySnapshot.contents ?? "");
|
|
161
|
-
} catch (error) {
|
|
162
|
-
if (fs.existsSync(canonicalPath)) {
|
|
163
|
-
const canonical = readSettingsFile(canonicalPath);
|
|
164
|
-
pendingSettingsNotice = [
|
|
165
|
-
...(!canonical ? [`${SETTINGS_FILE} is invalid and was ignored.`] : []),
|
|
166
|
-
`${LEGACY_SETTINGS_FILE} ignored because ${SETTINGS_FILE} was created concurrently.`,
|
|
167
|
-
].join("\n");
|
|
168
|
-
return canonical;
|
|
169
|
-
}
|
|
170
|
-
pendingSettingsNotice = `Subagent settings migration failed: ${formatError(error)}. The legacy file was used for this session.`;
|
|
171
|
-
return legacy;
|
|
172
|
-
}
|
|
173
|
-
if (!fileContentsEqual(legacyPath, legacySnapshot.contents ?? "")) {
|
|
174
|
-
pendingSettingsNotice = removeFileIfIdentityMatches(
|
|
175
|
-
canonicalPath,
|
|
176
|
-
installedIdentity,
|
|
177
|
-
legacySnapshot.contents ?? "",
|
|
178
|
-
)
|
|
179
|
-
? `${LEGACY_SETTINGS_FILE} changed during migration; the stale ${SETTINGS_FILE} snapshot was removed.`
|
|
180
|
-
: `${LEGACY_SETTINGS_FILE} changed during migration, but ${SETTINGS_FILE} was replaced concurrently and takes precedence on the next load.`;
|
|
181
|
-
return legacy;
|
|
182
|
-
}
|
|
183
|
-
try {
|
|
184
|
-
fs.rmSync(legacyPath);
|
|
185
|
-
pendingSettingsNotice = `Subagent settings migrated from ${LEGACY_SETTINGS_FILE} to ${SETTINGS_FILE}.`;
|
|
186
|
-
} catch (error) {
|
|
187
|
-
pendingSettingsNotice = `Subagent settings migrated to ${SETTINGS_FILE}, but ${LEGACY_SETTINGS_FILE} could not be removed: ${formatError(error)}.`;
|
|
188
|
-
}
|
|
204
|
+
pendingSettingsNotice = `Using legacy ${LEGACY_SETTINGS_FILE}; rename it to ${SETTINGS_FILE}. Future saves write ${SETTINGS_FILE} without modifying the legacy file.`;
|
|
189
205
|
return legacy;
|
|
190
206
|
}
|
|
191
207
|
|
|
192
|
-
type FileIdentity = { dev: number; ino: number };
|
|
193
|
-
|
|
194
|
-
function installFileExclusively(filePath: string, contents: string): FileIdentity {
|
|
195
|
-
const tempFile = path.join(path.dirname(filePath), `.${SETTINGS_FILE}.${randomUUID()}.tmp`);
|
|
196
|
-
try {
|
|
197
|
-
fs.writeFileSync(tempFile, contents, { encoding: "utf8", flag: "wx" });
|
|
198
|
-
const identity = fs.lstatSync(tempFile);
|
|
199
|
-
fs.linkSync(tempFile, filePath);
|
|
200
|
-
return { dev: identity.dev, ino: identity.ino };
|
|
201
|
-
} finally {
|
|
202
|
-
try {
|
|
203
|
-
fs.rmSync(tempFile, { force: true });
|
|
204
|
-
} catch {
|
|
205
|
-
// Preserve the migration result if best-effort temp cleanup fails.
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function removeFileIfIdentityMatches(
|
|
211
|
-
filePath: string,
|
|
212
|
-
expected: FileIdentity,
|
|
213
|
-
expectedContents: string,
|
|
214
|
-
) {
|
|
215
|
-
try {
|
|
216
|
-
const current = fs.lstatSync(filePath);
|
|
217
|
-
if (current.dev !== expected.dev || current.ino !== expected.ino) return false;
|
|
218
|
-
if (fs.readFileSync(filePath, "utf8") !== expectedContents) return false;
|
|
219
|
-
fs.rmSync(filePath);
|
|
220
|
-
return true;
|
|
221
|
-
} catch {
|
|
222
|
-
return false;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function fileContentsEqual(filePath: string, expected: string) {
|
|
227
|
-
try {
|
|
228
|
-
return fs.readFileSync(filePath, "utf8") === expected;
|
|
229
|
-
} catch {
|
|
230
|
-
return false;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
208
|
export function consumeSubagentSettingsNotice() {
|
|
235
209
|
const notice = pendingSettingsNotice;
|
|
236
210
|
pendingSettingsNotice = undefined;
|
|
@@ -241,6 +215,15 @@ export function saveSubagentConfig(settings: SubagentSettings): void {
|
|
|
241
215
|
writeSettingsObject(settings);
|
|
242
216
|
}
|
|
243
217
|
|
|
218
|
+
export type DelegationWorkflow = "all" | "async-only" | "blocking-only" | "disabled";
|
|
219
|
+
|
|
220
|
+
export interface DelegationWorkflowSettingsSnapshot {
|
|
221
|
+
path: string;
|
|
222
|
+
value: DelegationWorkflow;
|
|
223
|
+
source: "default" | "user settings";
|
|
224
|
+
error?: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
244
227
|
export interface CompletionDeliverySettingsSnapshot {
|
|
245
228
|
path: string;
|
|
246
229
|
value: CompletionDelivery;
|
|
@@ -252,102 +235,217 @@ export function subagentSettingsFilePath(): string {
|
|
|
252
235
|
return path.join(getAgentDir(), SETTINGS_FILE);
|
|
253
236
|
}
|
|
254
237
|
|
|
255
|
-
export function
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
238
|
+
export function resolveDelegationWorkflow(
|
|
239
|
+
blockingEnabled: boolean,
|
|
240
|
+
statefulEnabled: boolean,
|
|
241
|
+
): DelegationWorkflow {
|
|
242
|
+
if (blockingEnabled && statefulEnabled) return "all";
|
|
243
|
+
if (statefulEnabled) return "async-only";
|
|
244
|
+
if (blockingEnabled) return "blocking-only";
|
|
245
|
+
return "disabled";
|
|
246
|
+
}
|
|
247
|
+
|
|
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
|
+
} {
|
|
260
268
|
try {
|
|
261
|
-
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
269
|
+
const raw: unknown = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
262
270
|
const settings = normalizeSubagentSettings(raw);
|
|
263
|
-
if (!
|
|
264
|
-
|
|
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 };
|
|
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) {
|
|
265
283
|
return {
|
|
266
|
-
path:
|
|
267
|
-
value:
|
|
268
|
-
source:
|
|
284
|
+
path: inspected.path,
|
|
285
|
+
value: "all",
|
|
286
|
+
source: "default",
|
|
287
|
+
...(inspected.error ? { error: inspected.error } : {}),
|
|
269
288
|
};
|
|
270
|
-
}
|
|
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
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function inspectCompletionDeliverySettings(): CompletionDeliverySettingsSnapshot {
|
|
304
|
+
const inspected = inspectSubagentSettingsDocument();
|
|
305
|
+
if (!inspected.raw || !inspected.settings) {
|
|
271
306
|
return {
|
|
272
|
-
path:
|
|
307
|
+
path: inspected.path,
|
|
273
308
|
value: DEFAULT_COMPLETION_DELIVERY,
|
|
274
309
|
source: "default",
|
|
275
|
-
error:
|
|
310
|
+
...(inspected.error ? { error: inspected.error } : {}),
|
|
276
311
|
};
|
|
277
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
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function updateDelegationWorkflowSetting(
|
|
323
|
+
value: Exclude<DelegationWorkflow, "disabled">,
|
|
324
|
+
): void {
|
|
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
|
+
);
|
|
350
|
+
});
|
|
278
351
|
}
|
|
279
352
|
|
|
280
353
|
export function updateCompletionDeliverySetting(value: CompletionDelivery): void {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
+
);
|
|
292
371
|
});
|
|
293
372
|
}
|
|
294
373
|
|
|
295
374
|
export function updateAgentToolsSetting(name: string, tools: string[] | undefined): void {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
}
|
|
319
407
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
writeSettingsObject(updated);
|
|
408
|
+
interface SettingsObjectForUpdate {
|
|
409
|
+
document: Record<string, unknown>;
|
|
410
|
+
replaceCanonical: boolean;
|
|
324
411
|
}
|
|
325
412
|
|
|
326
|
-
function readSettingsObjectForUpdate():
|
|
327
|
-
const
|
|
328
|
-
if (
|
|
413
|
+
function readSettingsObjectForUpdate(): SettingsObjectForUpdate {
|
|
414
|
+
const { canonicalPath, activePath } = resolveSubagentSettingsPaths();
|
|
415
|
+
if (activePath === undefined) return { document: {}, replaceCanonical: false };
|
|
416
|
+
const activeFile = path.basename(activePath);
|
|
329
417
|
let parsed: unknown;
|
|
330
418
|
try {
|
|
331
|
-
parsed = JSON.parse(fs.readFileSync(
|
|
419
|
+
parsed = JSON.parse(fs.readFileSync(activePath, "utf8"));
|
|
332
420
|
} catch (error) {
|
|
333
|
-
throw new Error(`Cannot update malformed ${
|
|
421
|
+
throw new Error(`Cannot update malformed ${activeFile}: ${formatError(error)}`);
|
|
334
422
|
}
|
|
335
423
|
if (!isPlainObject(parsed) || !normalizeSubagentSettings(parsed)) {
|
|
336
|
-
throw new Error(`Cannot update invalid ${
|
|
424
|
+
throw new Error(`Cannot update invalid ${activeFile}`);
|
|
337
425
|
}
|
|
338
|
-
return parsed;
|
|
426
|
+
return { document: parsed, replaceCanonical: activePath === canonicalPath };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function writeSettingsObject(settings: object, replaceCanonical?: boolean): void {
|
|
430
|
+
withSettingsMutationLock(() => writeSettingsObjectUnlocked(settings, replaceCanonical));
|
|
339
431
|
}
|
|
340
432
|
|
|
341
|
-
function
|
|
433
|
+
function writeSettingsObjectUnlocked(settings: object, replaceCanonical?: boolean): void {
|
|
342
434
|
const agentDir = getAgentDir();
|
|
343
435
|
fs.mkdirSync(agentDir, { recursive: true });
|
|
344
436
|
const configPath = path.join(agentDir, SETTINGS_FILE);
|
|
345
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));
|
|
346
441
|
try {
|
|
347
442
|
fs.writeFileSync(tempFile, `${JSON.stringify(settings, null, "\t")}\n`, {
|
|
348
443
|
encoding: "utf8",
|
|
349
444
|
flag: "wx",
|
|
350
445
|
});
|
|
446
|
+
if (firstCanonicalPublication && pathEntryExists(configPath)) {
|
|
447
|
+
throw new Error(`${SETTINGS_FILE} was created concurrently; reopen settings and retry`);
|
|
448
|
+
}
|
|
351
449
|
fs.renameSync(tempFile, configPath);
|
|
352
450
|
} finally {
|
|
353
451
|
try {
|
|
@@ -358,6 +456,32 @@ function writeSettingsObject(settings: object): void {
|
|
|
358
456
|
}
|
|
359
457
|
}
|
|
360
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
|
+
|
|
361
485
|
function readSettingsFile(configPath: string): SubagentSettings | undefined {
|
|
362
486
|
return readSettingsSnapshot(configPath).settings;
|
|
363
487
|
}
|
package/src/stateful.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type CompletionDelivery,
|
|
13
13
|
discoverAgents,
|
|
14
14
|
isThinkingLevel,
|
|
15
|
+
type SubagentRuntimeSettings,
|
|
15
16
|
THINKING_LEVELS,
|
|
16
17
|
} from "./agents.js";
|
|
17
18
|
import { buildContextSnapshot, type ContextMode, redactPrivateText } from "./context.js";
|
|
@@ -52,11 +53,18 @@ const MAX_COMPLETION_ERROR_BYTES = 512;
|
|
|
52
53
|
const MAX_COMPLETIONS_PER_MESSAGE = 16;
|
|
53
54
|
const COMPLETION_BATCH_DELAY_MS = 10;
|
|
54
55
|
|
|
55
|
-
function createSpawnPromptGuidelines(
|
|
56
|
+
function createSpawnPromptGuidelines(
|
|
57
|
+
completionDelivery: CompletionDelivery,
|
|
58
|
+
blockingEnabled = true,
|
|
59
|
+
): string[] {
|
|
56
60
|
const deliveryGuidance =
|
|
57
61
|
completionDelivery === "auto-resume"
|
|
58
|
-
?
|
|
59
|
-
|
|
62
|
+
? blockingEnabled
|
|
63
|
+
? "With subagent_spawn completion delivery set to auto-resume, prefer one subagent_spawn for broad asynchronous research or review that covers related branches even when the final answer depends on its result; do not choose blocking parallel fan-out merely to keep delegation in the same turn."
|
|
64
|
+
: "With subagent_spawn completion delivery set to auto-resume, prefer one subagent_spawn for broad asynchronous research or review that covers related branches even when the final answer depends on its result."
|
|
65
|
+
: blockingEnabled
|
|
66
|
+
? "With subagent_spawn completion delivery set to next-turn (the default), prefer one subagent_spawn for broad asynchronous research or review only when the current response does not depend on its result; use the blocking subagent when the final answer depends on the detached result."
|
|
67
|
+
: "With subagent_spawn completion delivery set to next-turn (the default), use subagent_spawn only when the current response does not depend on its result; complete final-answer-dependent work directly because an idle root is not awakened.";
|
|
60
68
|
const noLocalWorkGuidance =
|
|
61
69
|
completionDelivery === "auto-resume"
|
|
62
70
|
? "After subagent_spawn returns, do useful non-overlapping local work immediately. If none remains, briefly tell the user what subagent_spawn launched and end the response; auto-resume will request a synthesis turn after completion."
|
|
@@ -66,8 +74,12 @@ function createSpawnPromptGuidelines(completionDelivery: CompletionDelivery): st
|
|
|
66
74
|
"Set subagent_spawn thinkingLevel to the lowest sufficient thinking level for the delegated task: use off or minimal for extraction, formatting, or mechanical work; low for straightforward bounded work; medium for ordinary multi-step research or implementation; high for complex debugging, design, review, or cross-file analysis; xhigh for highly ambiguous, cross-system, or high-risk analysis; and max only for the hardest tasks when quality clearly outweighs latency and cost. Omit subagent_spawn thinkingLevel only to preserve the agent or child default.",
|
|
67
75
|
deliveryGuidance,
|
|
68
76
|
"Use a single subagent_spawn only for a concrete bounded subtask that can run independently and has an isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation.",
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
...(blockingEnabled
|
|
78
|
+
? [
|
|
79
|
+
"Use the blocking subagent instead of subagent_spawn when synchronous output is required before the main agent can continue and waiting is intentional; queued steering cannot be processed until that blocking call returns.",
|
|
80
|
+
"When subagent_spawn fits the completion-delivery policy, do not choose a blocking parallel subagent merely to keep delegation in the same turn.",
|
|
81
|
+
]
|
|
82
|
+
: []),
|
|
71
83
|
"Add another subagent_spawn only for truly independent work with safe workspace concurrency.",
|
|
72
84
|
noLocalWorkGuidance,
|
|
73
85
|
'Consume and synthesize available subagent_spawn completion messages; use subagent_manage with action "interrupt" or "close" for agents that are no longer needed.',
|
|
@@ -76,8 +88,10 @@ function createSpawnPromptGuidelines(completionDelivery: CompletionDelivery): st
|
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
export interface StatefulSubagentDependencies {
|
|
91
|
+
blockingEnabled?: boolean;
|
|
79
92
|
createInProcessSession?: ChildSessionFactory;
|
|
80
93
|
workspaceManager?: WorkspaceManager;
|
|
94
|
+
settings?: SubagentRuntimeSettings;
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
export interface StatefulSubagentRuntimeStatus {
|
|
@@ -106,7 +120,10 @@ export function registerStatefulSubagents(
|
|
|
106
120
|
pi: ExtensionAPI,
|
|
107
121
|
dependencies: StatefulSubagentDependencies = {},
|
|
108
122
|
): StatefulSubagentController {
|
|
109
|
-
const settings =
|
|
123
|
+
const settings = Object.hasOwn(dependencies, "settings")
|
|
124
|
+
? (dependencies.settings ?? {})
|
|
125
|
+
: (readSubagentSettings()?.stateful ?? {});
|
|
126
|
+
const blockingEnabled = dependencies.blockingEnabled !== false;
|
|
110
127
|
const enabled = settings.enabled !== false;
|
|
111
128
|
const transportKind = resolveStatefulTransportKind(settings.transport);
|
|
112
129
|
let completionDelivery = resolveCompletionDelivery(settings.completionDelivery);
|
|
@@ -304,7 +321,7 @@ export function registerStatefulSubagents(
|
|
|
304
321
|
description:
|
|
305
322
|
"Start an addressable background subagent with an optional thinking level chosen for the task difficulty, return immediately with an agentId, and receive its completion asynchronously.",
|
|
306
323
|
promptSnippet: "Start a reusable detached subagent; completion is delivered asynchronously",
|
|
307
|
-
promptGuidelines: createSpawnPromptGuidelines(completionDelivery),
|
|
324
|
+
promptGuidelines: createSpawnPromptGuidelines(completionDelivery, blockingEnabled),
|
|
308
325
|
parameters: Type.Object({
|
|
309
326
|
agent: Type.String({ minLength: 1 }),
|
|
310
327
|
task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
|
|
@@ -382,7 +399,7 @@ export function registerStatefulSubagents(
|
|
|
382
399
|
},
|
|
383
400
|
});
|
|
384
401
|
refreshSpawnToolRegistration = () => {
|
|
385
|
-
spawnTool.promptGuidelines = createSpawnPromptGuidelines(completionDelivery);
|
|
402
|
+
spawnTool.promptGuidelines = createSpawnPromptGuidelines(completionDelivery, blockingEnabled);
|
|
386
403
|
pi.registerTool(spawnTool);
|
|
387
404
|
};
|
|
388
405
|
refreshSpawnToolRegistration();
|
|
@@ -531,39 +548,6 @@ export function registerStatefulSubagents(
|
|
|
531
548
|
},
|
|
532
549
|
});
|
|
533
550
|
|
|
534
|
-
pi.registerCommand("subagents:agents", {
|
|
535
|
-
description: "Inspect or clear current-session subagents",
|
|
536
|
-
getArgumentCompletions(prefix: string) {
|
|
537
|
-
return ["list", "clear"]
|
|
538
|
-
.filter((value) => value.startsWith(prefix))
|
|
539
|
-
.map((value) => ({ value, label: value }));
|
|
540
|
-
},
|
|
541
|
-
async handler(args, ctx) {
|
|
542
|
-
const subcommand = args.trim().toLowerCase() || "list";
|
|
543
|
-
if (subcommand === "clear") {
|
|
544
|
-
const count = await controller.clearAgents();
|
|
545
|
-
ctx.ui.notify(
|
|
546
|
-
count > 0
|
|
547
|
-
? `Cleared ${count} current-session subagent${count === 1 ? "" : "s"}.`
|
|
548
|
-
: statefulEmptyMessage(controller.getRuntimeStatus()),
|
|
549
|
-
"info",
|
|
550
|
-
);
|
|
551
|
-
return;
|
|
552
|
-
}
|
|
553
|
-
if (subcommand !== "list") {
|
|
554
|
-
ctx.ui.notify(`Unknown /subagents:agents subcommand: ${subcommand}`, "warning");
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
const agents = controller.listAgents(true);
|
|
558
|
-
ctx.ui.notify(
|
|
559
|
-
agents.length
|
|
560
|
-
? agents.map(formatLine).join("\n")
|
|
561
|
-
: statefulEmptyMessage(controller.getRuntimeStatus()),
|
|
562
|
-
"info",
|
|
563
|
-
);
|
|
564
|
-
},
|
|
565
|
-
});
|
|
566
|
-
|
|
567
551
|
return controller;
|
|
568
552
|
}
|
|
569
553
|
|
|
@@ -652,12 +636,6 @@ export function resolveSpawnContextMode(
|
|
|
652
636
|
return normalizeContextMode(value);
|
|
653
637
|
}
|
|
654
638
|
|
|
655
|
-
function statefulEmptyMessage(status: StatefulSubagentRuntimeStatus): string {
|
|
656
|
-
if (!status.enabled) return "Stateful subagents are disabled in user settings.";
|
|
657
|
-
if (!status.initialized) return "Stateful subagents are not initialized for this session.";
|
|
658
|
-
return "No current-session subagents.";
|
|
659
|
-
}
|
|
660
|
-
|
|
661
639
|
export function formatStatefulAgentLine(agent: ManagedAgent): string {
|
|
662
640
|
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - agent.updatedAt) / 1000));
|
|
663
641
|
const actions =
|