@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/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
- export function readSubagentSettings(): SubagentSettings | undefined {
138
- pendingSettingsNotice = undefined;
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
- 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) {
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 (!fs.existsSync(legacyPath)) return undefined;
152
- const legacySnapshot = readSettingsSnapshot(legacyPath);
153
- 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
+ }
154
200
  if (!legacy) {
155
201
  pendingSettingsNotice = `${LEGACY_SETTINGS_FILE} is invalid and was ignored.`;
156
202
  return undefined;
157
203
  }
158
- let installedIdentity: FileIdentity;
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 inspectCompletionDeliverySettings(): CompletionDeliverySettingsSnapshot {
256
- const configPath = subagentSettingsFilePath();
257
- if (!fs.existsSync(configPath)) {
258
- return { path: configPath, value: DEFAULT_COMPLETION_DELIVERY, source: "default" };
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 (!settings) throw new Error(`${SETTINGS_FILE} is not a valid settings object`);
264
- const explicit = isPlainObject(raw.stateful) && hasOwn(raw.stateful, "completionDelivery");
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: configPath,
267
- value: settings.stateful?.completionDelivery ?? DEFAULT_COMPLETION_DELIVERY,
268
- source: explicit ? "user settings" : "default",
284
+ path: inspected.path,
285
+ value: "all",
286
+ source: "default",
287
+ ...(inspected.error ? { error: inspected.error } : {}),
269
288
  };
270
- } catch (error) {
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: configPath,
307
+ path: inspected.path,
273
308
  value: DEFAULT_COMPLETION_DELIVERY,
274
309
  source: "default",
275
- error: formatError(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
- const raw = readSettingsObjectForUpdate();
282
- const stateful = raw.stateful;
283
- if (stateful !== undefined && !isPlainObject(stateful)) {
284
- throw new Error(`Cannot update invalid ${SETTINGS_FILE} stateful settings`);
285
- }
286
- writeSettingsObject({
287
- ...raw,
288
- stateful: {
289
- ...(stateful ?? {}),
290
- completionDelivery: value,
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
- const raw = readSettingsObjectForUpdate();
297
- const rawAgents = raw.agents;
298
- if (rawAgents !== undefined && !isPlainObject(rawAgents)) {
299
- throw new Error(`Cannot update invalid ${SETTINGS_FILE} agent settings`);
300
- }
301
- const agents = { ...(rawAgents ?? {}) };
302
- const rawAgent = hasOwn(agents, name) ? agents[name] : undefined;
303
- if (rawAgent !== undefined && !isPlainObject(rawAgent)) {
304
- throw new Error(`Cannot update invalid ${SETTINGS_FILE} settings for ${name}`);
305
- }
306
- const agent = { ...(rawAgent ?? {}) };
307
- if (tools === undefined) delete agent.tools;
308
- else agent.tools = tools;
309
- if (Object.keys(agent).length > 0) {
310
- Object.defineProperty(agents, name, {
311
- value: agent,
312
- enumerable: true,
313
- configurable: true,
314
- writable: true,
315
- });
316
- } else {
317
- delete agents[name];
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
- const updated = { ...raw };
321
- if (Object.keys(agents).length > 0) updated.agents = agents;
322
- else delete updated.agents;
323
- writeSettingsObject(updated);
408
+ interface SettingsObjectForUpdate {
409
+ document: Record<string, unknown>;
410
+ replaceCanonical: boolean;
324
411
  }
325
412
 
326
- function readSettingsObjectForUpdate(): Record<string, unknown> {
327
- const configPath = subagentSettingsFilePath();
328
- 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);
329
417
  let parsed: unknown;
330
418
  try {
331
- parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
419
+ parsed = JSON.parse(fs.readFileSync(activePath, "utf8"));
332
420
  } catch (error) {
333
- throw new Error(`Cannot update malformed ${SETTINGS_FILE}: ${formatError(error)}`);
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 ${SETTINGS_FILE}`);
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 writeSettingsObject(settings: object): void {
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(completionDelivery: CompletionDelivery): string[] {
56
+ function createSpawnPromptGuidelines(
57
+ completionDelivery: CompletionDelivery,
58
+ blockingEnabled = true,
59
+ ): string[] {
56
60
  const deliveryGuidance =
57
61
  completionDelivery === "auto-resume"
58
- ? "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."
59
- : "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.";
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
- "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.",
70
- "When subagent_spawn fits the completion-delivery policy, do not choose a blocking parallel subagent merely to keep delegation in the same turn.",
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 = readSubagentSettings()?.stateful ?? {};
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 =