@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.
- package/CHANGELOG.md +41 -0
- package/README.md +165 -117
- package/index.ts +2 -0
- package/package.json +12 -10
- package/src/configuration/setup.ts +16 -14
- package/src/delegation/agents.ts +2 -1
- package/src/delegation/dispatch.ts +189 -38
- package/src/delegation/phase-scope.ts +178 -0
- package/src/delegation/prompt.ts +46 -36
- package/src/delegation/risk.ts +168 -0
- package/src/execution/rpc-control.ts +2 -29
- package/src/execution/rpc-run.ts +29 -30
- package/src/execution/spawn.ts +21 -20
- package/src/isolation/temp-hygiene.ts +7 -9
- package/src/isolation/worktree.ts +13 -88
- package/src/lifecycle/durable.ts +24 -8
- package/src/lifecycle/runtime.ts +27 -63
- package/src/lifecycle/thread-lifecycle.ts +67 -414
- package/src/lifecycle/thread-restore.ts +16 -35
- package/src/lifecycle/thread-shared.ts +9 -63
- package/src/lifecycle/tools.ts +86 -241
- package/src/presentation/announcements.ts +1 -1
- package/src/presentation/format.ts +6 -16
- package/src/presentation/monitor.ts +0 -91
- package/src/presentation/widget.ts +7 -17
- package/src/execution/session-fork.ts +0 -86
package/src/delegation/agents.ts
CHANGED
|
@@ -52,8 +52,9 @@ const READ_ONLY_TOOL_NAMES = new Set([
|
|
|
52
52
|
]);
|
|
53
53
|
export const SUBAGENT_TOOL_NAMES = [
|
|
54
54
|
"subagent",
|
|
55
|
-
"
|
|
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
|
|
138
|
-
*
|
|
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}
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
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 = (
|
|
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
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
594
|
+
item.isolation,
|
|
468
595
|
catalogAgent ? isWorktreeCapableAgent(catalogAgent) : undefined,
|
|
469
596
|
catalogAgent?.isolation,
|
|
470
597
|
),
|
|
471
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
662
|
+
single.agent,
|
|
663
|
+
single.task,
|
|
664
|
+
single.cwd,
|
|
519
665
|
defaultIsolationMode(
|
|
520
666
|
"single",
|
|
521
|
-
|
|
522
|
-
|
|
667
|
+
single.agent,
|
|
668
|
+
single.isolation,
|
|
523
669
|
singleCatalogAgent ? isWorktreeCapableAgent(singleCatalogAgent) : undefined,
|
|
524
670
|
singleCatalogAgent?.isolation,
|
|
525
671
|
),
|
|
526
|
-
{
|
|
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
|
+
}
|