@narumitw/pi-subagents 0.31.0 → 0.33.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 CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@narumitw/pi-subagents)](https://www.npmjs.com/package/@narumitw/pi-subagents) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
4
 
5
- `@narumitw/pi-subagents` is a native [Pi coding agent](https://pi.dev) extension for delegating work to specialized agents. The blocking batch `subagent` tool keeps isolated Pi subprocesses, while four detached tools run reusable agents either as subprocess-backed logical sessions or as public-SDK in-process child sessions. Together they form a fixed five-tool surface.
5
+ `@narumitw/pi-subagents` is a native [Pi coding agent](https://pi.dev) extension for delegating work to specialized agents. By default, the blocking batch `subagent` tool uses isolated Pi subprocesses while four detached tools run reusable agents either as subprocess-backed logical sessions or as public-SDK in-process child sessions. Users can keep all five tools, choose async-only delegation, or retain only blocking batches.
6
6
 
7
7
  Use it to split independent research, planning, implementation, and review work across focused workers. Under the default next-turn delivery policy, background delegation is for work the current response does not depend on. Opt-in auto-resume also supports final-answer-dependent background work by requesting a synthesis turn after completion.
8
8
 
9
9
  ## ✨ Features
10
10
 
11
- - Registers a `subagent` tool for single-agent, parallel, fan-in, and chained delegation.
11
+ - Offers all delegation methods by default, with goal-oriented presets for async-only or blocking-only workflows.
12
12
  - Keeps batch workers isolated in `pi --mode json -p --no-session` subprocesses.
13
13
  - Registers detached stateful lifecycle tools by default; completion can stay queued for the next turn or opt into an idle root synthesis turn.
14
14
  - Supports an opt-in public-SDK `in-process` stateful transport with one reusable child `AgentSession` per `agentId`.
@@ -43,7 +43,17 @@ pi -e ./extensions/pi-subagents
43
43
 
44
44
  ## 🛠️ Pi tool
45
45
 
46
- `pi-subagents` always registers the primary batch tool and registers the stateful lifecycle tools unless `stateful.enabled` is `false`:
46
+ `pi-subagents` registers all five tools by default. Run `/subagents`, choose **Change delegation**, review the concrete tool changes, then select **Save and reload** to apply one of these workflows:
47
+
48
+ | Workflow | Registered tools |
49
+ | --- | --- |
50
+ | **All delegation methods** (default) | Blocking `subagent` plus all four detached lifecycle tools |
51
+ | **Async only** | `subagent_spawn`, `subagent_send`, `subagent_manage`, and `subagent_mailbox`; blocking `subagent` is omitted |
52
+ | **Blocking only** | Blocking `subagent` only; equivalent to the existing `stateful.enabled: false` behavior |
53
+
54
+ The preview compares the selection with the tools registered in the current session, even when a manual settings edit is pending, and remains read-only until confirmation. Escape or **Cancel** leaves settings unchanged. Tool removal requires an extension reload because Pi does not expose extension tool unregistration. To avoid aborting work or removing isolated worktrees during `session_shutdown`, workflow changes are blocked while detached agents are retained; finish or clear them through **Current agents** first. Pi owns reload-error reporting and does not return a success result to extensions, so the save notification also tells users to run `/reload` if the tool surface does not refresh.
55
+
56
+ The available tools are:
47
57
 
48
58
  - `subagent` — delegate blocking single, parallel, fan-in, or chained batch work. The main agent cannot process queued steering until the call returns.
49
59
  - `subagent_spawn` and related lifecycle tools — when enabled, start reusable detached work, return immediately, and receive bounded completion messages automatically.
@@ -52,7 +62,7 @@ Choose the API by lifecycle:
52
62
 
53
63
  | Need | Use |
54
64
  | --- | --- |
55
- | A delegated result is required before the root's next action under default next-turn delivery | One blocking `subagent` call (`tasks` for synchronous parallel work) |
65
+ | A delegated result is required before the root's next action under default next-turn delivery | Use one blocking `subagent` call when registered. In **Async only**, complete the critical-path work directly or switch workflows before delegating it |
56
66
  | Broad research/review the current response does not depend on | Prefer one `subagent_spawn` covering related branches, when lifecycle tools are enabled |
57
67
  | Final-answer-dependent broad work with `completionDelivery: "auto-resume"` | Prefer one `subagent_spawn`; completion requests a synthesis turn |
58
68
  | Reusable history, follow-ups, or mailboxes | `subagent_spawn` and lifecycle tools, when enabled |
@@ -75,7 +85,7 @@ For `subagent_spawn`, the root agent selects the lowest thinking level sufficien
75
85
 
76
86
  ## 🧭 Proactive use
77
87
 
78
- The always-available `subagent` tool advertises only blocking guidance. When stateful lifecycle tools
88
+ When registered, the blocking `subagent` tool advertises only blocking guidance. When stateful lifecycle tools
79
89
  are registered, `subagent_spawn` adds detached guidance for the active completion-delivery policy.
80
90
  Changing the policy through `/subagents settings` refreshes that guidance immediately.
81
91
 
@@ -86,8 +96,10 @@ Count-selection guidance:
86
96
  - `subagent` is deliberately blocking: while it runs, the main agent cannot answer queued steering.
87
97
  Use it when delegated outputs are required before the next root action and waiting is intentional.
88
98
  - With default `completionDelivery: "next-turn"`, prefer **one detached `subagent_spawn`** for broad
89
- research or review only when the current response does not depend on its result. Keep
90
- final-answer-dependent delegated work on the blocking path because an idle root is not awakened.
99
+ research or review only when the current response does not depend on its result. When blocking
100
+ `subagent` is registered, use it for required delegated output because an idle root is not awakened.
101
+ In **Async only**, complete required work directly, opt into `auto-resume` when a later synthesis
102
+ turn is appropriate, or switch delegation workflows.
91
103
  - With `completionDelivery: "auto-resume"`, prefer one detached `subagent_spawn` for broad related
92
104
  research or review even when the final answer depends on it; completion requests a later synthesis
93
105
  turn. Do not choose blocking parallel fan-out merely to keep delegation in one turn.
@@ -215,7 +227,7 @@ Run a chain where each step receives the previous output:
215
227
 
216
228
  Stateful lifecycle tools are available by default. `subagent_spawn` is detached: it schedules work, returns immediately with an opaque `agentId`, and later injects a bounded `pi-subagent-completion` custom message. Completions that settle in the same dispatch window are batched, and the broker allows at most one in-flight root wake until that parent turn starts.
217
229
 
218
- Detached work follows a non-polling policy. With default `next-turn` delivery, prefer one bounded `subagent_spawn` for related asynchronous research or review only when the current response does not depend on its result; if it does, use blocking `subagent`. With opt-in `auto-resume`, detached broad work may be final-answer-dependent because completion requests a synthesis turn after the root settles. In either mode, do useful non-overlapping main-agent work immediately, do not poll `subagent_manage` with `action: "list"` or `subagent_mailbox` with `action: "read"`, and do not duplicate delegated work. Add another detached agent only for truly independent work with safe workspace concurrency. Detached lifecycle work intentionally has no `subagent_wait` tool.
230
+ Detached work follows a non-polling policy. With default `next-turn` delivery, prefer one bounded `subagent_spawn` for related asynchronous research or review only when the current response does not depend on its result. If it does, use blocking `subagent` when registered; in **Async only**, complete required work directly, opt into `auto-resume` when a later synthesis turn is appropriate, or switch workflows. With opt-in `auto-resume`, detached broad work may be final-answer-dependent because completion requests a synthesis turn after the root settles. In either mode, do useful non-overlapping main-agent work immediately, do not poll `subagent_manage` with `action: "list"` or `subagent_mailbox` with `action: "read"`, and do not duplicate delegated work. Add another detached agent only for truly independent work with safe workspace concurrency. Detached lifecycle work intentionally has no `subagent_wait` tool.
219
231
 
220
232
  A detached agent additionally needs a concrete isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation. Simple work that the main agent can perform directly should not be delegated.
221
233
 
@@ -228,13 +240,17 @@ Auto-resume is best-effort because Pi's custom-message API is fire-and-forget. S
228
240
 
229
241
  The default `subprocess` transport preserves compatibility: each turn starts a fresh isolated `pi --mode json -p --no-session` child and receives sanitized, bounded history. Set `transport` to `in-process` to retain one public Pi SDK `AgentSession` per stateful `agentId`, avoiding repeated process startup while preserving native child history in memory.
230
242
 
231
- Run `/subagents` in TUI mode to open the primary manager. It separates current-session lifecycle state, transport, completion delivery, and active/retained counts from user settings that persist across sessions. Its actions open completion settings, per-agent tool settings, current-session agent inspection/clear, status, and help. Escape returns from a nested screen to a newly refreshed manager and then closes it.
243
+ Run `/subagents` in TUI mode to open the primary manager. It leads with the current delegation workflow, human-readable async completion behavior, and active/retained counts. **Change delegation**, **Current agents**, and **Completion behavior** cover the common workflows; agent permissions, transport/runtime details, source, and settings path remain under **Advanced settings**. Escape returns from a nested screen to a newly refreshed manager and then closes it.
232
244
 
233
- The direct routes remain predictable: `/subagents settings` changes user completion delivery and applies it immediately, including refreshing the model-facing spawn guidance; `/subagents status` reports current-session runtime values separately from the configured value, source, and path; `/subagents help` summarizes commands and compatibility routes. In RPC mode, bare `/subagents` emits the same bounded status through Pi's notification protocol instead of opening a custom TUI. JSON and print modes do not emit ad hoc command output. Manual edits use `~/.pi/agent/pi-subagents.json` and take effect after reloading Pi:
245
+ The direct routes remain predictable: `/subagents settings` changes user completion delivery and applies it immediately, including refreshing the model-facing spawn guidance; `/subagents status` reports current-session runtime values separately from the configured value, source, and path; `/subagents help` summarizes the single-command interface. In RPC mode, bare `/subagents` emits the same bounded status through Pi's notification protocol instead of opening a custom TUI. JSON and print modes do not emit ad hoc command output. Manual edits use `~/.pi/agent/pi-subagents.json` and take effect after reloading Pi:
234
246
 
235
247
  ```json
236
248
  {
249
+ "blocking": {
250
+ "enabled": false
251
+ },
237
252
  "stateful": {
253
+ "enabled": true,
238
254
  "transport": "in-process",
239
255
  "completionDelivery": "auto-resume",
240
256
  "maxAgents": 16,
@@ -250,7 +266,7 @@ The direct routes remain predictable: `/subagents settings` changes user complet
250
266
  }
251
267
  ```
252
268
 
253
- The settings UI patches the raw JSON atomically and preserves unknown fields; it refuses to overwrite malformed or invalid settings. Set `"enabled": false` to remove all four stateful tools. Otherwise, the extension keeps the following tool membership 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. `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.
254
270
 
255
271
  | Tool | Purpose |
256
272
  | --- | --- |
@@ -277,7 +293,7 @@ The action schemas are flat for provider compatibility and reject parameters tha
277
293
  }
278
294
  ```
279
295
 
280
- Use the **Current-session agents** action in `/subagents` to inspect the indented agent tree, lifecycle state, unread count, and available actions, or to confirm clearing retained agents. `/subagents:agents list|clear` remains the compatibility command for the same current-session operations. Active turns are FIFO-limited by `maxActiveTurns`; excess retained work remains in `starting` state until a slot is available. `maxAgents` separately bounds running, queued, and idle records. `parentId` creates a bounded child relationship; subtree interrupt and close operate child-first.
296
+ Use the **Current agents** action in `/subagents` to inspect the indented agent tree, lifecycle state, unread count, and available actions, or to confirm clearing retained agents. Active turns are FIFO-limited by `maxActiveTurns`; excess retained work remains in `starting` state until a slot is available. `maxAgents` separately bounds running, queued, and idle records. `parentId` creates a bounded child relationship; subtree interrupt and close operate child-first.
281
297
 
282
298
  ### Migrating from the seven-tool lifecycle surface
283
299
 
@@ -366,7 +382,7 @@ Built-in agents inherit the active/default Pi model instead of forcing a provide
366
382
 
367
383
  ## ⚙️ Configure agent tools
368
384
 
369
- Open `/subagents` and choose **Agent tool settings** in an interactive Pi session to edit the tools each subagent may use. `/subagents:config` remains a documented compatibility route to the same screen. These are user settings stored in `~/.pi/agent/pi-subagents.json` and affect future sessions.
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.
370
386
 
371
387
  Compatibility: a valid legacy `pi-subagents-config.json` is migrated automatically to `pi-subagents.json`. If both files exist, the new filename takes precedence.
372
388
 
@@ -491,7 +507,7 @@ The runner explicitly reports policy continuity in result details:
491
507
 
492
508
  Treat project-local agent prompts like executable project configuration: only enable them in trusted repositories. Stateful project agents require Pi's project trust; interactive use also keeps confirmation enabled by default.
493
509
 
494
- Stateful records are stored as versioned mode-0600 JSON under `~/.pi/agent/pi-subagents-state/` (or the configured Pi agent directory). Records contain sanitized logical history, never process IDs or credentials. Corrupt or unsupported state is quarantined, restored agents are always inert `idle` records, and no prior side effect is automatically resumed. Retention and count limits are configurable. Downgrading is safe: older extension versions ignore this separate state directory; use `/subagents:agents clear` before downgrade if the histories should be removed.
510
+ Stateful records are stored as versioned mode-0600 JSON under `~/.pi/agent/pi-subagents-state/` (or the configured Pi agent directory). Records contain sanitized logical history, never process IDs or credentials. Corrupt or unsupported state is quarantined, restored agents are always inert `idle` records, and no prior side effect is automatically resumed. Retention and count limits are configurable. Downgrading is safe: older extension versions ignore this separate state directory; clear **Current agents** from `/subagents` before downgrade if the histories should be removed.
495
511
 
496
512
  ## 🗂️ Package layout
497
513
 
@@ -509,7 +525,7 @@ extensions/pi-subagents/
509
525
  └── package.json
510
526
  ```
511
527
 
512
- `index.ts` is the Pi entrypoint and forwards to `subagents.ts`; the other source modules are internal. The package exposes its Pi extension through `package.json`:
528
+ `index.ts` is the Pi entrypoint and forwards to `subagents.ts`; the other source modules are internal. Workflow settings remain backward compatible: older files without `blocking.enabled` keep the five-tool default, existing `stateful.enabled: false` files remain blocking-only, and older package releases ignore the new `blocking` object. The package exposes its Pi extension through `package.json`:
513
529
 
514
530
  ```json
515
531
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/agents.ts CHANGED
@@ -41,6 +41,10 @@ export type SubagentTransportKind = "subprocess" | "in-process";
41
41
 
42
42
  export type CompletionDelivery = "next-turn" | "auto-resume";
43
43
 
44
+ export interface SubagentBlockingSettings {
45
+ enabled?: boolean;
46
+ }
47
+
44
48
  export interface SubagentRuntimeSettings {
45
49
  enabled?: boolean;
46
50
  transport?: SubagentTransportKind;
@@ -58,6 +62,7 @@ export interface SubagentRuntimeSettings {
58
62
 
59
63
  export interface SubagentSettings {
60
64
  agents?: Record<string, SubagentAgentConfig>;
65
+ blocking?: SubagentBlockingSettings;
61
66
  stateful?: SubagentRuntimeSettings;
62
67
  }
63
68
 
@@ -242,7 +247,7 @@ export function discoverAgents(
242
247
  for (const agent of projectAgents) agentMap.set(agent.name, agent);
243
248
  }
244
249
 
245
- // Apply user-configured overrides (from /subagents:config) on top of
250
+ // Apply user-configured overrides (from /subagents → Agent tool settings) on top of
246
251
  // the final resolved agent map, regardless of agent source.
247
252
  for (const [name, override] of Object.entries(config?.agents ?? {})) {
248
253
  const agent = agentMap.get(name);
package/src/config-ui.ts CHANGED
@@ -16,23 +16,27 @@ import {
16
16
  import { type CompletionDelivery, discoverAgents } from "./agents.js";
17
17
  import type { ManagedAgent } from "./registry.js";
18
18
  import {
19
+ type DelegationWorkflow,
19
20
  hasOwn,
20
21
  inspectCompletionDeliverySettings,
22
+ inspectDelegationWorkflowSettings,
21
23
  readSubagentSettings,
22
24
  sameToolSet,
23
25
  uniqueToolNames,
24
26
  updateAgentToolsSetting,
25
27
  updateCompletionDeliverySetting,
28
+ updateDelegationWorkflowSetting,
26
29
  } from "./settings.js";
27
30
  import { formatStatefulAgentLine, type StatefulSubagentRuntimeStatus } from "./stateful.js";
28
31
 
29
32
  const SUBCOMMANDS: AutocompleteItem[] = [
30
- { value: "settings", label: "settings", description: "Configure completion delivery" },
33
+ { value: "settings", label: "settings", description: "Configure completion behavior" },
31
34
  { value: "status", label: "status", description: "Show effective subagent settings" },
32
35
  { value: "help", label: "help", description: "Show subagent settings help" },
33
36
  ];
34
37
 
35
38
  export interface SubagentSettingsRuntime {
39
+ getBlockingEnabled(): boolean;
36
40
  getCompletionDelivery(): CompletionDelivery;
37
41
  setCompletionDelivery(value: CompletionDelivery): void;
38
42
  getRuntimeStatus(): StatefulSubagentRuntimeStatus;
@@ -102,17 +106,11 @@ export class ToolToggleList {
102
106
 
103
107
  export function registerSubagentConfigCommand(pi: ExtensionAPI, runtime: SubagentSettingsRuntime) {
104
108
  registerSubagentPrimaryCommand(pi, runtime);
105
- pi.registerCommand("subagents:config", {
106
- description: "Configure user tool settings for each subagent",
107
- handler: async (_args, ctx) => {
108
- await showSubagentToolSettings(pi, ctx);
109
- },
110
- });
111
109
  }
112
110
 
113
111
  async function showSubagentToolSettings(pi: ExtensionAPI, ctx: ExtensionCommandContext) {
114
112
  if (ctx.mode !== "tui") {
115
- if (ctx.hasUI) ctx.ui.notify("/subagents:config requires TUI mode", "info");
113
+ if (ctx.hasUI) ctx.ui.notify("Agent tool settings require TUI mode", "info");
116
114
  return;
117
115
  }
118
116
 
@@ -292,7 +290,8 @@ async function showSubagentToolSettings(pi: ExtensionAPI, ctx: ExtensionCommandC
292
290
  }
293
291
  }
294
292
 
295
- type ManagerAction = "settings" | "agent-tools" | "agents" | "status" | "help";
293
+ type ManagerAction = "workflow" | "agents" | "completion" | "advanced" | "help";
294
+ type AdvancedAction = "agent-tools" | "status" | "back";
296
295
  type AgentManagerAction = "back" | "clear";
297
296
 
298
297
  function registerSubagentPrimaryCommand(pi: ExtensionAPI, runtime: SubagentSettingsRuntime) {
@@ -317,7 +316,7 @@ function registerSubagentPrimaryCommand(pi: ExtensionAPI, runtime: SubagentSetti
317
316
  showSubagentStatus(ctx, runtime);
318
317
  return;
319
318
  case "help":
320
- showSubagentHelp(ctx, runtime);
319
+ showSubagentHelp(ctx);
321
320
  return;
322
321
  default:
323
322
  if (ctx.mode === "tui" || ctx.hasUI) {
@@ -341,20 +340,20 @@ async function showSubagentManager(
341
340
  const action = await selectManagerAction(ctx, runtime);
342
341
  if (!action) return;
343
342
  switch (action) {
344
- case "settings":
345
- await showSubagentSettings(ctx, runtime);
346
- break;
347
- case "agent-tools":
348
- await showSubagentToolSettings(pi, ctx);
343
+ case "workflow":
344
+ if (await showDelegationWorkflow(ctx, runtime)) return;
349
345
  break;
350
346
  case "agents":
351
347
  await showCurrentSessionAgents(ctx, runtime);
352
348
  break;
353
- case "status":
354
- showSubagentStatus(ctx, runtime);
349
+ case "completion":
350
+ await showSubagentSettings(ctx, runtime);
351
+ break;
352
+ case "advanced":
353
+ await showAdvancedSettings(pi, ctx, runtime);
355
354
  break;
356
355
  case "help":
357
- showSubagentHelp(ctx, runtime);
356
+ showSubagentHelp(ctx);
358
357
  break;
359
358
  }
360
359
  }
@@ -365,24 +364,28 @@ async function selectManagerAction(
365
364
  runtime: SubagentSettingsRuntime,
366
365
  ): Promise<ManagerAction | null> {
367
366
  const status = runtime.getRuntimeStatus();
368
- const settings = inspectCompletionDeliverySettings();
367
+ const workflow = inspectDelegationWorkflowSettings();
369
368
  const items: SelectItem[] = [
370
369
  {
371
- value: "settings",
372
- label: "Completion settings",
373
- description: "Change user completion delivery for this and future sessions",
374
- },
375
- {
376
- value: "agent-tools",
377
- label: "Agent tool settings",
378
- description: "Configure persistent per-agent tool allow-lists",
370
+ value: "workflow",
371
+ label: "Change delegation",
372
+ description: "Choose all methods, async only, or blocking only",
379
373
  },
380
374
  {
381
375
  value: "agents",
382
- label: "Current-session agents",
376
+ label: "Current agents",
383
377
  description: `${status.activeAgents} active · ${status.retainedAgents} retained`,
384
378
  },
385
- { value: "status", label: "Status", description: "Show effective runtime and settings state" },
379
+ {
380
+ value: "completion",
381
+ label: "Completion behavior",
382
+ description: "Choose whether async completion waits or resumes automatically",
383
+ },
384
+ {
385
+ value: "advanced",
386
+ label: "Advanced settings",
387
+ description: "Agent permissions, runtime details, and settings path",
388
+ },
386
389
  { value: "help", label: "Help", description: "Show commands and manual configuration" },
387
390
  ];
388
391
  return ctx.ui.custom<ManagerAction | null>((tui, theme, _keybindings, done) => {
@@ -390,7 +393,9 @@ async function selectManagerAction(
390
393
  container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
391
394
  container.addChild(new Text(theme.fg("accent", theme.bold("Subagents")), 1, 0));
392
395
  container.addChild(new Spacer(1));
393
- container.addChild(new Text(theme.fg("muted", formatManagerSummary(status, settings)), 1, 0));
396
+ container.addChild(
397
+ new Text(theme.fg("muted", formatManagerSummary(runtime, status, workflow)), 1, 0),
398
+ );
394
399
  container.addChild(new Spacer(1));
395
400
  const selectList = new SelectList(items, Math.min(items.length + 2, 15), {
396
401
  selectedPrefix: (text: string) => theme.fg("accent", text),
@@ -415,6 +420,257 @@ async function selectManagerAction(
415
420
  });
416
421
  }
417
422
 
423
+ async function showDelegationWorkflow(
424
+ ctx: ExtensionCommandContext,
425
+ runtime: SubagentSettingsRuntime,
426
+ ): Promise<boolean> {
427
+ const snapshot = inspectDelegationWorkflowSettings();
428
+ if (ctx.mode !== "tui") {
429
+ if (ctx.hasUI) {
430
+ ctx.ui.notify(
431
+ `Edit delegation settings manually: ${safeTerminalText(snapshot.path)}`,
432
+ "info",
433
+ );
434
+ }
435
+ return false;
436
+ }
437
+ if (snapshot.error) {
438
+ ctx.ui.notify(
439
+ `Delegation settings cannot be edited: ${safeTerminalText(snapshot.error)}. Repair ${safeTerminalText(snapshot.path)} and retry.`,
440
+ "error",
441
+ );
442
+ return false;
443
+ }
444
+ const activeWorkflow = currentWorkflow(runtime, runtime.getRuntimeStatus());
445
+ const choices: SelectItem[] = [
446
+ {
447
+ value: "all",
448
+ label: "All delegation methods",
449
+ description: "Allow blocking batches and reusable async agents",
450
+ },
451
+ {
452
+ value: "async-only",
453
+ label: "Async only",
454
+ description: "Keep the root responsive; remove blocking subagent",
455
+ },
456
+ {
457
+ value: "blocking-only",
458
+ label: "Blocking only",
459
+ description: "Keep blocking batches; remove reusable async agents",
460
+ },
461
+ ];
462
+ const selected = await ctx.ui.custom<DelegationWorkflow | null>(
463
+ (tui, theme, _keybindings, done) => {
464
+ const container = new Container();
465
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
466
+ container.addChild(new Text(theme.fg("accent", theme.bold("Change Delegation")), 1, 0));
467
+ container.addChild(
468
+ new Text(
469
+ theme.fg(
470
+ "muted",
471
+ [
472
+ `Current: ${workflowLabel(activeWorkflow)}`,
473
+ ...(snapshot.value !== activeWorkflow
474
+ ? [`Configured after reload: ${workflowLabel(snapshot.value)}`]
475
+ : []),
476
+ ].join("\n"),
477
+ ),
478
+ 1,
479
+ 0,
480
+ ),
481
+ );
482
+ container.addChild(new Spacer(1));
483
+ const selectList = new SelectList(choices, Math.min(choices.length + 2, 10), {
484
+ selectedPrefix: (text: string) => theme.fg("accent", text),
485
+ selectedText: (text: string) => theme.fg("accent", text),
486
+ description: (text: string) => theme.fg("muted", text),
487
+ scrollInfo: (text: string) => theme.fg("dim", text),
488
+ noMatch: (text: string) => theme.fg("warning", text),
489
+ });
490
+ selectList.setSelectedIndex(
491
+ Math.max(
492
+ 0,
493
+ choices.findIndex((item) => item.value === snapshot.value),
494
+ ),
495
+ );
496
+ selectList.onSelect = (item) => done(item.value as DelegationWorkflow);
497
+ selectList.onCancel = () => done(null);
498
+ container.addChild(selectList);
499
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate · enter preview · esc back"), 1, 0));
500
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
501
+ return {
502
+ render: (width: number) => container.render(width),
503
+ invalidate: () => container.invalidate(),
504
+ handleInput(data: string) {
505
+ selectList.handleInput(data);
506
+ tui.requestRender();
507
+ },
508
+ };
509
+ },
510
+ );
511
+ if (!selected) return false;
512
+ if (selected === activeWorkflow && selected === snapshot.value) {
513
+ ctx.ui.notify(`Delegation already uses ${workflowLabel(selected)}.`, "info");
514
+ return false;
515
+ }
516
+ const requiresReload = selected !== activeWorkflow;
517
+ if (requiresReload && blockReloadWithRetainedAgents(ctx, runtime)) return false;
518
+
519
+ const confirmed = await showWorkflowPreview(ctx, activeWorkflow, selected, requiresReload);
520
+ if (!confirmed) return false;
521
+ if (requiresReload && blockReloadWithRetainedAgents(ctx, runtime)) return false;
522
+ try {
523
+ updateDelegationWorkflowSetting(selected as Exclude<DelegationWorkflow, "disabled">);
524
+ } catch (error) {
525
+ ctx.ui.notify(
526
+ `Delegation settings were not saved: ${formatError(error)}. The current workflow is unchanged.`,
527
+ "error",
528
+ );
529
+ return false;
530
+ }
531
+ if (!requiresReload) {
532
+ ctx.ui.notify(
533
+ `Saved ${workflowLabel(selected)}. The current tool surface already matches.`,
534
+ "info",
535
+ );
536
+ return false;
537
+ }
538
+ ctx.ui.notify(
539
+ `Saved ${workflowLabel(selected)}. Reloading subagent tools… If the tool surface does not refresh, run /reload.`,
540
+ "info",
541
+ );
542
+ await ctx.reload();
543
+ return true;
544
+ }
545
+
546
+ function blockReloadWithRetainedAgents(
547
+ ctx: ExtensionCommandContext,
548
+ runtime: SubagentSettingsRuntime,
549
+ ): boolean {
550
+ const status = runtime.getRuntimeStatus();
551
+ if (status.retainedAgents === 0) return false;
552
+ ctx.ui.notify(
553
+ `Cannot reload while ${status.retainedAgents} detached subagent${status.retainedAgents === 1 ? " is" : "s are"} retained (${status.activeAgents} active). Open Current agents and clear them after their work is safe to discard, then change delegation.`,
554
+ "warning",
555
+ );
556
+ return true;
557
+ }
558
+
559
+ async function showWorkflowPreview(
560
+ ctx: ExtensionCommandContext,
561
+ current: DelegationWorkflow,
562
+ next: DelegationWorkflow,
563
+ requiresReload: boolean,
564
+ ): Promise<boolean> {
565
+ const workflowChanges = workflowEffects(current, next);
566
+ const effects = (
567
+ workflowChanges.length > 0
568
+ ? workflowChanges
569
+ : ["Keep the current registered tools and cancel the pending workflow change"]
570
+ )
571
+ .map((effect) => `- ${effect}`)
572
+ .join("\n");
573
+ return ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
574
+ const container = new Container();
575
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
576
+ container.addChild(new Text(theme.fg("accent", theme.bold("Review Delegation Change")), 1, 0));
577
+ container.addChild(
578
+ new Text(
579
+ theme.fg(
580
+ "muted",
581
+ `Current: ${workflowLabel(current)}\nNew: ${workflowLabel(next)}\n\nEffect:\n${effects}\n- ${requiresReload ? "Reload the extension to apply this tool surface" : "No reload is needed because the active tools already match"}`,
582
+ ),
583
+ 1,
584
+ 0,
585
+ ),
586
+ );
587
+ container.addChild(new Spacer(1));
588
+ const actions: SelectItem[] = [
589
+ {
590
+ value: "save",
591
+ label: requiresReload ? "Save and reload" : "Save",
592
+ description: requiresReload
593
+ ? "Persist and apply this workflow"
594
+ : "Persist the workflow that is already active",
595
+ },
596
+ { value: "cancel", label: "Cancel", description: "Leave settings and tools unchanged" },
597
+ ];
598
+ const selectList = new SelectList(actions, 4, {
599
+ selectedPrefix: (text: string) => theme.fg("accent", text),
600
+ selectedText: (text: string) => theme.fg("accent", text),
601
+ description: (text: string) => theme.fg("muted", text),
602
+ scrollInfo: (text: string) => theme.fg("dim", text),
603
+ noMatch: (text: string) => theme.fg("warning", text),
604
+ });
605
+ selectList.onSelect = (item) => done(item.value === "save");
606
+ selectList.onCancel = () => done(false);
607
+ container.addChild(selectList);
608
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate · enter choose · esc cancel"), 1, 0));
609
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
610
+ return {
611
+ render: (width: number) => container.render(width),
612
+ invalidate: () => container.invalidate(),
613
+ handleInput(data: string) {
614
+ selectList.handleInput(data);
615
+ tui.requestRender();
616
+ },
617
+ };
618
+ });
619
+ }
620
+
621
+ async function showAdvancedSettings(
622
+ pi: ExtensionAPI,
623
+ ctx: ExtensionCommandContext,
624
+ runtime: SubagentSettingsRuntime,
625
+ ) {
626
+ while (true) {
627
+ const action = await ctx.ui.custom<AdvancedAction | null>((tui, theme, _keybindings, done) => {
628
+ const container = new Container();
629
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
630
+ container.addChild(
631
+ new Text(theme.fg("accent", theme.bold("Advanced Subagent Settings")), 1, 0),
632
+ );
633
+ container.addChild(new Spacer(1));
634
+ const items: SelectItem[] = [
635
+ {
636
+ value: "agent-tools",
637
+ label: "Agent tool permissions",
638
+ description: "Customize persistent per-agent tool allow-lists",
639
+ },
640
+ {
641
+ value: "status",
642
+ label: "Runtime details",
643
+ description: "Show transport, configured source, and settings path",
644
+ },
645
+ { value: "back", label: "Back", description: "Return to the Subagents manager" },
646
+ ];
647
+ const selectList = new SelectList(items, 5, {
648
+ selectedPrefix: (text: string) => theme.fg("accent", text),
649
+ selectedText: (text: string) => theme.fg("accent", text),
650
+ description: (text: string) => theme.fg("muted", text),
651
+ scrollInfo: (text: string) => theme.fg("dim", text),
652
+ noMatch: (text: string) => theme.fg("warning", text),
653
+ });
654
+ selectList.onSelect = (item) => done(item.value as AdvancedAction);
655
+ selectList.onCancel = () => done(null);
656
+ container.addChild(selectList);
657
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate · enter select · esc back"), 1, 0));
658
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
659
+ return {
660
+ render: (width: number) => container.render(width),
661
+ invalidate: () => container.invalidate(),
662
+ handleInput(data: string) {
663
+ selectList.handleInput(data);
664
+ tui.requestRender();
665
+ },
666
+ };
667
+ });
668
+ if (!action || action === "back") return;
669
+ if (action === "agent-tools") await showSubagentToolSettings(pi, ctx);
670
+ else showSubagentStatus(ctx, runtime);
671
+ }
672
+ }
673
+
418
674
  async function showCurrentSessionAgents(
419
675
  ctx: ExtensionCommandContext,
420
676
  runtime: SubagentSettingsRuntime,
@@ -516,11 +772,11 @@ async function showSubagentSettings(
516
772
  const items: SettingItem[] = [
517
773
  {
518
774
  id: "completionDelivery",
519
- label: "Completion delivery",
775
+ label: "When async work finishes",
520
776
  description:
521
- "User setting applied now and to future sessions. next-turn queues completion; auto-resume requests synthesis after settlement.",
522
- currentValue,
523
- values: ["next-turn", "auto-resume"],
777
+ "Wait for your next turn, or request one synthesis turn after the root settles.",
778
+ currentValue: completionLabel(currentValue),
779
+ values: ["Wait until my next turn", "Resume automatically when finished"],
524
780
  },
525
781
  ];
526
782
  const container = new Container();
@@ -541,17 +797,15 @@ async function showSubagentSettings(
541
797
  (id, newValue) => {
542
798
  if (id !== "completionDelivery") return;
543
799
  const previous = currentValue;
544
- const next = newValue as CompletionDelivery;
800
+ const next: CompletionDelivery =
801
+ newValue === "Resume automatically when finished" ? "auto-resume" : "next-turn";
545
802
  try {
546
803
  updateCompletionDeliverySetting(next);
547
804
  runtime.setCompletionDelivery(next);
548
805
  currentValue = next;
549
- ctx.ui.notify(
550
- `User completion delivery set to ${next} for this and future sessions.`,
551
- "info",
552
- );
806
+ ctx.ui.notify(`Saved and applied: ${completionLabel(next)}.`, "info");
553
807
  } catch (error) {
554
- settingsList.updateValue(id, previous);
808
+ settingsList.updateValue(id, completionLabel(previous));
555
809
  ctx.ui.notify(`Subagent settings were not saved: ${formatError(error)}`, "error");
556
810
  }
557
811
  tui.requestRender();
@@ -574,25 +828,20 @@ function showSubagentStatus(ctx: ExtensionCommandContext, runtime: SubagentSetti
574
828
  if (ctx.mode !== "tui" && !ctx.hasUI) return;
575
829
  const snapshot = inspectCompletionDeliverySettings();
576
830
  ctx.ui.notify(
577
- formatStatus(runtime.getRuntimeStatus(), snapshot),
831
+ formatStatus(runtime.getRuntimeStatus(), snapshot, runtime),
578
832
  snapshot.error ? "warning" : "info",
579
833
  );
580
834
  }
581
835
 
582
- function showSubagentHelp(ctx: ExtensionCommandContext, runtime: SubagentSettingsRuntime) {
836
+ function showSubagentHelp(ctx: ExtensionCommandContext) {
583
837
  if (ctx.mode !== "tui" && !ctx.hasUI) return;
584
838
  const snapshot = inspectCompletionDeliverySettings();
585
- const runtimeStatus = runtime.getRuntimeStatus();
586
839
  ctx.ui.notify(
587
840
  [
588
- "/subagents — open the current-session manager",
589
- "/subagents settings — configure user completion delivery",
841
+ "/subagents — choose delegation workflow, manage current agents, and configure agent tools",
842
+ "/subagents settings — configure async completion behavior",
590
843
  "/subagents status — show current-session and user-setting values",
591
844
  "/subagents help — show this help",
592
- "/subagents:config — compatibility route for per-agent user tool settings",
593
- runtimeStatus.enabled
594
- ? "/subagents:agents list|clear — compatibility route for current-session agents"
595
- : "/subagents:agents — unavailable while stateful lifecycle tools are disabled",
596
845
  `User settings: ${safeTerminalText(snapshot.path)}`,
597
846
  ].join("\n"),
598
847
  "info",
@@ -600,41 +849,48 @@ function showSubagentHelp(ctx: ExtensionCommandContext, runtime: SubagentSetting
600
849
  }
601
850
 
602
851
  function formatManagerSummary(
852
+ runtime: SubagentSettingsRuntime,
603
853
  status: StatefulSubagentRuntimeStatus,
604
- settings: ReturnType<typeof inspectCompletionDeliverySettings>,
854
+ configured: ReturnType<typeof inspectDelegationWorkflowSettings>,
605
855
  ): string {
856
+ const current = currentWorkflow(runtime, status);
606
857
  return [
607
- "Current session",
608
- `Lifecycle: ${status.enabled ? "enabled" : "disabled"}${status.initialized ? " · initialized" : " · not initialized"}`,
609
- `Transport: ${status.transport}`,
610
- `Completion delivery: ${status.completionDelivery}`,
858
+ `Delegation: ${workflowLabel(current)}`,
859
+ `Completion: ${completionLabel(status.completionDelivery)}`,
611
860
  `Agents: ${status.activeAgents} active · ${status.retainedAgents} retained`,
612
- "",
613
- "User settings",
614
- `Completion source: ${settings.source}`,
615
- `Path: ${safeTerminalText(settings.path)}`,
616
- ...(settings.error ? [`Warning: ${safeTerminalText(settings.error)}`] : []),
861
+ ...(configured.value !== current
862
+ ? [`Configured after reload: ${workflowLabel(configured.value)}`]
863
+ : []),
864
+ ...(configured.error ? ["Settings need repair; open Advanced settings for details."] : []),
617
865
  ].join("\n");
618
866
  }
619
867
 
620
868
  function formatStatus(
621
869
  status: StatefulSubagentRuntimeStatus,
622
870
  snapshot: ReturnType<typeof inspectCompletionDeliverySettings>,
871
+ runtime?: SubagentSettingsRuntime,
623
872
  ): string {
873
+ const configuredWorkflow = inspectDelegationWorkflowSettings();
874
+ const current = runtime ? currentWorkflow(runtime, status) : configuredWorkflow.value;
624
875
  return [
625
876
  "Current session",
626
- ` Lifecycle: ${status.enabled ? "enabled" : "disabled"}`,
627
- ` Runtime: ${status.initialized ? "initialized" : "not initialized"}`,
877
+ ` Delegation: ${workflowLabel(current)}`,
878
+ ` Async runtime: ${status.initialized ? "initialized" : status.enabled ? "not initialized" : "disabled"}`,
628
879
  ` Transport: ${status.transport}`,
629
- ` Completion delivery: ${status.completionDelivery}`,
880
+ ` Completion: ${completionLabel(status.completionDelivery)}`,
630
881
  ` Agents: ${status.activeAgents} active, ${status.retainedAgents} retained`,
631
882
  "User settings",
883
+ ` Delegation source: ${configuredWorkflow.source}`,
884
+ ` Configured delegation: ${workflowLabel(configuredWorkflow.value)}`,
632
885
  ` Completion source: ${snapshot.source}`,
886
+ ` Configured completion: ${completionLabel(snapshot.value)}`,
633
887
  ` Path: ${safeTerminalText(snapshot.path)}`,
634
- ` Configured completion delivery: ${snapshot.value}`,
635
- snapshot.error ? ` Warning: ${safeTerminalText(snapshot.error)}` : " Warning: none",
636
- "User settings persist for future sessions; /subagents settings also applies changes now.",
637
- "Manual file changes require /reload.",
888
+ configuredWorkflow.error || snapshot.error
889
+ ? ` Warning: ${safeTerminalText(configuredWorkflow.error ?? snapshot.error ?? "invalid settings")}`
890
+ : " Warning: none",
891
+ configuredWorkflow.value !== current
892
+ ? "Configured delegation differs from this session. Run /reload to apply it."
893
+ : "Manual file changes require /reload.",
638
894
  ].join("\n");
639
895
  }
640
896
 
@@ -644,6 +900,52 @@ function formatEmptyRuntime(status: StatefulSubagentRuntimeStatus): string {
644
900
  return "No current-session subagents.";
645
901
  }
646
902
 
903
+ function currentWorkflow(
904
+ runtime: SubagentSettingsRuntime,
905
+ status: StatefulSubagentRuntimeStatus,
906
+ ): DelegationWorkflow {
907
+ const blocking = runtime.getBlockingEnabled();
908
+ if (blocking && status.enabled) return "all";
909
+ if (status.enabled) return "async-only";
910
+ if (blocking) return "blocking-only";
911
+ return "disabled";
912
+ }
913
+
914
+ function workflowLabel(value: DelegationWorkflow): string {
915
+ switch (value) {
916
+ case "all":
917
+ return "All delegation methods";
918
+ case "async-only":
919
+ return "Async only";
920
+ case "blocking-only":
921
+ return "Blocking only";
922
+ case "disabled":
923
+ return "Delegation disabled";
924
+ }
925
+ }
926
+
927
+ function completionLabel(value: CompletionDelivery): string {
928
+ return value === "auto-resume" ? "Resume automatically when finished" : "Wait until my next turn";
929
+ }
930
+
931
+ function workflowEffects(current: DelegationWorkflow, next: DelegationWorkflow): string[] {
932
+ const blockingEnabled = (value: DelegationWorkflow) =>
933
+ value === "all" || value === "blocking-only";
934
+ const asyncEnabled = (value: DelegationWorkflow) => value === "all" || value === "async-only";
935
+ const effects: string[] = [];
936
+ if (blockingEnabled(current) !== blockingEnabled(next)) {
937
+ effects.push(blockingEnabled(next) ? "Add blocking `subagent`" : "Remove blocking `subagent`");
938
+ }
939
+ if (asyncEnabled(current) !== asyncEnabled(next)) {
940
+ effects.push(
941
+ asyncEnabled(next)
942
+ ? "Add reusable async lifecycle tools"
943
+ : "Remove reusable async lifecycle tools",
944
+ );
945
+ }
946
+ return effects;
947
+ }
948
+
647
949
  function safeTerminalText(value: string): string {
648
950
  // biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
649
951
  return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "?");
package/src/settings.ts CHANGED
@@ -80,6 +80,15 @@ export function normalizeSubagentSettings(value: unknown): SubagentSettings | un
80
80
  }
81
81
  if (Object.keys(agents).length > 0) settings.agents = agents;
82
82
  }
83
+ if (hasOwn(value, "blocking")) {
84
+ if (!isPlainObject(value.blocking)) return undefined;
85
+ const blocking: NonNullable<SubagentSettings["blocking"]> = {};
86
+ if (hasOwn(value.blocking, "enabled")) {
87
+ if (typeof value.blocking.enabled !== "boolean") return undefined;
88
+ blocking.enabled = value.blocking.enabled;
89
+ }
90
+ settings.blocking = blocking;
91
+ }
83
92
  if (hasOwn(value, "stateful")) {
84
93
  if (!isPlainObject(value.stateful)) return undefined;
85
94
  const runtime: NonNullable<SubagentSettings["stateful"]> = {};
@@ -241,6 +250,15 @@ export function saveSubagentConfig(settings: SubagentSettings): void {
241
250
  writeSettingsObject(settings);
242
251
  }
243
252
 
253
+ export type DelegationWorkflow = "all" | "async-only" | "blocking-only" | "disabled";
254
+
255
+ export interface DelegationWorkflowSettingsSnapshot {
256
+ path: string;
257
+ value: DelegationWorkflow;
258
+ source: "default" | "user settings";
259
+ error?: string;
260
+ }
261
+
244
262
  export interface CompletionDeliverySettingsSnapshot {
245
263
  path: string;
246
264
  value: CompletionDelivery;
@@ -252,6 +270,46 @@ export function subagentSettingsFilePath(): string {
252
270
  return path.join(getAgentDir(), SETTINGS_FILE);
253
271
  }
254
272
 
273
+ export function resolveDelegationWorkflow(
274
+ blockingEnabled: boolean,
275
+ statefulEnabled: boolean,
276
+ ): DelegationWorkflow {
277
+ if (blockingEnabled && statefulEnabled) return "all";
278
+ if (statefulEnabled) return "async-only";
279
+ if (blockingEnabled) return "blocking-only";
280
+ return "disabled";
281
+ }
282
+
283
+ export function inspectDelegationWorkflowSettings(): DelegationWorkflowSettingsSnapshot {
284
+ const configPath = subagentSettingsFilePath();
285
+ if (!fs.existsSync(configPath)) {
286
+ return { path: configPath, value: "all", source: "default" };
287
+ }
288
+ try {
289
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
290
+ const settings = normalizeSubagentSettings(raw);
291
+ if (!settings) throw new Error(`${SETTINGS_FILE} is not a valid settings object`);
292
+ const explicit =
293
+ (isPlainObject(raw.blocking) && hasOwn(raw.blocking, "enabled")) ||
294
+ (isPlainObject(raw.stateful) && hasOwn(raw.stateful, "enabled"));
295
+ return {
296
+ path: configPath,
297
+ value: resolveDelegationWorkflow(
298
+ settings.blocking?.enabled !== false,
299
+ settings.stateful?.enabled !== false,
300
+ ),
301
+ source: explicit ? "user settings" : "default",
302
+ };
303
+ } catch (error) {
304
+ return {
305
+ path: configPath,
306
+ value: "all",
307
+ source: "default",
308
+ error: formatError(error),
309
+ };
310
+ }
311
+ }
312
+
255
313
  export function inspectCompletionDeliverySettings(): CompletionDeliverySettingsSnapshot {
256
314
  const configPath = subagentSettingsFilePath();
257
315
  if (!fs.existsSync(configPath)) {
@@ -277,6 +335,31 @@ export function inspectCompletionDeliverySettings(): CompletionDeliverySettingsS
277
335
  }
278
336
  }
279
337
 
338
+ export function updateDelegationWorkflowSetting(
339
+ value: Exclude<DelegationWorkflow, "disabled">,
340
+ ): void {
341
+ const raw = readSettingsObjectForUpdate();
342
+ const blocking = raw.blocking;
343
+ if (blocking !== undefined && !isPlainObject(blocking)) {
344
+ throw new Error(`Cannot update invalid ${SETTINGS_FILE} blocking settings`);
345
+ }
346
+ const stateful = raw.stateful;
347
+ if (stateful !== undefined && !isPlainObject(stateful)) {
348
+ throw new Error(`Cannot update invalid ${SETTINGS_FILE} stateful settings`);
349
+ }
350
+ writeSettingsObject({
351
+ ...raw,
352
+ blocking: {
353
+ ...(blocking ?? {}),
354
+ enabled: value !== "async-only",
355
+ },
356
+ stateful: {
357
+ ...(stateful ?? {}),
358
+ enabled: value !== "blocking-only",
359
+ },
360
+ });
361
+ }
362
+
280
363
  export function updateCompletionDeliverySetting(value: CompletionDelivery): void {
281
364
  const raw = readSettingsObjectForUpdate();
282
365
  const stateful = raw.stateful;
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 =
package/src/subagents.ts CHANGED
@@ -22,6 +22,33 @@ import { consumeSubagentSettingsNotice, readSubagentSettings } from "./settings.
22
22
  import { registerStatefulSubagents } from "./stateful.js";
23
23
 
24
24
  export default function (pi: ExtensionAPI) {
25
+ const settings = readSubagentSettings();
26
+ if (settings?.blocking?.enabled !== false) registerBlockingSubagent(pi);
27
+
28
+ pi.on("session_start", (_event, ctx) => {
29
+ // Preserve a one-shot migration notice from extension load while refreshing
30
+ // validation against settings that may have changed before this session.
31
+ const loadNotice = consumeSubagentSettingsNotice();
32
+ readSubagentSettings();
33
+ const refreshedNotice = consumeSubagentSettingsNotice();
34
+ const notice = [
35
+ ...new Set([loadNotice, refreshedNotice].filter((value) => value !== undefined)),
36
+ ].join("\n");
37
+ if (notice) ctx.ui.notify(notice, "warning");
38
+ });
39
+
40
+ const blockingEnabled = settings?.blocking?.enabled !== false;
41
+ const statefulRuntime = registerStatefulSubagents(pi, {
42
+ blockingEnabled,
43
+ settings: settings?.stateful,
44
+ });
45
+ registerSubagentConfigCommand(pi, {
46
+ ...statefulRuntime,
47
+ getBlockingEnabled: () => blockingEnabled,
48
+ });
49
+ }
50
+
51
+ function registerBlockingSubagent(pi: ExtensionAPI) {
25
52
  pi.registerTool<typeof SubagentParams, SubagentDetails>({
26
53
  name: "subagent",
27
54
  label: "Blocking Subagent",
@@ -65,24 +92,14 @@ export default function (pi: ExtensionAPI) {
65
92
  if ((event.details as (SubagentDetails & { isError?: boolean }) | undefined)?.isError)
66
93
  return { isError: true };
67
94
  });
68
-
69
- pi.on("session_start", (_event, ctx) => {
70
- let notice = consumeSubagentSettingsNotice();
71
- if (!notice) {
72
- readSubagentSettings();
73
- notice = consumeSubagentSettingsNotice();
74
- }
75
- if (notice) ctx.ui.notify(notice, "warning");
76
- });
77
-
78
- const statefulRuntime = registerStatefulSubagents(pi);
79
- registerSubagentConfigCommand(pi, statefulRuntime);
80
95
  }
96
+
81
97
  export { parsePositiveInteger } from "./execution.js";
82
98
  export { formatTokens, formatUsageStats } from "./render.js";
83
99
  export { buildPiArgs } from "./runner.js";
84
100
  export {
85
101
  inspectCompletionDeliverySettings,
102
+ inspectDelegationWorkflowSettings,
86
103
  normalizeAgentSettings,
87
104
  normalizeSubagentSettings,
88
105
  readSubagentSettings,
@@ -93,4 +110,5 @@ export {
93
110
  uniqueToolNames,
94
111
  updateAgentToolsSetting,
95
112
  updateCompletionDeliverySetting,
113
+ updateDelegationWorkflowSetting,
96
114
  } from "./settings.js";