@ferris1225/pi-subagents 4.3.8 → 4.3.9

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/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ Published versions of `@ferris1225/pi-subagents`. Unpublished numbers
4
4
  (`4.2.3`, `4.2.6`, `4.2.9`–`4.2.11`) never shipped on npm; their changes
5
5
  landed in the next published release.
6
6
 
7
+ ## 4.3.9
8
+
9
+ - Add optional bounded stable `phaseId` and exact declarative write `scope` claims to single
10
+ and parallel dispatches. Phase identity is immutable across task rewrites and resume; scope
11
+ is monotonic across retained generations and survives durable v1 restore. Exact task+cwd
12
+ remains the compatibility fallback.
13
+ - Reject deterministic duplicates and declared writer-scope conflicts before parallel batch
14
+ allocation. Fresh single and resumed writers also reject normalized absolute scope overlap
15
+ with active leases, without requiring equal caller cwd. Parallel calls that omit scope remain
16
+ compatible and explicitly report `independence not verified`; declared claims do not prove
17
+ natural-language task independence. Scope is conflict metadata, not permissions or a sandbox.
18
+ - Add the advisory-only `subagent_risk` tool. Without a model call it resolves the repository
19
+ root, reads root-relative tracked and untracked changes from `HEAD`, and applies fixed
20
+ explainable rules for concurrency, trust-boundary, persistence-compatibility, and
21
+ failure-cancellation risk. It propagates cancellation, and suggests but never dispatches or
22
+ requires a Sentinel review.
23
+
7
24
  ## 4.3.8
8
25
 
9
26
  - Restore `sentinel` as an optional fresh-context reviewer instead of the mandatory
package/README.md CHANGED
@@ -12,12 +12,13 @@ once and your main agent delegates on its own.
12
12
 
13
13
  ## What's new
14
14
 
15
- **4.3.8** — `sentinel` is back as an optional fresh-context reviewer. It reads a
16
- completed diff with no memory of how it was written and returns only evidence-backed
17
- defects and test gaps; the main agent dispatches it for diffs that touch concurrency,
18
- trust boundaries, persistence, or failure paths, never as a fixed pre-commit ritual, and
19
- routes a finding back to the thread that owns the change. Configs written by
20
- 4.3.5–4.3.7 adopt it once; a deliberate disable sticks.
15
+ **4.3.9** — dispatch admission now accepts bounded stable `phaseId` identities and exact
16
+ declarative write `scope` claims. Fresh/resumed writers are checked against active leases;
17
+ parallel batches preflight deterministic duplicates and all declared scope conflicts before
18
+ allocation. Omitted scopes remain compatible; a parallel call that omits `scope` reports
19
+ `independence not verified`. Declared claims do not prove natural-language task independence.
20
+ The no-model-call `subagent_risk` advisory classifies changed tracked and untracked paths
21
+ with fixed, documented rules before main decides whether a Sentinel review is worthwhile.
21
22
 
22
23
  See [CHANGELOG.md](./CHANGELOG.md).
23
24
 
@@ -50,9 +51,10 @@ back — with you. This extension owns them:
50
51
  handoff costs. Every brief carries the objective and done condition, exact paths,
51
52
  facts already established with citations, boundaries, and the expected output, so a
52
53
  child starts from evidence instead of re-deriving it.
53
- - One normalized task and working directory owns its phase: an exact duplicate of an
54
- active run is rejected, and an exact re-run of a finished brief with retained
55
- context is rejected in favor of resuming it, so the same work is never bought twice.
54
+ - A stable `phaseId` owns a logical phase in one resolved working directory even if
55
+ its task wording changes. IDs are 1–80 ASCII letters, numbers, or `._:-`, starting
56
+ with a letter or number, so lease output stays single-line. Exact normalized task+cwd
57
+ remains the backward-compatible fallback for old calls.
56
58
  - Follow-up work stays on the same thread: `steer` a running phase, `resume` or
57
59
  `park` a thread with its retained context, `stop` a phase the evidence made moot.
58
60
  - Background completions and stop results arrive at the next parent model boundary;
@@ -122,15 +124,30 @@ what the injected delegation guidance produces when the main agent dispatches fo
122
124
  ```ts
123
125
  // One task
124
126
  subagent({
127
+ phaseId: "cache-invalidation-fix",
125
128
  agent: "artisan",
126
129
  task: "Fix the cache invalidation bug in src/cache, add regression tests, run the checks.",
130
+ scope: {
131
+ paths: ["src/cache"],
132
+ symbols: [{ path: "test/cache.test.ts", name: "invalidates stale entries" }],
133
+ },
127
134
  });
128
135
 
129
136
  // Parallel only when each scope independently justifies a child
130
137
  subagent({
131
138
  tasks: [
132
- { agent: "scout", task: "Research current provider API limits in primary sources and cite URLs." },
133
- { agent: "artisan", task: "Fix config validation in src/config.ts and its tests." },
139
+ {
140
+ agent: "artisan",
141
+ phaseId: "provider-docs",
142
+ task: "Update provider limits documentation from the established API citations.",
143
+ scope: { paths: ["docs/provider-limits.md"] },
144
+ },
145
+ {
146
+ agent: "artisan",
147
+ phaseId: "config-validation",
148
+ task: "Fix config validation in src/config.ts and its tests.",
149
+ scope: { paths: ["src/config.ts", "test/config.test.ts"] },
150
+ },
134
151
  ],
135
152
  });
136
153
  ```
@@ -140,13 +157,13 @@ independent unit in one `tasks` array. The runtime paces execution instead, runn
140
157
  half the machine's cores with a 4–6 child-process bound; wider batches queue and
141
158
  start automatically as slots free.
142
159
 
143
- A run leases its normalized task and resolved working directory across agent
144
- names. Dispatching the same pair again while the run is active is rejected and
145
- names the existing run id. Once the run has finished in this session and still
146
- holds its retained session, the same pair is rejected too, pointing at
147
- `subagent_control resume` the thread that already did the work continues for a
148
- fraction of a fresh run or at restating the brief with what changed. Matching is
149
- exact, never fuzzy.
160
+ A run leases its stable, single-line `phaseId` in the resolved working directory.
161
+ Rewording the task with the same `phaseId` is rejected and names the existing run.
162
+ The id remains immutable across resume. Calls that omit `phaseId` keep the old exact
163
+ normalized task+cwd behavior; consequently, equal task text with different phase ids is
164
+ still rejected by that fallback. Matching is deterministic, never fuzzy, embedding-based,
165
+ or inferred from natural language. Active leases win over matching settled threads when
166
+ the runtime chooses which owner to report.
150
167
 
151
168
  Because queueing is pacing rather than refusal, it is always reported as such.
152
169
  Dispatch confirmations name each waiting run's real reason — waiting for a free
@@ -190,8 +207,49 @@ paths, or when the checks cannot prove the change. It is never a fixed pre-commi
190
207
  ritual. A finding is evidence, not an order: main routes it to the thread that owns
191
208
  the change with `subagent_control resume`, or fixes it inline when that is cheaper.
192
209
 
210
+ `subagent_risk({})` is an advisory-only, no-model-call check over tracked and untracked
211
+ changes relative to `HEAD`. It resolves the repository root first, so a nested `cwd` still
212
+ returns repository-root-relative paths, including untracked files outside that subdirectory.
213
+ Its fixed case-insensitive path-token rules flag:
214
+ `concurrency` (`thread`, `queue`, `parallel`, `dispatch`, locks/races and related tokens);
215
+ `trust-boundary` (`auth`, credentials, permissions, policy, secrets, sandbox, security, trust, tokens);
216
+ `persistence-compatibility` (durable state, manifests, migrations, restore, schemas,
217
+ serialization/storage); and `failure-cancellation` (abort, cancel, errors/failures, recovery,
218
+ retry, stop, timeout). It returns the changed paths, matched categories, and whether those
219
+ rules suggest Sentinel. If Git or `HEAD` is unavailable, it reports advisory unavailable; an
220
+ aborted tool call propagates cancellation instead of converting it to an advisory result. It
221
+ never blocks, starts a child, or automatically dispatches Sentinel.
222
+
223
+ This classifier is intentionally conservative and explainable: it only sees path names, so
224
+ it can produce false positives and miss risky behavior hidden behind neutral names. Main
225
+ still decides whether review pays from the actual diff, test evidence, handoff cost, and the
226
+ complete conversation. The runtime can enforce explicit phase/scope admission, but cannot
227
+ safely force the natural-language judgment of whether work is worth delegating.
228
+
193
229
  ## Parallel edits
194
230
 
231
+ `scope` is declarative admission metadata for expected writes, not access control. `paths`
232
+ contains exact file or directory paths; `symbols` contains exact `{ path, name }` claims.
233
+ Paths resolve from each task's caller-facing cwd and use case-insensitive comparison on
234
+ Windows. Wildcard `*` and `?` inputs are rejected; other punctuation is treated literally,
235
+ so paths such as `app/[id]/page.tsx` are valid exact claims. A path claim overlaps the same
236
+ path, an ancestor/descendant path, or a symbol under that path; identical path+symbol
237
+ claims overlap, while two different symbols in the same file may run together.
238
+
239
+ Fresh single dispatches and resumes check a declared writer scope against active, parked,
240
+ resuming, interrupting, or settling writer leases before allocating a generation. Scope
241
+ comparison uses normalized absolute claims rather than requiring equal caller cwd, so a
242
+ repo-root claim still conflicts with the same path claimed from a nested cwd. Settled
243
+ threads do not block a later phase solely because it edits the same scope.
244
+
245
+ Before allocating any run in a parallel call, the runtime also rejects deterministic phase
246
+ duplicates within the batch or against existing active/retained threads, then compares
247
+ declared writer scopes across the whole batch. A definite conflict rejects the whole batch
248
+ with zero starts. Parallel calls without `scope` remain valid, but their tool result and
249
+ launch receipt say `independence not verified`; that means the contract lacked enough
250
+ metadata, not that overlap was proved safe. Single calls never make a batch-independence
251
+ claim. The existing shared-checkout writer lane remains the final serialization boundary.
252
+
195
253
  - Single tasks use your checkout. Every parallel write-capable agent (`artisan`,
196
254
  `steward`, and custom writers) defaults to a detached Git worktree, so
197
255
  parallel writers run at the same time. Worktree mode needs a committed `HEAD`;
@@ -230,7 +288,7 @@ Every dispatch returns a stable `#id`, which is the handle for the thread tools:
230
288
 
231
289
  | Tool | What it does |
232
290
  | ------------------ | ------------ |
233
- | `subagent_control` | `steer` a running RPC attempt with additional evidence/guidance, continuing the same thread with it when the thread has settled or is parked; `resume` a parked/settled thread with an optional appended `objective`; `park` a running thread at a stable checkpoint, keeping its session and worktree for a later resume. |
291
+ | `subagent_control` | `steer` a running RPC attempt with additional evidence/guidance, continuing the same thread with it when the thread has settled or is parked; `resume` a parked/settled thread with an optional appended `objective` and additive `scope`; `park` a running thread at a stable checkpoint, keeping its session and worktree for a later resume. |
234
292
  | `subagent_stop` | Destructively cancel, deliver partial output, and retire the thread. Steering and follow-up messages still queued in the child are dropped so nothing can revive it later. |
235
293
 
236
294
  ```ts
@@ -239,6 +297,13 @@ subagent_control({ action: "park", id: 7 });
239
297
  subagent_control({ action: "resume", id: 7, objective: "Finish the tests." });
240
298
  ```
241
299
 
300
+ A fresh dispatch stores `phaseId` and normalized `scope` on its stable thread and durable
301
+ v1 record. `resume` always keeps the thread's phase id. An optional resume `scope` adds
302
+ normalized claims to the retained scope; it cannot shrink or clear prior claims, so edits
303
+ already present in a retained worktree stay covered by admission. Resume also rechecks the
304
+ unioned scope against other active writer leases before starting a generation. Existing v1
305
+ manifests without these optional fields remain readable.
306
+
242
307
  `steer` requires a nonblank `objective`. While the child RPC is running, it adds
243
308
  guidance to the current phase without replacing the original task. If the thread has
244
309
  already reached `completed`, `failed`, or `parked` — including a generation that settles
package/index.ts CHANGED
@@ -24,6 +24,7 @@ import { getConfigPath, loadConfig } from "./src/configuration/config.ts";
24
24
  import { runSetup } from "./src/configuration/setup.ts";
25
25
  import { discoverAgents } from "./src/delegation/agents.ts";
26
26
  import { registerSubagentTool } from "./src/delegation/dispatch.ts";
27
+ import { registerSubagentRiskTool } from "./src/delegation/risk.ts";
27
28
  import { buildDelegationDirective } from "./src/delegation/prompt.ts";
28
29
  import { currentSubagentDepth } from "./src/execution/spawn.ts";
29
30
  import { createRuntime } from "./src/lifecycle/runtime.ts";
@@ -69,6 +70,7 @@ export default function (pi: ExtensionAPI): void {
69
70
  });
70
71
 
71
72
  registerSubagentTool(pi, runtime);
73
+ registerSubagentRiskTool(pi);
72
74
  registerLookupTools(pi, runtime);
73
75
 
74
76
  pi.registerCommand("subagents-setup", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "4.3.8",
3
+ "version": "4.3.9",
4
4
  "description": "A managed sub-agent team for pi: scout, artisan, steward, and sentinel roles, durable threads, model fallback, and Git worktree isolation.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,6 +54,7 @@ export const SUBAGENT_TOOL_NAMES = [
54
54
  "subagent",
55
55
  "subagent_control",
56
56
  "subagent_stop",
57
+ "subagent_risk",
57
58
  ] as const;
58
59
  const SUBAGENT_TOOL_NAME_SET = new Set<string>(SUBAGENT_TOOL_NAMES);
59
60
 
@@ -7,10 +7,11 @@
7
7
  */
8
8
 
9
9
  import { StringEnum, type Usage } from "@earendil-works/pi-ai";
10
+ import { resolve } from "node:path";
10
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
12
  import { Text } from "@earendil-works/pi-tui";
12
13
  import { Type } from "typebox";
13
- import { discoverAgents } from "./agents.ts";
14
+ import { discoverAgents, isWriteCapableAgent, type AgentConfig } from "./agents.ts";
14
15
  import { loadConfig } from "../configuration/config.ts";
15
16
  import { formatCompletionBlock, formatUsage } from "../presentation/format.ts";
16
17
  import {
@@ -22,7 +23,17 @@ import {
22
23
  sumUsage,
23
24
  type RunWaitReason,
24
25
  } from "../presentation/monitor.ts";
25
- import { formatPhaseLeaseReceipt } from "./prompt.ts";
26
+ import { findDuplicateDispatch, formatParallelScopeAdmissionNote, formatPhaseLeaseReceipt } from "./prompt.ts";
27
+ import {
28
+ findPhaseScopeOverlap,
29
+ findWriterLeaseScopeOverlap,
30
+ normalizePhaseId,
31
+ normalizePhaseScope,
32
+ PHASE_ID_MAX_LENGTH,
33
+ PHASE_ID_PATTERN_SOURCE,
34
+ type PhaseScope,
35
+ type PhaseScopeInput,
36
+ } from "./phase-scope.ts";
26
37
  import type { SubagentRuntime, SubagentThread } from "../lifecycle/runtime.ts";
27
38
  import { createBackgroundDispatcher } from "../lifecycle/thread-lifecycle.ts";
28
39
  import {
@@ -53,6 +64,23 @@ const IsolationSchema = Type.Optional(
53
64
  StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
54
65
  );
55
66
 
67
+ const PhaseIdSchema = Type.Optional(Type.String({
68
+ minLength: 1,
69
+ maxLength: PHASE_ID_MAX_LENGTH,
70
+ pattern: PHASE_ID_PATTERN_SOURCE,
71
+ description: "Stable logical phase id: 1-80 ASCII letters, numbers, or ._:- characters, starting with a letter or number. Reuse it when task wording changes so duplicate fresh dispatches are rejected.",
72
+ }));
73
+ const ScopeSchema = Type.Optional(Type.Object({
74
+ paths: Type.Optional(Type.Array(Type.String({
75
+ ...NON_BLANK_TASK_OPTIONS,
76
+ description: "Exact file or directory write claim resolved from the caller-facing cwd; wildcard * and ? are rejected, while other punctuation is literal.",
77
+ }))),
78
+ symbols: Type.Optional(Type.Array(Type.Object({
79
+ path: Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Exact file path resolved from the caller-facing cwd." }),
80
+ name: Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Exact symbol name claimed for writing." }),
81
+ }))),
82
+ }, { description: "Declarative write-conflict metadata for admission, not filesystem permissions or a sandbox. If present, at least one valid claim is required." }));
83
+
56
84
  const WaitSchema = Type.Optional(
57
85
  Type.Boolean({
58
86
  description:
@@ -69,6 +97,8 @@ const TaskItem = Type.Object({
69
97
  ...NON_BLANK_TASK_OPTIONS,
70
98
  description: TASK_BRIEF_DESCRIPTION,
71
99
  }),
100
+ phaseId: PhaseIdSchema,
101
+ scope: ScopeSchema,
72
102
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
73
103
  isolation: IsolationSchema,
74
104
  });
@@ -78,6 +108,8 @@ const SubagentParams = Type.Object({
78
108
  task: Type.Optional(
79
109
  Type.String({ ...NON_BLANK_TASK_OPTIONS, description: `${TASK_BRIEF_DESCRIPTION} (single mode)` }),
80
110
  ),
111
+ phaseId: PhaseIdSchema,
112
+ scope: ScopeSchema,
81
113
  tasks: Type.Optional(Type.Array(TaskItem, { description: "Independently justified, disjoint phases for parallel execution" })),
82
114
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
83
115
  isolation: IsolationSchema,
@@ -109,6 +141,91 @@ export function defaultIsolationMode(
109
141
  return mode === "parallel" && writeCapable ? "worktree" : "shared";
110
142
  }
111
143
 
144
+ interface PreparedDispatchTask {
145
+ index: number;
146
+ agent: string;
147
+ task: string;
148
+ cwd: string;
149
+ phaseId?: string;
150
+ scope?: PhaseScope;
151
+ isolation?: IsolationMode;
152
+ writeCapable: boolean;
153
+ }
154
+
155
+ function prepareDispatchTasks(
156
+ tasks: ReadonlyArray<{ agent: string; task: string; cwd?: string; phaseId?: string; scope?: PhaseScopeInput; isolation?: IsolationMode }>,
157
+ callerCwd: string,
158
+ agents: readonly AgentConfig[],
159
+ ): PreparedDispatchTask[] {
160
+ return tasks.map((item, index) => {
161
+ const cwd = resolve(callerCwd, item.cwd ?? ".");
162
+ const agent = agents.find((candidate) => candidate.name === item.agent);
163
+ return {
164
+ index,
165
+ agent: item.agent,
166
+ task: item.task,
167
+ cwd,
168
+ phaseId: normalizePhaseId(item.phaseId),
169
+ scope: normalizePhaseScope(item.scope, cwd),
170
+ isolation: item.isolation,
171
+ writeCapable: agent ? isWriteCapableAgent(agent) : true,
172
+ };
173
+ });
174
+ }
175
+
176
+ function parallelAdmissionConflict(
177
+ tasks: readonly PreparedDispatchTask[],
178
+ threads: Iterable<SubagentThread>,
179
+ ): string | undefined {
180
+ for (let leftIndex = 0; leftIndex < tasks.length; leftIndex++) {
181
+ for (let rightIndex = leftIndex + 1; rightIndex < tasks.length; rightIndex++) {
182
+ const left = tasks[leftIndex]!;
183
+ const right = tasks[rightIndex]!;
184
+ const duplicate = findDuplicateDispatch([{
185
+ id: left.index,
186
+ agentName: left.agent,
187
+ task: left.task,
188
+ phaseId: left.phaseId,
189
+ cwd: left.cwd,
190
+ state: "queued",
191
+ }], right.task, right.cwd, right.phaseId);
192
+ if (duplicate) {
193
+ return `deterministic duplicate between tasks[${left.index}] and tasks[${right.index}]`;
194
+ }
195
+ }
196
+ }
197
+ const leases = [...threads];
198
+ for (const task of tasks) {
199
+ const duplicate = findDuplicateDispatch(leases, task.task, task.cwd, task.phaseId);
200
+ if (duplicate?.kind === "active") {
201
+ return `tasks[${task.index}] duplicates active run #${duplicate.source.id} (${duplicate.source.agentName})`;
202
+ }
203
+ if (duplicate?.kind === "settled") {
204
+ return `tasks[${task.index}] duplicates settled run #${duplicate.source.id} (${duplicate.source.agentName}); resume that retained thread instead`;
205
+ }
206
+ }
207
+ const writers = tasks.filter(
208
+ (task): task is PreparedDispatchTask & { scope: PhaseScope } => task.writeCapable && task.scope !== undefined,
209
+ );
210
+ for (let leftIndex = 0; leftIndex < writers.length; leftIndex++) {
211
+ for (let rightIndex = leftIndex + 1; rightIndex < writers.length; rightIndex++) {
212
+ const left = writers[leftIndex]!;
213
+ const right = writers[rightIndex]!;
214
+ const overlap = findPhaseScopeOverlap(left.scope, right.scope);
215
+ if (overlap) {
216
+ return `tasks[${left.index}] scope ${overlap.left} overlaps tasks[${right.index}] scope ${overlap.right}`;
217
+ }
218
+ }
219
+ }
220
+ for (const task of writers) {
221
+ const conflict = findWriterLeaseScopeOverlap(task.scope, leases);
222
+ if (conflict) {
223
+ return `tasks[${task.index}] scope ${conflict.overlap.left} overlaps run #${conflict.lease.id} scope ${conflict.overlap.right}`;
224
+ }
225
+ }
226
+ return undefined;
227
+ }
228
+
112
229
  /** Map the child's own usage tally onto pi's tool-result `Usage`, so sub-agent
113
230
  * token spend lands in the parent's footer, /session, and RPC session totals
114
231
  * instead of being invisible. Only the total cost is known here: a child
@@ -325,11 +442,15 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
325
442
  (mode: "single" | "parallel", background = false) =>
326
443
  (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
327
444
 
328
- const phaseLeaseReceipt = (runIds: number[]): string =>
445
+ const phaseLeaseReceipt = (
446
+ runIds: number[],
447
+ options: { mode: "single" } | { mode: "parallel"; declaredScopesComplete: boolean },
448
+ ): string =>
329
449
  formatPhaseLeaseReceipt(
330
450
  runIds
331
451
  .map((runId) => runtime.threads.get(runId))
332
452
  .filter((thread): thread is SubagentThread => thread !== undefined),
453
+ options,
333
454
  );
334
455
 
335
456
  /** Pacing note appended to dispatch confirmations whenever runs are actually
@@ -378,7 +499,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
378
499
  pi.registerTool({
379
500
  name: "subagent",
380
501
  label: "Subagent",
381
- description: "Start paid leaf runs for broad reconnaissance or substantial self-contained work. Each normalized task+cwd owns its phase: an exact duplicate of an active run is rejected, and one of a finished run with retained context is rejected in favor of subagent_control resume. Batch scopes must be independent. wait:true returns results in-turn; otherwise completions wake main. Parallel writers default to detached Git worktrees; isolation:'shared' serializes same-repository writes.",
502
+ description: "Start paid leaf runs for substantial self-contained work. phaseId is a stable single-line logical identity; exact task+cwd remains the compatibility fallback. scope declares exact write-conflict metadata (not permissions or sandboxing). Fresh single and resumed writer scopes are checked against active leases; parallel batches also preflight deterministic duplicates and all declared scope overlaps before allocation. A parallel batch that omits scope reports `independence not verified`; declared claims do not prove natural-language task independence. wait:true returns results in-turn; otherwise completions wake main.",
382
503
  parameters: SubagentParams,
383
504
 
384
505
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -452,10 +573,21 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
452
573
  // each delegated phase. The queue paces child processes without changing
453
574
  // phase ownership or requiring a per-call task cap.
454
575
  if (params.tasks && params.tasks.length > 0) {
455
- // Admission is synchronous and ordered; slow worktree preparation belongs
456
- // to the bounded queue. Promise.all preserves caller result order while no
457
- // item waits for a sibling's filesystem setup.
458
- const results = await Promise.all(params.tasks.map((item) => {
576
+ let prepared: PreparedDispatchTask[];
577
+ try {
578
+ prepared = prepareDispatchTasks(params.tasks, ctx.cwd, agents);
579
+ } catch (error) {
580
+ throw new Error(`Parallel admission rejected: ${error instanceof Error ? error.message : String(error)} No background tasks were started.`);
581
+ }
582
+ const conflict = parallelAdmissionConflict(prepared, runtime.threads.values());
583
+ if (conflict) {
584
+ throw new Error(`Parallel admission rejected: ${conflict}. No background tasks were started.`);
585
+ }
586
+ const declaredScopesComplete = prepared.every((item) => item.scope !== undefined);
587
+ const admissionNote = formatParallelScopeAdmissionNote(declaredScopesComplete);
588
+ // Duplicate and scope admission completes for the whole batch before any
589
+ // startBackground call can allocate a run. Worktree preparation stays queued.
590
+ const results = await Promise.all(prepared.map((item) => {
459
591
  const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
460
592
  return startBackground(
461
593
  item.agent,
@@ -464,11 +596,16 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
464
596
  defaultIsolationMode(
465
597
  "parallel",
466
598
  item.agent,
467
- item.isolation as IsolationMode | undefined,
599
+ item.isolation,
468
600
  catalogAgent ? isWorktreeCapableAgent(catalogAgent) : undefined,
469
601
  catalogAgent?.isolation,
470
602
  ),
471
- { deliveryRoute: params.wait ? "await" : "background" },
603
+ {
604
+ deliveryRoute: params.wait ? "await" : "background",
605
+ phaseId: item.phaseId,
606
+ scope: item.scope,
607
+ writeCapable: item.writeCapable,
608
+ },
472
609
  );
473
610
  }));
474
611
  const startedRuns = results.filter((result) => result.exitCode === -1);
@@ -492,9 +629,10 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
492
629
  const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("parallel", true)(results)));
493
630
  if (signal?.aborted) runtime.fallbackAwaitDelivery(startedIds);
494
631
  else runtime.completeAwaitDelivery(startedIds);
495
- const text = failureLines.length > 0
632
+ const resultText = failureLines.length > 0
496
633
  ? `${blocks}\n\nLaunch failures:\n${failureLines.join("\n")}`
497
634
  : blocks;
635
+ const text = `${resultText}\n\n${admissionNote}`;
498
636
  return {
499
637
  content: [{ type: "text", text }],
500
638
  details: makeDetails("parallel", true)(results),
@@ -502,7 +640,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
502
640
  };
503
641
  }
504
642
  const text = [
505
- phaseLeaseReceipt(startedIds),
643
+ phaseLeaseReceipt(startedIds, { mode: "parallel", declaredScopesComplete }),
506
644
  ...(failureLines.length > 0 ? ["Launch failures:", ...failureLines] : []),
507
645
  ].join("\n") + queuePacingNote();
508
646
  return {
@@ -511,19 +649,37 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
511
649
  };
512
650
  }
513
651
 
514
- const singleCatalogAgent = agents.find((candidate) => candidate.name === params.agent);
652
+ let single: PreparedDispatchTask;
653
+ try {
654
+ single = prepareDispatchTasks([{
655
+ agent: params.agent as string,
656
+ task: params.task as string,
657
+ cwd: params.cwd,
658
+ phaseId: params.phaseId,
659
+ scope: params.scope,
660
+ isolation: params.isolation as IsolationMode | undefined,
661
+ }], ctx.cwd, agents)[0]!;
662
+ } catch (error) {
663
+ throw new Error(`Dispatch admission rejected: ${error instanceof Error ? error.message : String(error)}`);
664
+ }
665
+ const singleCatalogAgent = agents.find((candidate) => candidate.name === single.agent);
515
666
  const result = await startBackground(
516
- params.agent as string,
517
- params.task as string,
518
- params.cwd,
667
+ single.agent,
668
+ single.task,
669
+ single.cwd,
519
670
  defaultIsolationMode(
520
671
  "single",
521
- params.agent as string,
522
- params.isolation as IsolationMode | undefined,
672
+ single.agent,
673
+ single.isolation,
523
674
  singleCatalogAgent ? isWorktreeCapableAgent(singleCatalogAgent) : undefined,
524
675
  singleCatalogAgent?.isolation,
525
676
  ),
526
- { deliveryRoute: params.wait ? "await" : "background" },
677
+ {
678
+ deliveryRoute: params.wait ? "await" : "background",
679
+ phaseId: single.phaseId,
680
+ scope: single.scope,
681
+ writeCapable: single.writeCapable,
682
+ },
527
683
  );
528
684
  if (result.exitCode !== -1) {
529
685
  throw new Error(getResultOutput(result));
@@ -541,7 +697,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
541
697
  return {
542
698
  content: [{
543
699
  type: "text",
544
- text: phaseLeaseReceipt(result.runId === undefined ? [] : [result.runId]) + queuePacingNote(),
700
+ text: phaseLeaseReceipt(result.runId === undefined ? [] : [result.runId], { mode: "single" }) + queuePacingNote(),
545
701
  }],
546
702
  details: makeDetails("single", true)([result]),
547
703
  };