@ferris1225/pi-subagents 4.3.8 → 4.3.10

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.
@@ -52,8 +52,9 @@ const READ_ONLY_TOOL_NAMES = new Set([
52
52
  ]);
53
53
  export const SUBAGENT_TOOL_NAMES = [
54
54
  "subagent",
55
- "subagent_control",
55
+ "subagent_status",
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,10 +23,21 @@ 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 {
40
+ getResultError,
29
41
  getResultOutput,
30
42
  isFailedResult,
31
43
  type SingleResult,
@@ -53,6 +65,23 @@ const IsolationSchema = Type.Optional(
53
65
  StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
54
66
  );
55
67
 
68
+ const PhaseIdSchema = Type.Optional(Type.String({
69
+ minLength: 1,
70
+ maxLength: PHASE_ID_MAX_LENGTH,
71
+ pattern: PHASE_ID_PATTERN_SOURCE,
72
+ 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.",
73
+ }));
74
+ const ScopeSchema = Type.Optional(Type.Object({
75
+ paths: Type.Optional(Type.Array(Type.String({
76
+ ...NON_BLANK_TASK_OPTIONS,
77
+ description: "Exact file or directory write claim resolved from the caller-facing cwd; wildcard * and ? are rejected, while other punctuation is literal.",
78
+ }))),
79
+ symbols: Type.Optional(Type.Array(Type.Object({
80
+ path: Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Exact file path resolved from the caller-facing cwd." }),
81
+ name: Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Exact symbol name claimed for writing." }),
82
+ }))),
83
+ }, { description: "Declarative write-conflict metadata for admission, not filesystem permissions or a sandbox. If present, at least one valid claim is required." }));
84
+
56
85
  const WaitSchema = Type.Optional(
57
86
  Type.Boolean({
58
87
  description:
@@ -69,6 +98,8 @@ const TaskItem = Type.Object({
69
98
  ...NON_BLANK_TASK_OPTIONS,
70
99
  description: TASK_BRIEF_DESCRIPTION,
71
100
  }),
101
+ phaseId: PhaseIdSchema,
102
+ scope: ScopeSchema,
72
103
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
73
104
  isolation: IsolationSchema,
74
105
  });
@@ -78,6 +109,8 @@ const SubagentParams = Type.Object({
78
109
  task: Type.Optional(
79
110
  Type.String({ ...NON_BLANK_TASK_OPTIONS, description: `${TASK_BRIEF_DESCRIPTION} (single mode)` }),
80
111
  ),
112
+ phaseId: PhaseIdSchema,
113
+ scope: ScopeSchema,
81
114
  tasks: Type.Optional(Type.Array(TaskItem, { description: "Independently justified, disjoint phases for parallel execution" })),
82
115
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
83
116
  isolation: IsolationSchema,
@@ -109,6 +142,91 @@ export function defaultIsolationMode(
109
142
  return mode === "parallel" && writeCapable ? "worktree" : "shared";
110
143
  }
111
144
 
145
+ interface PreparedDispatchTask {
146
+ index: number;
147
+ agent: string;
148
+ task: string;
149
+ cwd: string;
150
+ phaseId?: string;
151
+ scope?: PhaseScope;
152
+ isolation?: IsolationMode;
153
+ writeCapable: boolean;
154
+ }
155
+
156
+ function prepareDispatchTasks(
157
+ tasks: ReadonlyArray<{ agent: string; task: string; cwd?: string; phaseId?: string; scope?: PhaseScopeInput; isolation?: IsolationMode }>,
158
+ callerCwd: string,
159
+ agents: readonly AgentConfig[],
160
+ ): PreparedDispatchTask[] {
161
+ return tasks.map((item, index) => {
162
+ const cwd = resolve(callerCwd, item.cwd ?? ".");
163
+ const agent = agents.find((candidate) => candidate.name === item.agent);
164
+ return {
165
+ index,
166
+ agent: item.agent,
167
+ task: item.task,
168
+ cwd,
169
+ phaseId: normalizePhaseId(item.phaseId),
170
+ scope: normalizePhaseScope(item.scope, cwd),
171
+ isolation: item.isolation,
172
+ writeCapable: agent ? isWriteCapableAgent(agent) : true,
173
+ };
174
+ });
175
+ }
176
+
177
+ function parallelAdmissionConflict(
178
+ tasks: readonly PreparedDispatchTask[],
179
+ threads: Iterable<SubagentThread>,
180
+ ): string | undefined {
181
+ for (let leftIndex = 0; leftIndex < tasks.length; leftIndex++) {
182
+ for (let rightIndex = leftIndex + 1; rightIndex < tasks.length; rightIndex++) {
183
+ const left = tasks[leftIndex]!;
184
+ const right = tasks[rightIndex]!;
185
+ const duplicate = findDuplicateDispatch([{
186
+ id: left.index,
187
+ agentName: left.agent,
188
+ task: left.task,
189
+ phaseId: left.phaseId,
190
+ cwd: left.cwd,
191
+ state: "queued",
192
+ }], right.task, right.cwd, right.phaseId);
193
+ if (duplicate) {
194
+ return `deterministic duplicate between tasks[${left.index}] and tasks[${right.index}]`;
195
+ }
196
+ }
197
+ }
198
+ const leases = [...threads];
199
+ for (const task of tasks) {
200
+ const duplicate = findDuplicateDispatch(leases, task.task, task.cwd, task.phaseId);
201
+ if (duplicate?.kind === "active") {
202
+ return `tasks[${task.index}] duplicates active run #${duplicate.source.id} (${duplicate.source.agentName})`;
203
+ }
204
+ if (duplicate?.kind === "settled") {
205
+ return `tasks[${task.index}] duplicates settled run #${duplicate.source.id} (${duplicate.source.agentName}); inspect it with subagent_status and handle follow-up work in main`;
206
+ }
207
+ }
208
+ const writers = tasks.filter(
209
+ (task): task is PreparedDispatchTask & { scope: PhaseScope } => task.writeCapable && task.scope !== undefined,
210
+ );
211
+ for (let leftIndex = 0; leftIndex < writers.length; leftIndex++) {
212
+ for (let rightIndex = leftIndex + 1; rightIndex < writers.length; rightIndex++) {
213
+ const left = writers[leftIndex]!;
214
+ const right = writers[rightIndex]!;
215
+ const overlap = findPhaseScopeOverlap(left.scope, right.scope);
216
+ if (overlap) {
217
+ return `tasks[${left.index}] scope ${overlap.left} overlaps tasks[${right.index}] scope ${overlap.right}`;
218
+ }
219
+ }
220
+ }
221
+ for (const task of writers) {
222
+ const conflict = findWriterLeaseScopeOverlap(task.scope, leases);
223
+ if (conflict) {
224
+ return `tasks[${task.index}] scope ${conflict.overlap.left} overlaps run #${conflict.lease.id} scope ${conflict.overlap.right}`;
225
+ }
226
+ }
227
+ return undefined;
228
+ }
229
+
112
230
  /** Map the child's own usage tally onto pi's tool-result `Usage`, so sub-agent
113
231
  * token spend lands in the parent's footer, /session, and RPC session totals
114
232
  * instead of being invisible. Only the total cost is known here: a child
@@ -134,13 +252,8 @@ function toolUsage(runtime: SubagentRuntime, runIds: number[]): { usage?: Usage
134
252
  return parts.length > 0 ? { usage: toToolUsage(sumUsage(parts)) } : {};
135
253
  }
136
254
 
137
- /** In-turn wait behind dispatch `wait: true` the escape hatch for one-shot
138
- * `pi -p` parents that exit at end of turn or an immediate dependent step: hold
139
- * the call until every run it started settles, then hand back result blocks. No
140
- * timer: a waiter resolves the moment its run's result registers (children
141
- * are bounded by the idle watchdog), an already-parked run answers
142
- * immediately with its resume handle, and the turn's abort signal remains the
143
- * escape hatch. */
255
+ /** In-turn wait for a fresh dispatch. Registration resolves it without a model-chosen
256
+ * timer; parent abort or removal ends the wait without losing background delivery. */
144
257
  export async function awaitRunResults(
145
258
  runtime: SubagentRuntime,
146
259
  runIds: number[],
@@ -153,7 +266,7 @@ export async function awaitRunResults(
153
266
  const already = runtime.settledRuns.get(runId);
154
267
  if (already) return Promise.resolve({ result: already });
155
268
  if (monitor.findRun(runId)?.status === "parked") {
156
- return Promise.resolve({ note: `run #${runId} is parked at a stable checkpoint; use subagent_control resume to continue it` });
269
+ return Promise.resolve({ note: `run #${runId} was interrupted; inspect retained work with subagent_status and finish it in main` });
157
270
  }
158
271
  return new Promise((resolve) => {
159
272
  let done = false;
@@ -182,7 +295,7 @@ export async function awaitRunResults(
182
295
  }
183
296
  const live = monitor.findRun(runId);
184
297
  if (live?.status === "parked") {
185
- finish({ note: `run #${runId} was parked at a stable checkpoint; use subagent_control resume to continue it` });
298
+ finish({ note: `run #${runId} was interrupted; inspect retained work with subagent_status and finish it in main` });
186
299
  return;
187
300
  }
188
301
  if (!live) {
@@ -242,15 +355,12 @@ export async function awaitRunResults(
242
355
  }
243
356
 
244
357
  export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
245
- // Latest dispatch environment. The dispatcher is created once per process so
246
- // restored threads can resume before any dispatch has run; each execute
247
- // refreshes the fallback context, config, and agent catalog it resolves.
358
+ // Each dispatch refreshes the context, config, and agent catalog.
248
359
  const environmentRef: { current: DispatchEnvironment | undefined } = { current: undefined };
249
360
 
250
361
  // Terminal rows stay in the monitor until the next beginTurn so the footer
251
362
  // can count them beside siblings that are still live. The widget ignores
252
- // them. A second finishRun for the same endedAt is a no-op; a resume
253
- // clears endedAt, so the next settlement notifies again.
363
+ // them. Repeated publication of the same settlement is a no-op.
254
364
  const publishedEndedAt = new Map<number, number>();
255
365
  const finishRun = (
256
366
  runId: number,
@@ -265,7 +375,12 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
265
375
  if (endedAt !== undefined) publishedEndedAt.set(runId, endedAt);
266
376
  if (opts?.silent || !runtime.sessionActive) return;
267
377
  const icon = status === "done" ? "✓" : "✗";
268
- environmentRef.current?.ctx.ui.notify(`${icon} #${run.id} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
378
+ const result = runtime.threads.get(runId)?.lastResult;
379
+ const error = status === "failed" ? result ? getResultError(result) : "No failure reason was recorded." : undefined;
380
+ environmentRef.current?.ctx.ui.notify(
381
+ `${icon} #${run.id} ${monitor.summarize(run)}${error ? ` · ${formatTaskSummary(error, 300, false)}` : ""}`,
382
+ status === "done" ? "info" : "error",
383
+ );
269
384
  };
270
385
 
271
386
  // Live sub-agent activity → concise one-line status ("thinking",
@@ -325,11 +440,15 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
325
440
  (mode: "single" | "parallel", background = false) =>
326
441
  (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
327
442
 
328
- const phaseLeaseReceipt = (runIds: number[]): string =>
443
+ const phaseLeaseReceipt = (
444
+ runIds: number[],
445
+ options: { mode: "single" } | { mode: "parallel"; declaredScopesComplete: boolean },
446
+ ): string =>
329
447
  formatPhaseLeaseReceipt(
330
448
  runIds
331
449
  .map((runId) => runtime.threads.get(runId))
332
450
  .filter((thread): thread is SubagentThread => thread !== undefined),
451
+ options,
333
452
  );
334
453
 
335
454
  /** Pacing note appended to dispatch confirmations whenever runs are actually
@@ -373,12 +492,11 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
373
492
  makeLiveHandler,
374
493
  makeDetails,
375
494
  });
376
- runtime.dispatcher = startBackground;
377
495
 
378
496
  pi.registerTool({
379
497
  name: "subagent",
380
498
  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.",
499
+ description: "Start paid one-shot leaf runs for substantial self-contained work. phaseId is a stable logical identity; exact task+cwd is the fallback. scope declares write-conflict metadata, not permissions or a sandbox. Fresh writers are checked against active leases; parallel batches preflight duplicate phases and declared overlaps before allocation. Missing scope reports `independence not verified`; claims do not prove task independence. wait:true returns results in-turn; otherwise completions wake main. Inspect with subagent_status, cancel with subagent_stop; main handles failed or incomplete work.",
382
500
  parameters: SubagentParams,
383
501
 
384
502
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
@@ -401,8 +519,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
401
519
  projectTrusted: ctx.isProjectTrusted?.() === true,
402
520
  });
403
521
  const agents = discovery.agents;
404
- // Refresh the dispatcher's fallback environment so control operations
405
- // (resume of restored threads) never run on a stale context.
406
522
  environmentRef.current = { ctx, config, agents };
407
523
 
408
524
  const hasTasks = (params.tasks?.length ?? 0) > 0;
@@ -452,10 +568,21 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
452
568
  // each delegated phase. The queue paces child processes without changing
453
569
  // phase ownership or requiring a per-call task cap.
454
570
  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) => {
571
+ let prepared: PreparedDispatchTask[];
572
+ try {
573
+ prepared = prepareDispatchTasks(params.tasks, ctx.cwd, agents);
574
+ } catch (error) {
575
+ throw new Error(`Parallel admission rejected: ${error instanceof Error ? error.message : String(error)} No background tasks were started.`);
576
+ }
577
+ const conflict = parallelAdmissionConflict(prepared, runtime.threads.values());
578
+ if (conflict) {
579
+ throw new Error(`Parallel admission rejected: ${conflict}. No background tasks were started.`);
580
+ }
581
+ const declaredScopesComplete = prepared.every((item) => item.scope !== undefined);
582
+ const admissionNote = formatParallelScopeAdmissionNote(declaredScopesComplete);
583
+ // Duplicate and scope admission completes for the whole batch before any
584
+ // startBackground call can allocate a run. Worktree preparation stays queued.
585
+ const results = await Promise.all(prepared.map((item) => {
459
586
  const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
460
587
  return startBackground(
461
588
  item.agent,
@@ -464,11 +591,16 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
464
591
  defaultIsolationMode(
465
592
  "parallel",
466
593
  item.agent,
467
- item.isolation as IsolationMode | undefined,
594
+ item.isolation,
468
595
  catalogAgent ? isWorktreeCapableAgent(catalogAgent) : undefined,
469
596
  catalogAgent?.isolation,
470
597
  ),
471
- { deliveryRoute: params.wait ? "await" : "background" },
598
+ {
599
+ deliveryRoute: params.wait ? "await" : "background",
600
+ phaseId: item.phaseId,
601
+ scope: item.scope,
602
+ writeCapable: item.writeCapable,
603
+ },
472
604
  );
473
605
  }));
474
606
  const startedRuns = results.filter((result) => result.exitCode === -1);
@@ -492,9 +624,10 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
492
624
  const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("parallel", true)(results)));
493
625
  if (signal?.aborted) runtime.fallbackAwaitDelivery(startedIds);
494
626
  else runtime.completeAwaitDelivery(startedIds);
495
- const text = failureLines.length > 0
627
+ const resultText = failureLines.length > 0
496
628
  ? `${blocks}\n\nLaunch failures:\n${failureLines.join("\n")}`
497
629
  : blocks;
630
+ const text = `${resultText}\n\n${admissionNote}`;
498
631
  return {
499
632
  content: [{ type: "text", text }],
500
633
  details: makeDetails("parallel", true)(results),
@@ -502,7 +635,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
502
635
  };
503
636
  }
504
637
  const text = [
505
- phaseLeaseReceipt(startedIds),
638
+ phaseLeaseReceipt(startedIds, { mode: "parallel", declaredScopesComplete }),
506
639
  ...(failureLines.length > 0 ? ["Launch failures:", ...failureLines] : []),
507
640
  ].join("\n") + queuePacingNote();
508
641
  return {
@@ -511,19 +644,37 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
511
644
  };
512
645
  }
513
646
 
514
- const singleCatalogAgent = agents.find((candidate) => candidate.name === params.agent);
647
+ let single: PreparedDispatchTask;
648
+ try {
649
+ single = prepareDispatchTasks([{
650
+ agent: params.agent as string,
651
+ task: params.task as string,
652
+ cwd: params.cwd,
653
+ phaseId: params.phaseId,
654
+ scope: params.scope,
655
+ isolation: params.isolation as IsolationMode | undefined,
656
+ }], ctx.cwd, agents)[0]!;
657
+ } catch (error) {
658
+ throw new Error(`Dispatch admission rejected: ${error instanceof Error ? error.message : String(error)}`);
659
+ }
660
+ const singleCatalogAgent = agents.find((candidate) => candidate.name === single.agent);
515
661
  const result = await startBackground(
516
- params.agent as string,
517
- params.task as string,
518
- params.cwd,
662
+ single.agent,
663
+ single.task,
664
+ single.cwd,
519
665
  defaultIsolationMode(
520
666
  "single",
521
- params.agent as string,
522
- params.isolation as IsolationMode | undefined,
667
+ single.agent,
668
+ single.isolation,
523
669
  singleCatalogAgent ? isWorktreeCapableAgent(singleCatalogAgent) : undefined,
524
670
  singleCatalogAgent?.isolation,
525
671
  ),
526
- { deliveryRoute: params.wait ? "await" : "background" },
672
+ {
673
+ deliveryRoute: params.wait ? "await" : "background",
674
+ phaseId: single.phaseId,
675
+ scope: single.scope,
676
+ writeCapable: single.writeCapable,
677
+ },
527
678
  );
528
679
  if (result.exitCode !== -1) {
529
680
  throw new Error(getResultOutput(result));
@@ -541,7 +692,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
541
692
  return {
542
693
  content: [{
543
694
  type: "text",
544
- text: phaseLeaseReceipt(result.runId === undefined ? [] : [result.runId]) + queuePacingNote(),
695
+ text: phaseLeaseReceipt(result.runId === undefined ? [] : [result.runId], { mode: "single" }) + queuePacingNote(),
545
696
  }],
546
697
  details: makeDetails("single", true)([result]),
547
698
  };
@@ -0,0 +1,178 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+
3
+ export interface SymbolScopeClaim {
4
+ path: string;
5
+ name: string;
6
+ }
7
+
8
+ export interface PhaseScopeInput {
9
+ paths?: string[];
10
+ symbols?: SymbolScopeClaim[];
11
+ }
12
+
13
+ /** Absolute, platform-normalized claims used only for deterministic admission. */
14
+ export interface PhaseScope {
15
+ paths?: string[];
16
+ symbols?: SymbolScopeClaim[];
17
+ }
18
+
19
+ export interface PhaseScopeOverlap {
20
+ left: string;
21
+ right: string;
22
+ kind: "path-path" | "path-symbol" | "symbol-symbol";
23
+ }
24
+
25
+ export const PHASE_ID_MAX_LENGTH = 80;
26
+ export const PHASE_ID_PATTERN_SOURCE = "^[A-Za-z0-9][A-Za-z0-9._:-]*$";
27
+ const PHASE_ID_PATTERN = new RegExp(PHASE_ID_PATTERN_SOURCE, "u");
28
+ const WILDCARD_PATH = /[*?]/u;
29
+
30
+ function normalizePathCase(path: string): string {
31
+ return process.platform === "win32" ? path.toLowerCase() : path;
32
+ }
33
+
34
+ function normalizeClaimPath(path: unknown, cwd: string, label: string): string {
35
+ if (typeof path !== "string" || path.trim().length === 0) {
36
+ throw new Error(`${label} must be a non-blank exact file or directory path.`);
37
+ }
38
+ const trimmed = path.trim();
39
+ if (WILDCARD_PATH.test(trimmed)) {
40
+ throw new Error(`${label} must be exact; wildcard * and ? inputs are not supported. Other punctuation is treated literally.`);
41
+ }
42
+ return normalizePathCase(resolve(cwd, trimmed));
43
+ }
44
+
45
+ export function normalizePhaseId(phaseId: string | undefined): string | undefined {
46
+ if (phaseId === undefined) return undefined;
47
+ if (
48
+ typeof phaseId !== "string" ||
49
+ phaseId.length > PHASE_ID_MAX_LENGTH ||
50
+ !PHASE_ID_PATTERN.test(phaseId)
51
+ ) {
52
+ throw new Error(`phaseId must be a single-line identifier of 1-${PHASE_ID_MAX_LENGTH} ASCII letters, numbers, or ._:- characters, starting with a letter or number.`);
53
+ }
54
+ return phaseId;
55
+ }
56
+
57
+ /** Resolve exact claims against the caller-facing cwd. An omitted scope remains
58
+ * compatible; a present scope must contain at least one valid claim. */
59
+ export function normalizePhaseScope(
60
+ scope: PhaseScopeInput | undefined,
61
+ cwd: string,
62
+ ): PhaseScope | undefined {
63
+ if (scope === undefined) return undefined;
64
+ if (!scope || typeof scope !== "object") throw new Error("scope must be an object when provided.");
65
+ if (scope.paths !== undefined && !Array.isArray(scope.paths)) throw new Error("scope.paths must be an array.");
66
+ if (scope.symbols !== undefined && !Array.isArray(scope.symbols)) throw new Error("scope.symbols must be an array.");
67
+
68
+ const paths = [...new Set((scope.paths ?? []).map((path, index) =>
69
+ normalizeClaimPath(path, cwd, `scope.paths[${index}]`),
70
+ ))];
71
+ const symbols: SymbolScopeClaim[] = [];
72
+ const symbolKeys = new Set<string>();
73
+ for (const [index, symbol] of (scope.symbols ?? []).entries()) {
74
+ if (!symbol || typeof symbol !== "object") {
75
+ throw new Error(`scope.symbols[${index}] must contain an exact path and symbol name.`);
76
+ }
77
+ const path = normalizeClaimPath(symbol.path, cwd, `scope.symbols[${index}].path`);
78
+ if (typeof symbol.name !== "string" || symbol.name.trim().length === 0) {
79
+ throw new Error(`scope.symbols[${index}].name must be non-blank.`);
80
+ }
81
+ const name = symbol.name.trim();
82
+ const key = `${path}\0${name}`;
83
+ if (!symbolKeys.has(key)) {
84
+ symbolKeys.add(key);
85
+ symbols.push({ path, name });
86
+ }
87
+ }
88
+ if (paths.length === 0 && symbols.length === 0) {
89
+ throw new Error("scope must contain at least one valid claim in paths or symbols.");
90
+ }
91
+ return {
92
+ ...(paths.length > 0 ? { paths } : {}),
93
+ ...(symbols.length > 0 ? { symbols } : {}),
94
+ };
95
+ }
96
+
97
+ function containsPath(ancestor: string, candidate: string): boolean {
98
+ if (ancestor === candidate) return true;
99
+ const child = relative(ancestor, candidate);
100
+ return child !== "" && child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child);
101
+ }
102
+
103
+ function describeSymbol(symbol: SymbolScopeClaim): string {
104
+ return `${symbol.path}#${symbol.name}`;
105
+ }
106
+
107
+ /** Return the first deterministic overlap. Path claims cover the named path and
108
+ * descendants; symbols on the same file overlap only when their names match. */
109
+ export function findPhaseScopeOverlap(
110
+ left: PhaseScope,
111
+ right: PhaseScope,
112
+ ): PhaseScopeOverlap | undefined {
113
+ for (const leftPath of left.paths ?? []) {
114
+ for (const rightPath of right.paths ?? []) {
115
+ if (containsPath(leftPath, rightPath) || containsPath(rightPath, leftPath)) {
116
+ return { left: leftPath, right: rightPath, kind: "path-path" };
117
+ }
118
+ }
119
+ }
120
+ for (const leftPath of left.paths ?? []) {
121
+ for (const rightSymbol of right.symbols ?? []) {
122
+ if (containsPath(leftPath, rightSymbol.path)) {
123
+ return { left: leftPath, right: describeSymbol(rightSymbol), kind: "path-symbol" };
124
+ }
125
+ }
126
+ }
127
+ for (const leftSymbol of left.symbols ?? []) {
128
+ for (const rightPath of right.paths ?? []) {
129
+ if (containsPath(rightPath, leftSymbol.path)) {
130
+ return { left: describeSymbol(leftSymbol), right: rightPath, kind: "path-symbol" };
131
+ }
132
+ }
133
+ }
134
+ for (const leftSymbol of left.symbols ?? []) {
135
+ for (const rightSymbol of right.symbols ?? []) {
136
+ if (leftSymbol.path === rightSymbol.path && leftSymbol.name === rightSymbol.name) {
137
+ return {
138
+ left: describeSymbol(leftSymbol),
139
+ right: describeSymbol(rightSymbol),
140
+ kind: "symbol-symbol",
141
+ };
142
+ }
143
+ }
144
+ }
145
+ return undefined;
146
+ }
147
+
148
+ export interface WriterScopeLease {
149
+ id: number;
150
+ agentName: string;
151
+ state: "queued" | "running" | "interrupting" | "parked" | "completed" | "failed" | "stopped";
152
+ lifecycleOperation?: "stop" | "settle";
153
+ retired?: boolean;
154
+ scope?: PhaseScope;
155
+ writeCapable?: boolean;
156
+ }
157
+
158
+ export interface WriterLeaseScopeOverlap {
159
+ lease: WriterScopeLease;
160
+ overlap: PhaseScopeOverlap;
161
+ }
162
+
163
+ const SCOPE_ADMISSION_STATES = new Set<WriterScopeLease["state"]>(["queued", "running", "interrupting", "parked"]);
164
+
165
+ /** Compare absolute normalized claims against active writer leases across caller cwds. */
166
+ export function findWriterLeaseScopeOverlap(
167
+ scope: PhaseScope,
168
+ leases: Iterable<WriterScopeLease>,
169
+ ): WriterLeaseScopeOverlap | undefined {
170
+ for (const lease of leases) {
171
+ const active = lease.lifecycleOperation === "settle" || SCOPE_ADMISSION_STATES.has(lease.state);
172
+ const writes = lease.writeCapable ?? lease.agentName !== "scout";
173
+ if (!active || lease.retired || !writes || !lease.scope) continue;
174
+ const overlap = findPhaseScopeOverlap(scope, lease.scope);
175
+ if (overlap) return { lease, overlap };
176
+ }
177
+ return undefined;
178
+ }