@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 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 `action: "poll"`
130
- or `action: "cancel"`.
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 `*` so it parses as the string "*", which resolveFrontmatterTools
79
- // then expands via TOOL_GROUPS. (A `*` mid-scalar, e.g. `use * here`, is a
80
- // legal plain scalar and needs no quoting.)
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(/^(\s*[\w-]+):\s*\*(?=\s*$)/gm, '$1: "*"'),
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. This is the only caller-owned knob — both inline tasks
144
- * and named agents now inherit-all when `tools:` is absent. */
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) => aliasMap[n.toLowerCase()] ?? null)
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) inherit.
161
- return mapped.length ? resolveToolGroups(mapped) : DEFAULT_TOOLS;
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. Two Claude-specific adaptations:
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. A reviewer with `disallowedTools: Write, Edit` and no
211
- * `tools` becomes `read, bash, grep, find, ls` it does NOT silently
212
- * inherit full tools.
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
- let tools = resolveFrontmatterTools(data.tools, CLAUDE_TOOL_ALIASES);
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
- // Empty-after-mapping denylist (e.g. only unmappable names) no-op.
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) tools = tools.filter((t) => !denied.has(t));
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
- options.parentSystemPrompt,
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
- function acquireGlobal(): Promise<void> {
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<void>((r) => globalConcurrencyWaiters.push(r));
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
- await acquireGlobal();
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 { fmtDuration, formatCompletedTask, trunc } from "./format.ts";
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.action || "", 50),
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
- deliverTicketResults(pi, ticket);
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
- deliverTicketResults(pi, ticket);
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({ action: "poll", ticket: "${ticketId}" }) — avoid polling in a tight loop`,
320
- `Cancel if needed: delegate({ action: "cancel", ticket: "${ticketId}", force: true }) — first call without force is a preview`,
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 returns a ticket.",
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.action === "poll") {
63
+ if (params.ticketAction === "poll") {
62
64
  return handlePoll(params, ctx);
63
65
  }
64
66
 
65
67
  // ── Cancel action ─────────────────────────────────────────────────
66
- if (params.action === "cancel") {
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.action === "wait") {
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
- /** Return absolute paths reported as changed by Git in the task cwd.
6
- * Git failures degrade to an empty set because file tracking is observational. */
7
- export async function getGitChangedFiles(cwd: string): Promise<Set<string>> {
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 new Set();
50
+ return undefined;
41
51
  }
42
52
  }
43
53
 
44
- /** Extract file paths mutated by edit/write from the activity log. */
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));