@bermudi/pi-delegate 0.1.1 → 0.1.2
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 +3 -2
- package/agents.ts +163 -17
- package/concurrency.ts +70 -7
- package/delegate.ts +13 -0
- package/dispatch.ts +33 -7
- package/extension.ts +20 -4
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/leaf.ts +48 -0
- package/lifecycle.ts +83 -21
- package/manual.ts +37 -10
- package/package.json +4 -1
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/render-branches.ts +31 -13
- package/render-result.ts +12 -0
- package/runner.ts +237 -42
- package/schema.ts +203 -46
- package/status.ts +68 -2
- package/task-resolution.ts +101 -65
- package/tickets.ts +173 -62
- package/tools.ts +16 -15
- package/types.ts +54 -13
package/README.md
CHANGED
|
@@ -126,8 +126,9 @@ over an installed extension.
|
|
|
126
126
|
- **Resumed subagent** — A subagent rehydrated from a previous session `.jsonl`
|
|
127
127
|
via `resumeFrom`. It can also be pooled by providing a `sessionId`.
|
|
128
128
|
- **Async ticket** — A background execution handle returned when top-level
|
|
129
|
-
`async: true` is used. Poll or cancel tickets with top-level
|
|
130
|
-
|
|
129
|
+
`async: true` is used. Poll, wait, or cancel tickets with top-level
|
|
130
|
+
`ticketAction: "poll"`, `ticketAction: "wait"` (blocks until the ticket settles;
|
|
131
|
+
optional `timeoutMs`), or `ticketAction: "cancel"`.
|
|
131
132
|
- **Skill** — A `SKILL.md` instruction bundle injected into the subagent system
|
|
132
133
|
prompt. Skills are text instructions only; they do not unlock additional
|
|
133
134
|
tools.
|
package/agents.ts
CHANGED
|
@@ -75,11 +75,15 @@ export function parseFrontmatter(
|
|
|
75
75
|
|
|
76
76
|
// A bare `*` is a YAML alias indicator and is invalid as a scalar, so
|
|
77
77
|
// `tools: *` (the full-agent shorthand) would throw. Quote any value that is
|
|
78
|
-
// exactly
|
|
79
|
-
//
|
|
80
|
-
//
|
|
78
|
+
// exactly `*`, including when it has a trailing YAML comment, so it parses
|
|
79
|
+
// as the string "*", which resolveFrontmatterTools then expands via
|
|
80
|
+
// TOOL_GROUPS. (A `*` mid-scalar, e.g. `use * here`, is a legal plain scalar
|
|
81
|
+
// and needs no quoting.)
|
|
81
82
|
const sanitized = sanitizeYamlScalars(
|
|
82
|
-
yamlString.replace(
|
|
83
|
+
yamlString.replace(
|
|
84
|
+
/^([ \t]*[\w-]+:[ \t]*)\*([ \t]*(?:#[^\r\n]*)?)(?=\r?$)/gm,
|
|
85
|
+
'$1"*"$2',
|
|
86
|
+
),
|
|
83
87
|
);
|
|
84
88
|
|
|
85
89
|
try {
|
|
@@ -140,11 +144,13 @@ const CLAUDE_TOOL_ALIASES: Record<string, string> = {
|
|
|
140
144
|
|
|
141
145
|
/** Parse a `tools:` frontmatter value into a resolved tool list.
|
|
142
146
|
* Omitted or blank → inherit the full agent set (`*`), matching CC/OpenCode/
|
|
143
|
-
* Devin convention.
|
|
144
|
-
*
|
|
147
|
+
* Devin convention. For Claude imports (`aliasMap` set), an explicit
|
|
148
|
+
* allowlist that maps to an empty set means the agent gets no tools, with a
|
|
149
|
+
* warning, instead of silently inheriting anything. */
|
|
145
150
|
function resolveFrontmatterTools(
|
|
146
151
|
raw: string | undefined,
|
|
147
152
|
aliasMap?: Record<string, string>,
|
|
153
|
+
filePath?: string,
|
|
148
154
|
): string[] {
|
|
149
155
|
if (!raw) return DEFAULT_TOOLS; // omitted/blank → inherit *
|
|
150
156
|
const names = raw
|
|
@@ -154,11 +160,25 @@ function resolveFrontmatterTools(
|
|
|
154
160
|
if (!names.length) return DEFAULT_TOOLS;
|
|
155
161
|
const mapped = aliasMap
|
|
156
162
|
? names
|
|
157
|
-
.map((n) =>
|
|
163
|
+
.map((n) => {
|
|
164
|
+
const lower = n.toLowerCase();
|
|
165
|
+
// Preserve delegate tool-group shorthands when importing Claude files.
|
|
166
|
+
if (lower === "*" || lower === "ro") return lower;
|
|
167
|
+
return aliasMap[lower] ?? null;
|
|
168
|
+
})
|
|
158
169
|
.filter((n): n is string => n !== null)
|
|
159
170
|
: names;
|
|
160
|
-
// Empty after aliasing (e.g. a Claude agent listing only WebSearch)
|
|
161
|
-
|
|
171
|
+
// Empty after aliasing (e.g. a Claude agent listing only WebSearch) must not
|
|
172
|
+
// silently inherit the full mutating set. An explicit allowlist that maps to
|
|
173
|
+
// nothing means the agent gets no tools.
|
|
174
|
+
if (!mapped.length) {
|
|
175
|
+
const where = filePath ? ` (${filePath})` : "";
|
|
176
|
+
console.warn(
|
|
177
|
+
`[delegate] Claude agent allowlist contained no mappable tools${where}; agent will have no tools.`,
|
|
178
|
+
);
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
return resolveToolGroups(mapped);
|
|
162
182
|
}
|
|
163
183
|
|
|
164
184
|
/** Parse and alias a comma-separated Claude tool list into delegate tool names,
|
|
@@ -201,15 +221,19 @@ export function loadAgentFile(filePath: string): AgentConfig | null {
|
|
|
201
221
|
};
|
|
202
222
|
}
|
|
203
223
|
|
|
204
|
-
/** Variant for `.claude/agents/*.md` files.
|
|
224
|
+
/** Variant for `.claude/agents/*.md` files. Claude-specific adaptations:
|
|
205
225
|
* - Maps capitalized tool names (Read/Glob/…) to delegate tools, dropping
|
|
206
226
|
* unmappable ones (WebSearch, TodoWrite, …). Omitted `tools` inherits `*`.
|
|
207
227
|
* - Honors `disallowedTools` as a denylist layered on top of the resolved
|
|
208
228
|
* set (Claude semantics: denylist applies whether or not an allowlist is
|
|
209
229
|
* set). Since delegate has no runtime denylist, we bake it into `tools` at
|
|
210
|
-
* import time.
|
|
211
|
-
* `
|
|
212
|
-
*
|
|
230
|
+
* import time. `bash` is special: in the delegate tool set it subsumes
|
|
231
|
+
* `grep`/`find`/`ls` (a shell can run all of them), so an imported `Bash`
|
|
232
|
+
* resolves to `bash` alone. If the denylist then removes `Bash` from an
|
|
233
|
+
* inherited (omitted) allowlist, the dedicated read-only search tools it
|
|
234
|
+
* was subsuming are restored. If the user explicitly allowlisted `Bash`,
|
|
235
|
+
* removing it gives them no extra tools — they never asked for `grep`,
|
|
236
|
+
* `find`, or `ls`.
|
|
213
237
|
* - `model: inherit` (Claude's default) is mapped to "omit" so the agent
|
|
214
238
|
* inherits the parent model; passing it through verbatim would crash
|
|
215
239
|
* resolveModel() with "model 'inherit' is not available". */
|
|
@@ -223,11 +247,32 @@ export function loadClaudeAgentFile(filePath: string): AgentConfig | null {
|
|
|
223
247
|
const { data, body } = parseFrontmatter(content, filePath);
|
|
224
248
|
if (!data.name || !data.description) return null;
|
|
225
249
|
|
|
226
|
-
|
|
250
|
+
// Track whether the user wrote an explicit `tools:` allowlist. Omitted or
|
|
251
|
+
// blank means "inherit the full default set" (`*`), and only in that case
|
|
252
|
+
// does denying `bash` restore the search tools it was subsuming.
|
|
253
|
+
const explicitTools = data.tools != null && data.tools.trim() !== "";
|
|
254
|
+
let tools = resolveFrontmatterTools(
|
|
255
|
+
data.tools,
|
|
256
|
+
CLAUDE_TOOL_ALIASES,
|
|
257
|
+
filePath,
|
|
258
|
+
);
|
|
227
259
|
// disallowedTools is a denylist applied after the allowlist resolves.
|
|
228
|
-
//
|
|
260
|
+
// `bash` subsumes `grep`/`find`/`ls`: if the denylist removes `bash` from an
|
|
261
|
+
// inherited (omitted) allowlist, restore the dedicated read-only search
|
|
262
|
+
// tools it was covering, but only the ones not already present and not also
|
|
263
|
+
// explicitly denied. If `bash` came from an explicit allowlist, do not add
|
|
264
|
+
// search tools the user never requested.
|
|
229
265
|
const denied = new Set(mapClaudeToolNames(data.disallowedTools));
|
|
230
|
-
if (denied.size)
|
|
266
|
+
if (denied.size) {
|
|
267
|
+
const hadBash = tools.includes("bash");
|
|
268
|
+
tools = tools.filter((t) => !denied.has(t));
|
|
269
|
+
if (hadBash && !tools.includes("bash") && !explicitTools) {
|
|
270
|
+
const restored = ["grep", "find", "ls"].filter(
|
|
271
|
+
(t) => !tools.includes(t) && !denied.has(t),
|
|
272
|
+
);
|
|
273
|
+
if (restored.length) tools = tools.concat(restored);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
231
276
|
|
|
232
277
|
return {
|
|
233
278
|
name: data.name,
|
|
@@ -324,6 +369,95 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
|
|
|
324
369
|
export const DEFAULT_SUBAGENT_SYSTEM_PROMPT =
|
|
325
370
|
"You are a helpful coding assistant.";
|
|
326
371
|
|
|
372
|
+
/** First line of Pi's default generated system prompt. Used to detect an
|
|
373
|
+
* inherited fully-assembled parent prompt that contains a stale tool
|
|
374
|
+
* inventory. */
|
|
375
|
+
const PI_DEFAULT_PROMPT_PREFIX =
|
|
376
|
+
"You are an expert coding assistant operating inside pi, a coding agent harness.";
|
|
377
|
+
|
|
378
|
+
const PI_DEFAULT_PROMPT_INTRO = `${PI_DEFAULT_PROMPT_PREFIX} You help users by reading files, executing commands, editing code, and writing new files.`;
|
|
379
|
+
|
|
380
|
+
function buildCapabilityProse(tools: string[]): string {
|
|
381
|
+
const set = new Set(tools);
|
|
382
|
+
const parts: string[] = [];
|
|
383
|
+
if (set.has("read")) parts.push("reading files");
|
|
384
|
+
if (set.has("grep") || set.has("find")) parts.push("searching code");
|
|
385
|
+
if (set.has("ls")) parts.push("listing directories");
|
|
386
|
+
if (set.has("bash")) parts.push("executing commands");
|
|
387
|
+
if (set.has("edit")) parts.push("editing code");
|
|
388
|
+
if (set.has("write")) parts.push("writing new files");
|
|
389
|
+
if (parts.length === 0) return "following the provided instructions";
|
|
390
|
+
if (parts.length === 1) return parts[0]!;
|
|
391
|
+
if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
|
|
392
|
+
return `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]!}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function buildIntro(tools: string[]): string {
|
|
396
|
+
return `${PI_DEFAULT_PROMPT_PREFIX} You help users by ${buildCapabilityProse(tools)}.`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const PI_DEFAULT_AVAILABLE_TOOLS_MARKER = "\n\nAvailable tools:\n";
|
|
400
|
+
const PI_DEFAULT_PI_DOCS_MARKER = "\n\nPi documentation";
|
|
401
|
+
const PI_DEFAULT_CWD_PREFIX = "\nCurrent working directory: ";
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Strip the default tool inventory, guidelines, and parent working directory
|
|
405
|
+
* line from an inherited parent prompt before it becomes a child's custom
|
|
406
|
+
* system prompt.
|
|
407
|
+
*
|
|
408
|
+
* Pi's `buildSystemPrompt()` synthesizes an "Available tools" list, tool
|
|
409
|
+
* guidelines, and a "Current working directory" line when no custom `SYSTEM.md`
|
|
410
|
+
* is in use. That assembled prompt is what the parent's `getSystemPrompt()`
|
|
411
|
+
* returns. If we pass it through as the child's custom prompt, the child keeps
|
|
412
|
+
* the parent's tool advertisement (and cwd) even when its actual tool set is
|
|
413
|
+
* restricted (e.g. `tools: ["ro"]`) and it is running in a different
|
|
414
|
+
* directory. AgentSession will append the correct child cwd when it rebuilds
|
|
415
|
+
* the system prompt, so the inherited parent cwd line is removed here.
|
|
416
|
+
*
|
|
417
|
+
* The function detects the default prompt by its stable intro, then removes
|
|
418
|
+
* only the "Available tools" + "Guidelines" region and the trailing
|
|
419
|
+
* "Current working directory" line, preserving the rest of the parent base
|
|
420
|
+
* (and any append/project context/skills that the resource loader added).
|
|
421
|
+
*
|
|
422
|
+
* Custom parent `SYSTEM.md` prompts are left untouched: they do not start with
|
|
423
|
+
* the default intro, so no heuristic stripping occurs.
|
|
424
|
+
*/
|
|
425
|
+
function sanitizeParentToolInventory(
|
|
426
|
+
prompt: string | undefined,
|
|
427
|
+
tools: string[] = DEFAULT_TOOLS,
|
|
428
|
+
): string | undefined {
|
|
429
|
+
if (!prompt) return prompt;
|
|
430
|
+
|
|
431
|
+
// Only act on Pi's default generated prompt. Custom prompts may mention tools
|
|
432
|
+
// intentionally and should not be rewritten by a heuristic.
|
|
433
|
+
const trimmed = prompt.trimStart();
|
|
434
|
+
if (!trimmed.startsWith(PI_DEFAULT_PROMPT_INTRO)) return prompt;
|
|
435
|
+
|
|
436
|
+
const availableStart = prompt.indexOf(PI_DEFAULT_AVAILABLE_TOOLS_MARKER);
|
|
437
|
+
if (availableStart < 0) return prompt;
|
|
438
|
+
|
|
439
|
+
const piDocsStart = prompt.indexOf(
|
|
440
|
+
PI_DEFAULT_PI_DOCS_MARKER,
|
|
441
|
+
availableStart + PI_DEFAULT_AVAILABLE_TOOLS_MARKER.length,
|
|
442
|
+
);
|
|
443
|
+
if (piDocsStart < 0) return prompt;
|
|
444
|
+
|
|
445
|
+
const intro = buildIntro(tools);
|
|
446
|
+
const prefix = prompt
|
|
447
|
+
.slice(0, availableStart)
|
|
448
|
+
.replace(PI_DEFAULT_PROMPT_INTRO, intro);
|
|
449
|
+
let base = prefix + prompt.slice(piDocsStart);
|
|
450
|
+
|
|
451
|
+
// Remove the inherited parent cwd line; AgentSession adds the correct child
|
|
452
|
+
// cwd when it assembles the final system prompt.
|
|
453
|
+
const cwdIndex = base.lastIndexOf(PI_DEFAULT_CWD_PREFIX);
|
|
454
|
+
if (cwdIndex >= 0) {
|
|
455
|
+
return base.slice(0, cwdIndex).replace(/\n+$/, "");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return base;
|
|
459
|
+
}
|
|
460
|
+
|
|
327
461
|
function firstNonBlank(
|
|
328
462
|
...values: Array<string | undefined>
|
|
329
463
|
): string | undefined {
|
|
@@ -338,6 +472,7 @@ export function buildSubagentSystemPrompt(options: {
|
|
|
338
472
|
agentSystemPrompt?: string;
|
|
339
473
|
parentSystemPrompt?: string;
|
|
340
474
|
pooledSystemPrompt?: string;
|
|
475
|
+
tools?: string[];
|
|
341
476
|
}): string {
|
|
342
477
|
// Pooled agents already have a frozen prompt baked into their session state.
|
|
343
478
|
// Return it unchanged so repeated sessionId calls do not re-resolve.
|
|
@@ -347,11 +482,22 @@ export function buildSubagentSystemPrompt(options: {
|
|
|
347
482
|
// from this custom prompt + its own resource-loader discovery (skills,
|
|
348
483
|
// AGENTS.md, active-tool snippets). We previously appended skills/AGENTS.md
|
|
349
484
|
// here; that duplicated AgentSession's work.
|
|
485
|
+
// An inherited parent prompt may be Pi's fully-assembled default prompt,
|
|
486
|
+
// which carries the parent's "Available tools" list and tool guidelines. A
|
|
487
|
+
// restricted child (e.g. `tools: ["ro"]`) must not advertise the parent's
|
|
488
|
+
// mutating tools as callable. Sanitize the parent prompt only; task and agent
|
|
489
|
+
// prompts are intentionally authored and are left untouched.
|
|
490
|
+
const resolvedTools = resolveToolGroups(options.tools ?? DEFAULT_TOOLS);
|
|
491
|
+
const parentSystemPrompt = sanitizeParentToolInventory(
|
|
492
|
+
options.parentSystemPrompt,
|
|
493
|
+
resolvedTools,
|
|
494
|
+
);
|
|
495
|
+
|
|
350
496
|
const base =
|
|
351
497
|
firstNonBlank(
|
|
352
498
|
options.taskSystemPrompt,
|
|
353
499
|
options.agentSystemPrompt,
|
|
354
|
-
|
|
500
|
+
parentSystemPrompt,
|
|
355
501
|
) ?? DEFAULT_SUBAGENT_SYSTEM_PROMPT;
|
|
356
502
|
|
|
357
503
|
return base;
|
package/concurrency.ts
CHANGED
|
@@ -10,21 +10,75 @@ import { getMaxConcurrent } from "./config.ts";
|
|
|
10
10
|
|
|
11
11
|
let globalConcurrencyLimit = Math.max(1, getMaxConcurrent());
|
|
12
12
|
let globalConcurrencyRunning = 0;
|
|
13
|
-
const globalConcurrencyWaiters: Array<() => void> = [];
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
interface GlobalWaiter {
|
|
15
|
+
/** Resolve with `true` when a slot was acquired, `false` when the signal
|
|
16
|
+
* aborted while queued (the caller must not release a slot it never held). */
|
|
17
|
+
resolve: (acquired: boolean) => void;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
onAbort: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const globalConcurrencyWaiters: GlobalWaiter[] = [];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Acquire a global concurrency slot.
|
|
26
|
+
*
|
|
27
|
+
* Resolves `true` when a slot is held (caller must pair with `releaseGlobal`),
|
|
28
|
+
* or `false` when `signal` aborted before a slot could be acquired. Waiters are
|
|
29
|
+
* abort-aware: an abort removes the queued waiter immediately and resolves
|
|
30
|
+
* `false`, so a cancelled ticket is not stranded in "cancelling" until some
|
|
31
|
+
* unrelated task happens to release capacity. The caller still invokes its fn
|
|
32
|
+
* on `false` — the production fn (runResolvedTask) observes the aborted signal
|
|
33
|
+
* at entry and returns an "Aborted" TaskResult without consuming a slot.
|
|
34
|
+
*/
|
|
35
|
+
function acquireGlobal(signal?: AbortSignal): Promise<boolean> {
|
|
36
|
+
// Already aborted: never queue (a slot would be wasted on a task that can
|
|
37
|
+
// only report Aborted) and never increment — nothing to release later.
|
|
38
|
+
if (signal?.aborted) return Promise.resolve(false);
|
|
16
39
|
if (globalConcurrencyRunning < globalConcurrencyLimit) {
|
|
17
40
|
globalConcurrencyRunning++;
|
|
18
|
-
return Promise.resolve();
|
|
41
|
+
return Promise.resolve(true);
|
|
19
42
|
}
|
|
20
|
-
return new Promise<
|
|
43
|
+
return new Promise<boolean>((resolve) => {
|
|
44
|
+
const waiter: GlobalWaiter = {
|
|
45
|
+
resolve,
|
|
46
|
+
signal,
|
|
47
|
+
onAbort: () => {
|
|
48
|
+
// Remove ourselves from the queue without taking a slot. The caller's
|
|
49
|
+
// fn sees the aborted signal at entry and settles promptly.
|
|
50
|
+
const index = globalConcurrencyWaiters.indexOf(waiter);
|
|
51
|
+
if (index === -1) return; // already woken by releaseGlobal
|
|
52
|
+
globalConcurrencyWaiters.splice(index, 1);
|
|
53
|
+
waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
54
|
+
resolve(false);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
if (signal) {
|
|
58
|
+
signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
59
|
+
}
|
|
60
|
+
globalConcurrencyWaiters.push(waiter);
|
|
61
|
+
});
|
|
21
62
|
}
|
|
22
63
|
|
|
23
64
|
function releaseGlobal(): void {
|
|
24
65
|
globalConcurrencyRunning--;
|
|
66
|
+
// Never hand a slot to a waiter whose signal already aborted. The abort
|
|
67
|
+
// listener normally removes it synchronously during abort() dispatch; this
|
|
68
|
+
// drain is defensive for any ordering edge.
|
|
69
|
+
while (
|
|
70
|
+
globalConcurrencyWaiters.length > 0 &&
|
|
71
|
+
globalConcurrencyWaiters[0]!.signal?.aborted
|
|
72
|
+
) {
|
|
73
|
+
const w = globalConcurrencyWaiters.shift()!;
|
|
74
|
+
w.signal?.removeEventListener("abort", w.onAbort);
|
|
75
|
+
w.resolve(false);
|
|
76
|
+
}
|
|
25
77
|
if (globalConcurrencyWaiters.length > 0) {
|
|
26
78
|
globalConcurrencyRunning++;
|
|
27
|
-
globalConcurrencyWaiters.shift()
|
|
79
|
+
const w = globalConcurrencyWaiters.shift()!;
|
|
80
|
+
w.signal?.removeEventListener("abort", w.onAbort);
|
|
81
|
+
w.resolve(true);
|
|
28
82
|
}
|
|
29
83
|
}
|
|
30
84
|
|
|
@@ -109,9 +163,18 @@ export async function mapConcurrentByModel<T, R>(
|
|
|
109
163
|
groupItems,
|
|
110
164
|
group.limit,
|
|
111
165
|
async (_item, localIdx) => {
|
|
112
|
-
|
|
166
|
+
const globalIdx = group.indices[localIdx]!;
|
|
167
|
+
const acquired = await acquireGlobal(signal);
|
|
168
|
+
if (!acquired) {
|
|
169
|
+
// Aborted while queued for a global slot: we hold no slot, so we
|
|
170
|
+
// must NOT release one. Still invoke fn — runResolvedTask observes
|
|
171
|
+
// the aborted signal at entry and returns an "Aborted" TaskResult,
|
|
172
|
+
// keeping the results array dense for the sync dereference path
|
|
173
|
+
// (the same contract as mapConcurrent's worker loop).
|
|
174
|
+
results[globalIdx] = await fn(_item, globalIdx);
|
|
175
|
+
return results[globalIdx];
|
|
176
|
+
}
|
|
113
177
|
try {
|
|
114
|
-
const globalIdx = group.indices[localIdx]!;
|
|
115
178
|
results[globalIdx] = await fn(_item, globalIdx);
|
|
116
179
|
return results[globalIdx];
|
|
117
180
|
} finally {
|
package/delegate.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { default } from "./extension.ts";
|
|
|
3
3
|
export type {
|
|
4
4
|
AgentConfig,
|
|
5
5
|
SessionAction,
|
|
6
|
+
TicketAction,
|
|
6
7
|
DelegateAction,
|
|
7
8
|
DelegateArguments,
|
|
8
9
|
TaskDef,
|
|
@@ -58,6 +59,7 @@ export {
|
|
|
58
59
|
ticketRegistry,
|
|
59
60
|
sweepTickets,
|
|
60
61
|
cancelTicketForShutdown,
|
|
62
|
+
requestTicketCancel,
|
|
61
63
|
isSessionBusy,
|
|
62
64
|
handlePoll,
|
|
63
65
|
handleCancel,
|
|
@@ -67,6 +69,13 @@ export {
|
|
|
67
69
|
resolveFinalTicketStatus,
|
|
68
70
|
formatCompletedTicket,
|
|
69
71
|
} from "./tickets.ts";
|
|
72
|
+
export type { TicketDelivery } from "./tickets.ts";
|
|
73
|
+
export {
|
|
74
|
+
recordTreeNavigation,
|
|
75
|
+
getCurrentLeafId,
|
|
76
|
+
resetLeafTracking,
|
|
77
|
+
isCrossLeafTicket,
|
|
78
|
+
} from "./leaf.ts";
|
|
70
79
|
export { runAgentSession } from "./runner.ts";
|
|
71
80
|
export {
|
|
72
81
|
activeTicketSummary,
|
|
@@ -76,6 +85,8 @@ export {
|
|
|
76
85
|
syncDelegateStatus,
|
|
77
86
|
notifyActiveTicketsOnSettled,
|
|
78
87
|
guardSessionReplacement,
|
|
88
|
+
guardTreeNavigation,
|
|
89
|
+
notifyCrossLeafDelivery,
|
|
79
90
|
} from "./status.ts";
|
|
80
91
|
export type { ActiveTicketSummary } from "./status.ts";
|
|
81
92
|
export { getHostDeps, invalidateHostDepsCache } from "./host.ts";
|
|
@@ -99,6 +110,8 @@ export {
|
|
|
99
110
|
indent,
|
|
100
111
|
formatFailedTask,
|
|
101
112
|
formatCompletedTask,
|
|
113
|
+
findTouchedOverlaps,
|
|
114
|
+
formatTouchedOverlapWarning,
|
|
102
115
|
} from "./format.ts";
|
|
103
116
|
export {
|
|
104
117
|
parseFrontmatter,
|
package/dispatch.ts
CHANGED
|
@@ -10,12 +10,19 @@ import {
|
|
|
10
10
|
notifyWaiters,
|
|
11
11
|
} from "./tickets.ts";
|
|
12
12
|
import { getConcurrencyLimit, getMaxAsyncTickets } from "./config.ts";
|
|
13
|
+
import { getCurrentLeafId } from "./leaf.ts";
|
|
13
14
|
import { getModelKey, mapConcurrentByModel } from "./concurrency.ts";
|
|
14
15
|
import { sumUsage } from "./usage.ts";
|
|
15
16
|
import { runResolvedTask, updateProgressFromRun } from "./lifecycle.ts";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
fmtDuration,
|
|
19
|
+
formatCompletedTask,
|
|
20
|
+
trunc,
|
|
21
|
+
findTouchedOverlaps,
|
|
22
|
+
formatTouchedOverlapWarning,
|
|
23
|
+
} from "./format.ts";
|
|
17
24
|
import { validateDelegateOperation } from "./schema.ts";
|
|
18
|
-
import { syncDelegateStatus } from "./status.ts";
|
|
25
|
+
import { notifyCrossLeafDelivery, syncDelegateStatus } from "./status.ts";
|
|
19
26
|
import { validateTasks, resolveTasks } from "./task-resolution.ts";
|
|
20
27
|
import type {
|
|
21
28
|
AgentConfig,
|
|
@@ -57,9 +64,10 @@ export function validateDelegateOperationResult(
|
|
|
57
64
|
/** Build the initial per-task progress rows from resolved tasks. */
|
|
58
65
|
export function initProgress(resolved: ResolvedTask[]): TaskProgress[] {
|
|
59
66
|
return resolved.map((t, i) => ({
|
|
67
|
+
id: t.id,
|
|
60
68
|
index: i,
|
|
61
69
|
agent: t.agentName,
|
|
62
|
-
task: trunc(t.prompt || t.
|
|
70
|
+
task: trunc(t.prompt || t.sessionAction || "", 50),
|
|
63
71
|
status: "pending" as const,
|
|
64
72
|
durationMs: 0,
|
|
65
73
|
tokens: 0,
|
|
@@ -182,6 +190,15 @@ export async function dispatchDelegate(
|
|
|
182
190
|
});
|
|
183
191
|
}
|
|
184
192
|
|
|
193
|
+
/** Deliver a settled ticket and, when leaf affinity downgraded delivery to a
|
|
194
|
+
* non-waking `nextTurn` message, tell the human — otherwise the completion is
|
|
195
|
+
* silent apart from the footer clearing. */
|
|
196
|
+
function finishTicketDelivery(pi: ExtensionAPI, ticket: AsyncTicket): void {
|
|
197
|
+
if (deliverTicketResults(pi, ticket) === "deferred") {
|
|
198
|
+
notifyCrossLeafDelivery(ticket);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
185
202
|
/** Fire-and-forget background execution. Registers an `AsyncTicket`, kicks off
|
|
186
203
|
* the concurrent run, and returns the ticket acknowledgment immediately.
|
|
187
204
|
* Results are delivered via `deliverTicketResults` when all tasks settle. */
|
|
@@ -216,6 +233,9 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
216
233
|
progress: [...progress],
|
|
217
234
|
controller,
|
|
218
235
|
parentModelId,
|
|
236
|
+
// Leaf affinity for delivery: a ticket that outlives a /tree navigation
|
|
237
|
+
// must not wake the agent on the branch the user moved to (issue #30).
|
|
238
|
+
spawnLeafId: getCurrentLeafId(),
|
|
219
239
|
};
|
|
220
240
|
ticketRegistry.set(ticketId, ticket);
|
|
221
241
|
// Footer visibility for the new background work (see status.ts). Uses the
|
|
@@ -289,7 +309,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
289
309
|
syncTicketBusyIndex(ticket);
|
|
290
310
|
}
|
|
291
311
|
syncDelegateStatus();
|
|
292
|
-
|
|
312
|
+
finishTicketDelivery(pi, ticket);
|
|
293
313
|
})
|
|
294
314
|
.catch((err) => {
|
|
295
315
|
// Defense-in-depth — should not happen if individual tasks catch properly.
|
|
@@ -304,7 +324,7 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
304
324
|
ticket.completedAt = Date.now();
|
|
305
325
|
syncTicketBusyIndex(ticket);
|
|
306
326
|
syncDelegateStatus();
|
|
307
|
-
|
|
327
|
+
finishTicketDelivery(pi, ticket);
|
|
308
328
|
});
|
|
309
329
|
|
|
310
330
|
return {
|
|
@@ -316,8 +336,8 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
316
336
|
`${resolved.length} task(s) dispatched · ${runningCount + 1}/${getMaxAsyncTickets()} async slots in use`,
|
|
317
337
|
"",
|
|
318
338
|
"Completed task results are available via poll. Final results delivered automatically when all tasks complete.",
|
|
319
|
-
`Check progress: delegate({
|
|
320
|
-
`Cancel if needed: delegate({
|
|
339
|
+
`Check progress: delegate({ ticketAction: "poll", ticket: "${ticketId}" }) — avoid polling in a tight loop`,
|
|
340
|
+
`Cancel if needed: delegate({ ticketAction: "cancel", ticket: "${ticketId}", force: true }) — first call without force is a preview`,
|
|
321
341
|
].join("\n"),
|
|
322
342
|
},
|
|
323
343
|
],
|
|
@@ -376,6 +396,11 @@ export async function dispatchSync(
|
|
|
376
396
|
parts.push(...formatCompletedTask(t, r));
|
|
377
397
|
}
|
|
378
398
|
|
|
399
|
+
const overlapWarning = formatTouchedOverlapWarning(
|
|
400
|
+
findTouchedOverlaps(finalResults),
|
|
401
|
+
);
|
|
402
|
+
if (overlapWarning) parts.push("", overlapWarning);
|
|
403
|
+
|
|
379
404
|
return {
|
|
380
405
|
content: [{ type: "text", text: parts.join("\n\n") }],
|
|
381
406
|
details: {
|
|
@@ -383,6 +408,7 @@ export async function dispatchSync(
|
|
|
383
408
|
results: finalResults,
|
|
384
409
|
progress,
|
|
385
410
|
parentModel: parentModelId,
|
|
411
|
+
overlapWarning: overlapWarning || undefined,
|
|
386
412
|
},
|
|
387
413
|
// Aggregate subagent spend so Pi folds it into the parent's
|
|
388
414
|
// session/footer totals. Sync dispatch only — async results arrive via a
|
package/extension.ts
CHANGED
|
@@ -19,12 +19,14 @@ import {
|
|
|
19
19
|
import { renderDelegateCall, renderDelegateResult } from "./render-result.ts";
|
|
20
20
|
import { hostCompatError } from "./host-compat.ts";
|
|
21
21
|
import { invalidateHostDepsCache } from "./host.ts";
|
|
22
|
+
import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
|
|
22
23
|
import { closeAllPooledAgents } from "./pool.ts";
|
|
23
24
|
import {
|
|
24
25
|
activeTicketSummary,
|
|
25
26
|
clearDelegateStatusContext,
|
|
26
27
|
describeActiveTickets,
|
|
27
28
|
guardSessionReplacement,
|
|
29
|
+
guardTreeNavigation,
|
|
28
30
|
notifyActiveTicketsOnSettled,
|
|
29
31
|
syncDelegateStatus,
|
|
30
32
|
} from "./status.ts";
|
|
@@ -36,7 +38,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
36
38
|
name: "delegate",
|
|
37
39
|
label: "Delegate to Subagents",
|
|
38
40
|
description:
|
|
39
|
-
"Run parallel subagents via tasks:[{prompt}]. Sync returns results; async
|
|
41
|
+
"Run parallel subagents via tasks:[{prompt}]. Sync returns results; async=ticket. tasks:[]=full manual.",
|
|
40
42
|
parameters: delegateArgumentsSchema,
|
|
41
43
|
// Runs before schema validation — recovers stringified `tasks` arrays
|
|
42
44
|
// (a common model mistake that would otherwise be rejected upstream).
|
|
@@ -58,12 +60,12 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
58
60
|
if (operationResult) return operationResult;
|
|
59
61
|
|
|
60
62
|
// ── Poll action ───────────────────────────────────────────────────
|
|
61
|
-
if (params.
|
|
63
|
+
if (params.ticketAction === "poll") {
|
|
62
64
|
return handlePoll(params, ctx);
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
// ── Cancel action ─────────────────────────────────────────────────
|
|
66
|
-
if (params.
|
|
68
|
+
if (params.ticketAction === "cancel") {
|
|
67
69
|
const result = handleCancel(params);
|
|
68
70
|
// A forced cancel flips the ticket to "cancelling" — keep the
|
|
69
71
|
// footer status in step (deduped; the preview path is a no-op).
|
|
@@ -72,7 +74,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
// ── Wait action ────────────────────────────────────────────────────
|
|
75
|
-
if (params.
|
|
77
|
+
if (params.ticketAction === "wait") {
|
|
76
78
|
return handleWait(params, signal, onUpdate, ctx);
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -140,6 +142,17 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
140
142
|
guardSessionReplacement(ctx, "fork"),
|
|
141
143
|
);
|
|
142
144
|
|
|
145
|
+
// /tree navigation stays inside the same session: nothing is torn down and
|
|
146
|
+
// live tickets keep running, but their results would land on the branch the
|
|
147
|
+
// user moves to. Ask first, and record the new leaf either way so delivery
|
|
148
|
+
// can detect the mismatch (issue #30). `session_tree` also fires for
|
|
149
|
+
// extension-driven ctx.navigateTree, which never reaches the guard.
|
|
150
|
+
pi.on("session_before_tree", (_event, ctx) => guardTreeNavigation(ctx));
|
|
151
|
+
pi.on("session_tree", (event, ctx) => {
|
|
152
|
+
recordTreeNavigation(event.newLeafId);
|
|
153
|
+
syncDelegateStatus(ctx);
|
|
154
|
+
});
|
|
155
|
+
|
|
143
156
|
// ── Session shutdown: abort tickets and dispose live pooled sessions ──
|
|
144
157
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
145
158
|
// Quit and /reload kill background work with no cancellable hook, so
|
|
@@ -167,6 +180,9 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
167
180
|
// tickets keep unwinding asynchronously and must find no cached ctx (or
|
|
168
181
|
// captured pi) to touch. See the "cancelled"-at-entry guard in dispatch.
|
|
169
182
|
clearDelegateStatusContext();
|
|
183
|
+
// A replacement session starts on its own leaf; stale tracking would make
|
|
184
|
+
// every ticket look cross-leaf (or, worse, look same-leaf by accident).
|
|
185
|
+
resetLeafTracking();
|
|
170
186
|
// Do NOT clear the ticket registry here — completed tickets are retained
|
|
171
187
|
// until their TTL cleanup. Pooled AgentSessions, however, own listeners
|
|
172
188
|
// and must be disposed before the parent session exits.
|
package/file-tracking.ts
CHANGED
|
@@ -2,9 +2,19 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { ToolActivity } from "./types.ts";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Return absolute paths reported as changed by Git in the task cwd.
|
|
7
|
+
*
|
|
8
|
+
* Touched-file tracking is best-effort, not authoritative. On success this
|
|
9
|
+
* returns the set of changed paths (possibly empty for a clean repo). On
|
|
10
|
+
* failure (non-git directory, git unavailable, timeout) it returns `undefined`
|
|
11
|
+
* so callers can tell "git failed" from "clean repo". A failed baseline
|
|
12
|
+
* suppresses git-based attribution in the runner; only explicit edit/write tool
|
|
13
|
+
* activity is captured by {@link extractTouchedFromActivities}.
|
|
14
|
+
*/
|
|
15
|
+
export async function getGitChangedFiles(
|
|
16
|
+
cwd: string,
|
|
17
|
+
): Promise<Set<string> | undefined> {
|
|
8
18
|
try {
|
|
9
19
|
const runGit = (args: string[]) =>
|
|
10
20
|
new Promise<string>((resolve, reject) => {
|
|
@@ -37,11 +47,22 @@ export async function getGitChangedFiles(cwd: string): Promise<Set<string>> {
|
|
|
37
47
|
}
|
|
38
48
|
return files;
|
|
39
49
|
} catch {
|
|
40
|
-
return
|
|
50
|
+
return undefined;
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Extract file paths from explicit edit/write tool calls in the activity log.
|
|
56
|
+
*
|
|
57
|
+
* This is the reliable, activity-based contribution to touched-file tracking.
|
|
58
|
+
* Only completed, successful tool calls are counted: an activity must have a
|
|
59
|
+
* terminal `result` and `result.isError` must be false. Interrupted or in-flight
|
|
60
|
+
* calls (no `result`) and failed calls (`result.isError` true) are skipped,
|
|
61
|
+
* because they did not actually mutate the file. bash mutations are NOT captured
|
|
62
|
+
* here; they are only captured by git diff when the task cwd is inside a git
|
|
63
|
+
* repo with git available. The combined touchedFiles list is therefore a lower
|
|
64
|
+
* bound: absence does not mean a file was unchanged.
|
|
65
|
+
*/
|
|
45
66
|
export function extractTouchedFromActivities(
|
|
46
67
|
activities: ToolActivity[],
|
|
47
68
|
cwd: string,
|
|
@@ -49,6 +70,7 @@ export function extractTouchedFromActivities(
|
|
|
49
70
|
const files = new Set<string>();
|
|
50
71
|
for (const a of activities) {
|
|
51
72
|
if (a.name !== "edit" && a.name !== "write") continue;
|
|
73
|
+
if (!a.result || a.result.isError) continue;
|
|
52
74
|
const raw = a.args?.path ?? a.args?.file_path ?? a.args?.filePath;
|
|
53
75
|
if (typeof raw !== "string" || !raw) continue;
|
|
54
76
|
files.add(path.resolve(cwd, raw));
|