@bermudi/pi-delegate 0.1.1 → 0.1.3

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/extension.ts CHANGED
@@ -19,61 +19,195 @@ 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";
33
+ import {
34
+ beginCall,
35
+ closeTelemetry,
36
+ getTelemetryGeneration,
37
+ prepareTelemetryForSession,
38
+ sealTelemetryWrites,
39
+ } from "./telemetry.ts";
31
40
  import type { DelegateArguments } from "./types.ts";
32
41
 
42
+ const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS = 10_000;
43
+ let shutdownDrainTimeoutMs = DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
44
+
45
+ type ShutdownDrainResult =
46
+ { drained: true; failures: unknown[] } | { drained: false; failures: [] };
47
+
48
+ /** Wait for shutdown workers without allowing a stuck provider/tool to hold
49
+ * Pi's reload or exit hostage. The allSettled promise is intentionally left
50
+ * attached after timeout so a late rejection cannot become unhandled. */
51
+ export async function drainAsyncTickets(
52
+ completions: readonly Promise<void>[],
53
+ timeoutMs = shutdownDrainTimeoutMs,
54
+ ): Promise<ShutdownDrainResult> {
55
+ if (!completions.length) return { drained: true, failures: [] };
56
+
57
+ const drain = Promise.allSettled(completions).then((results) => ({
58
+ drained: true as const,
59
+ failures: results
60
+ .filter(
61
+ (result): result is PromiseRejectedResult =>
62
+ result.status === "rejected",
63
+ )
64
+ .map((result) => result.reason),
65
+ }));
66
+
67
+ let timer: ReturnType<typeof setTimeout> | undefined;
68
+ const timeout = new Promise<ShutdownDrainResult>((resolve) => {
69
+ timer = setTimeout(
70
+ () => resolve({ drained: false, failures: [] }),
71
+ timeoutMs,
72
+ );
73
+ });
74
+ const result = await Promise.race([drain, timeout]);
75
+ if (timer !== undefined) clearTimeout(timer);
76
+ return result;
77
+ }
78
+
79
+ /** Wait for another shutdown cleanup operation without allowing a stuck
80
+ * session lock to hold reload/quit forever. Rejection is consumed here; the
81
+ * caller reports it when it wins the race, and a late rejection stays handled.
82
+ */
83
+ async function boundedShutdownCleanup(
84
+ cleanup: Promise<void>,
85
+ timeoutMs = shutdownDrainTimeoutMs,
86
+ ): Promise<"settled" | "timed-out" | "failed"> {
87
+ let timer: ReturnType<typeof setTimeout> | undefined;
88
+ const outcome = cleanup.then(
89
+ () => "settled" as const,
90
+ () => "failed" as const,
91
+ );
92
+ const timeout = new Promise<"timed-out">((resolve) => {
93
+ timer = setTimeout(() => resolve("timed-out"), timeoutMs);
94
+ });
95
+ const result = await Promise.race([outcome, timeout]);
96
+ if (timer !== undefined) clearTimeout(timer);
97
+ return result;
98
+ }
99
+
100
+ /** @internal Test-only timeout override. */
101
+ export function _setShutdownDrainTimeoutForTesting(
102
+ timeoutMs: number | undefined,
103
+ ): void {
104
+ shutdownDrainTimeoutMs = timeoutMs ?? DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
105
+ }
106
+
33
107
  /** Register the delegate tool and clean up its parent-session resources. */
34
108
  export default function delegateExtension(pi: ExtensionAPI): void {
109
+ // A /reload can reuse this module instance after the previous runtime closed
110
+ // its SQLite handle. Permit the new runtime to open a fresh backend; stale
111
+ // workers from the old runtime remain blocked from reopening it.
112
+ prepareTelemetryForSession();
113
+
35
114
  pi.registerTool({
36
115
  name: "delegate",
37
116
  label: "Delegate to Subagents",
38
117
  description:
39
- "Run parallel subagents via tasks:[{prompt}]. Sync returns results; async returns a ticket.",
118
+ "Run parallel subagents via tasks:[{prompt}]. Sync returns results; async=ticket. tasks:[]=full manual.",
40
119
  parameters: delegateArgumentsSchema,
41
120
  // Runs before schema validation — recovers stringified `tasks` arrays
42
121
  // (a common model mistake that would otherwise be rejected upstream).
43
122
  prepareArguments: normalizeDelegateArguments,
44
123
 
45
124
  async execute(_id, params: DelegateArguments, signal, onUpdate, ctx) {
125
+ const parentModelId = ctx.model?.id;
126
+ const tasks = params.tasks ?? [];
127
+ const parentSessionFile = (
128
+ ctx as { sessionManager?: { getSessionFile?(): string | undefined } }
129
+ ).sessionManager?.getSessionFile?.();
130
+
131
+ let mode: string;
132
+ if (params.ticketAction) {
133
+ mode = params.ticketAction;
134
+ } else if (tasks.length === 0) {
135
+ mode = "manual";
136
+ } else if (params.async) {
137
+ mode = "async";
138
+ } else {
139
+ mode = "sync";
140
+ }
141
+ const taskCount = params.ticketAction ? 0 : tasks.length;
142
+ const callSpan = beginCall({
143
+ parentModel: parentModelId,
144
+ mode,
145
+ taskCount,
146
+ parentSessionFile,
147
+ });
148
+
149
+ function failCall(): void {
150
+ callSpan.finish({
151
+ status: "failed",
152
+ totalTokens: 0,
153
+ totalCost: 0,
154
+ wallMs: Date.now() - callSpan.startedAt,
155
+ });
156
+ }
157
+
158
+ function succeedCall(): void {
159
+ callSpan.finish({
160
+ status: "success",
161
+ totalTokens: 0,
162
+ totalCost: 0,
163
+ wallMs: Date.now() - callSpan.startedAt,
164
+ });
165
+ }
166
+
46
167
  // Guard against pi dropping/renaming a symbol this extension imports
47
168
  // before any operation-specific validation or early return.
48
169
  const compatError = hostCompatError();
49
- if (compatError) return compatError;
50
-
51
- const parentModelId = ctx.model?.id;
52
- const tasks = params.tasks ?? [];
170
+ if (compatError) {
171
+ failCall();
172
+ return compatError;
173
+ }
53
174
 
54
175
  const operationResult = validateDelegateOperationResult(
55
176
  params,
56
177
  parentModelId,
57
178
  );
58
- if (operationResult) return operationResult;
179
+ if (operationResult) {
180
+ failCall();
181
+ return operationResult;
182
+ }
59
183
 
60
184
  // ── Poll action ───────────────────────────────────────────────────
61
- if (params.action === "poll") {
62
- return handlePoll(params, ctx);
185
+ if (params.ticketAction === "poll") {
186
+ const result = handlePoll(params, ctx);
187
+ succeedCall();
188
+ return result;
63
189
  }
64
190
 
65
191
  // ── Cancel action ─────────────────────────────────────────────────
66
- if (params.action === "cancel") {
192
+ if (params.ticketAction === "cancel") {
67
193
  const result = handleCancel(params);
68
194
  // A forced cancel flips the ticket to "cancelling" — keep the
69
195
  // footer status in step (deduped; the preview path is a no-op).
70
196
  syncDelegateStatus(ctx);
197
+ succeedCall();
71
198
  return result;
72
199
  }
73
200
 
74
201
  // ── Wait action ────────────────────────────────────────────────────
75
- if (params.action === "wait") {
76
- return handleWait(params, signal, onUpdate, ctx);
202
+ if (params.ticketAction === "wait") {
203
+ try {
204
+ const result = await handleWait(params, signal, onUpdate, ctx);
205
+ succeedCall();
206
+ return result;
207
+ } catch (err) {
208
+ failCall();
209
+ throw err;
210
+ }
77
211
  }
78
212
 
79
213
  // Agent discovery is intentionally parent-cwd-scoped: agent profiles are a
@@ -85,6 +219,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
85
219
 
86
220
  // ── Help mode ─────────────────────────────────────────────────
87
221
  if (!tasks.length) {
222
+ succeedCall();
88
223
  return {
89
224
  content: [{ type: "text", text: getSubagentManualMarkdown(agents) }],
90
225
  details: {
@@ -106,25 +241,38 @@ export default function delegateExtension(pi: ExtensionAPI): void {
106
241
  // across dispatches: edits to auth/models/settings/context files must be
107
242
  // visible without restarting Pi.
108
243
  invalidateHostDepsCache();
109
- return dispatchDelegate({
110
- pi,
111
- params,
112
- ctx,
113
- agents,
114
- parentModelId,
115
- parentDefaults: {
116
- thinking: pi.getThinkingLevel(),
117
- tools: pi.getActiveTools(),
118
- },
119
- signal,
120
- onUpdate,
121
- });
244
+ try {
245
+ return await dispatchDelegate({
246
+ pi,
247
+ params,
248
+ ctx,
249
+ agents,
250
+ parentModelId,
251
+ parentDefaults: {
252
+ thinking: pi.getThinkingLevel(),
253
+ tools: pi.getActiveTools(),
254
+ },
255
+ signal,
256
+ onUpdate,
257
+ callSpan,
258
+ });
259
+ } catch (err) {
260
+ failCall();
261
+ throw err;
262
+ }
122
263
  },
123
264
 
124
265
  renderCall: renderDelegateCall,
125
266
  renderResult: renderDelegateResult,
126
267
  });
127
268
 
269
+ // A reload may rebuild the extension runtime without re-invoking this
270
+ // module's default export. Re-open telemetry only after the prior shutdown
271
+ // handler has drained old workers and closed its connection.
272
+ pi.on("session_start", () => {
273
+ prepareTelemetryForSession();
274
+ });
275
+
128
276
  // ── Background-work visibility (see status.ts) ──────────────────────────
129
277
  // The turn settling with live tickets is the "looks idle but isn't" moment:
130
278
  // warn once per ticket. The footer status carries it from there.
@@ -140,51 +288,150 @@ export default function delegateExtension(pi: ExtensionAPI): void {
140
288
  guardSessionReplacement(ctx, "fork"),
141
289
  );
142
290
 
291
+ // /tree navigation stays inside the same session: nothing is torn down and
292
+ // live tickets keep running, but their results would land on the branch the
293
+ // user moves to. Ask first, and record the new leaf either way so delivery
294
+ // can detect the mismatch (issue #30). `session_tree` also fires for
295
+ // extension-driven ctx.navigateTree, which never reaches the guard.
296
+ pi.on("session_before_tree", (_event, ctx) => guardTreeNavigation(ctx));
297
+ pi.on("session_tree", (event, ctx) => {
298
+ recordTreeNavigation(event.newLeafId);
299
+ syncDelegateStatus(ctx);
300
+ });
301
+
143
302
  // ── Session shutdown: abort tickets and dispose live pooled sessions ──
144
303
  pi.on("session_shutdown", async (event, ctx) => {
145
- // Quit and /reload kill background work with no cancellable hook, so
146
- // leave a trace. For quit the TUI is already stopped — stderr lands in
147
- // the scrollback. For reload the TUI survives — warn in place. Switch
148
- // and fork already passed the confirm guard above.
149
- const active = activeTicketSummary();
150
- if (active.tickets.length) {
151
- if (event.reason === "quit") {
304
+ const shutdownGeneration = getTelemetryGeneration();
305
+ const shutdownDeadline = Date.now() + shutdownDrainTimeoutMs;
306
+ const ticketCompletions: Promise<void>[] = [];
307
+ let drainAttempted = false;
308
+ let telemetrySealed = false;
309
+
310
+ try {
311
+ // Quit and /reload kill background work with no cancellable hook, so
312
+ // leave a trace. For quit the TUI is already stopped — stderr lands in
313
+ // the scrollback. For reload the TUI survives — warn in place. Switch
314
+ // and fork already passed the confirm guard above.
315
+ const active = activeTicketSummary();
316
+ if (active.tickets.length) {
317
+ if (event.reason === "quit") {
318
+ console.error(
319
+ `[delegate] pi exited with ${describeActiveTickets(active)} — aborted.`,
320
+ );
321
+ } else if (event.reason === "reload") {
322
+ try {
323
+ ctx.ui.notify(
324
+ `[delegate] reload aborted ${describeActiveTickets(active)}`,
325
+ "warning",
326
+ );
327
+ } catch (error) {
328
+ console.error(
329
+ "[delegate] reload shutdown notification failed",
330
+ error,
331
+ );
332
+ }
333
+ }
334
+ }
335
+
336
+ for (const ticket of ticketRegistry.values()) {
337
+ if (ticket.status === "running" || ticket.status === "cancelling") {
338
+ cancelTicketForShutdown(ticket);
339
+ }
340
+ // Include already-cancelled tickets too: a repeated shutdown event can
341
+ // race the first handler while its workers are still unwinding.
342
+ if (ticket.completion) ticketCompletions.push(ticket.completion);
343
+ }
344
+ syncDelegateStatus(ctx);
345
+ // The runtime is invalidated right after this handler returns; aborted
346
+ // tickets keep unwinding asynchronously and must find no cached ctx (or
347
+ // captured pi) to touch. The cancelled completion path still writes one
348
+ // final aggregate after late task results arrive, but never delivers UI.
349
+ clearDelegateStatusContext();
350
+ // A replacement session starts on its own leaf; stale tracking would make
351
+ // every ticket look cross-leaf (or, worse, look same-leaf by accident).
352
+ resetLeafTracking();
353
+ // Do NOT clear the ticket registry here — completed tickets are retained
354
+ // until their TTL cleanup. Pooled AgentSessions, however, own listeners
355
+ // and must be disposed before the parent session exits.
356
+ //
357
+ // Start pooled cleanup before waiting for ticket completion. Its immediate
358
+ // abort requests can help a worker blocked on a pooled session unwind;
359
+ // SQLite still stays open until both cleanup paths finish.
360
+ let poolCleanup: Promise<void>;
361
+ try {
362
+ poolCleanup = closeAllPooledAgents();
363
+ } catch (error) {
364
+ console.error("[delegate] pooled-session shutdown start failed", error);
365
+ poolCleanup = Promise.resolve();
366
+ }
367
+ // Drain cooperatively, but never let a provider/tool hold reload or
368
+ // quit forever. A timed-out old runtime is sealed before a new one can
369
+ // reopen telemetry; its eventual task completion remains harmless.
370
+ drainAttempted = true;
371
+ const drain = await drainAsyncTickets(
372
+ ticketCompletions,
373
+ Math.max(0, shutdownDeadline - Date.now()),
374
+ );
375
+ if (!drain.drained) {
152
376
  console.error(
153
- `[delegate] pi exited with ${describeActiveTickets(active)} aborted.`,
377
+ `[delegate] async-ticket shutdown drain exceeded ${shutdownDrainTimeoutMs}ms; continuing without late results`,
154
378
  );
155
- } else if (event.reason === "reload") {
156
- ctx.ui.notify(
157
- `[delegate] reload aborted ${describeActiveTickets(active)}`,
158
- "warning",
379
+ // Seal immediately, before waiting on pooled-session cleanup. A worker
380
+ // that finishes during that second phase belongs to the old runtime
381
+ // and must not write into a freshly reopened backend.
382
+ telemetrySealed = sealTelemetryWrites(shutdownGeneration);
383
+ } else if (drain.failures.length) {
384
+ console.error(
385
+ "[delegate] async-ticket cleanup failed during shutdown:",
386
+ drain.failures,
159
387
  );
160
388
  }
161
- }
162
- for (const ticket of ticketRegistry.values()) {
163
- cancelTicketForShutdown(ticket);
164
- }
165
- syncDelegateStatus(ctx);
166
- // The runtime is invalidated right after this handler returns; aborted
167
- // tickets keep unwinding asynchronously and must find no cached ctx (or
168
- // captured pi) to touch. See the "cancelled"-at-entry guard in dispatch.
169
- clearDelegateStatusContext();
170
- // Do NOT clear the ticket registry here — completed tickets are retained
171
- // until their TTL cleanup. Pooled AgentSessions, however, own listeners
172
- // and must be disposed before the parent session exits.
173
- //
174
- // closeAllPooledAgents attempts every session (Promise.allSettled) before
175
- // aggregating failures into an AggregateError, so swallowing here does not
176
- // abandon remaining cleanup. Catch and log so a wedged session's failure
177
- // stays observable instead of becoming an unhandled rejection — pi.on is
178
- // EventEmitter-style and does not surface handler rejections — and so
179
- // shutdown completes even when one pooled session failed to abort/dispose.
180
- try {
181
- await closeAllPooledAgents();
182
- } catch (error) {
183
- const failures = error instanceof AggregateError ? error.errors : [error];
184
- console.error(
185
- "[delegate] pooled-session cleanup failed during shutdown:",
186
- failures,
389
+
390
+ // closeAllPooledAgents attempts every session (Promise.allSettled) before
391
+ // aggregating failures into an AggregateError, so swallowing here does
392
+ // not abandon remaining cleanup.
393
+ const poolResult = await boundedShutdownCleanup(
394
+ poolCleanup,
395
+ Math.max(0, shutdownDeadline - Date.now()),
187
396
  );
397
+ if (poolResult === "timed-out") {
398
+ console.error(
399
+ `[delegate] pooled-session shutdown cleanup exceeded ${shutdownDrainTimeoutMs}ms; continuing`,
400
+ );
401
+ } else if (poolResult === "failed") {
402
+ // closeAllPooledAgents aggregates every session failure before
403
+ // rejecting, so the rejection is already a complete diagnostic.
404
+ try {
405
+ await poolCleanup;
406
+ } catch (error) {
407
+ const failures =
408
+ error instanceof AggregateError ? error.errors : [error];
409
+ console.error(
410
+ "[delegate] pooled-session cleanup failed during shutdown:",
411
+ failures,
412
+ );
413
+ }
414
+ }
415
+ } catch (error) {
416
+ // pi.on is EventEmitter-style and does not surface handler rejections.
417
+ // Keep shutdown moving, but leave the failure visible.
418
+ console.error("[delegate] shutdown cleanup failed", error);
419
+ } finally {
420
+ if (!drainAttempted) {
421
+ // An unexpected earlier cleanup failure must not leave a worker with an
422
+ // unhandled rejection, but this fallback is bounded too.
423
+ await drainAsyncTickets(
424
+ ticketCompletions,
425
+ Math.max(0, shutdownDeadline - Date.now()),
426
+ );
427
+ }
428
+ // Once the bounded drain expires, old workers may still unwind. Seal
429
+ // their generation before closing SQLite so a later runtime cannot
430
+ // receive stale task/call writes.
431
+ if (!telemetrySealed) {
432
+ telemetrySealed = sealTelemetryWrites(shutdownGeneration);
433
+ }
434
+ closeTelemetry(shutdownGeneration);
188
435
  }
189
436
  });
190
437
  }
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));
package/format.ts CHANGED
@@ -185,6 +185,11 @@ export function trunc(s: string, n: number): string {
185
185
  return s.length <= n ? s : s.slice(0, n - 1) + "…";
186
186
  }
187
187
 
188
+ /** Render an optional task `id` in a compact, visually distinct form. */
189
+ export function formatTaskId(id: string | undefined): string {
190
+ return id ? ` #${id}` : "";
191
+ }
192
+
188
193
  /**
189
194
  * Extract a single-line preview of agent output for collapsed final display.
190
195
  *
@@ -340,7 +345,7 @@ function isResumableSessionFile(sessionFile: string): boolean {
340
345
  * path that didn't exist on disk.
341
346
  *
342
347
  * Emits:
343
- * [FAILED|ABORTED: <error> · session: <shortpath> · touched: <files>]
348
+ * [FAILED|ABORTED: <error> · session: <shortpath> · touched (best-effort): <files>]
344
349
  * <partial output, when available>
345
350
  * → To retry: delegate({ tasks: [{ resumeFrom: "<path>", prompt: "continue" }] })
346
351
  *
@@ -351,12 +356,12 @@ function isResumableSessionFile(sessionFile: string): boolean {
351
356
  */
352
357
  export function formatFailedTask(r: TaskResult, cwd?: string): string[] {
353
358
  const parts: string[] = [];
354
- const isAbort = /abort/i.test(r.error ?? "");
359
+ const isAbort = r.error === "Aborted";
355
360
  // Empty string is falsy but not nullish — `||` covers both undefined and "".
356
361
  const failParts = [r.error || "unknown error"];
357
362
  if (r.sessionFile) failParts.push(`session: ${shortenPath(r.sessionFile)}`);
358
363
  const touched = cwd ? relativeTouchedSummary(r.touchedFiles, cwd) : null;
359
- if (touched) failParts.push(`touched: ${touched}`);
364
+ if (touched) failParts.push(`touched (best-effort): ${touched}`);
360
365
  parts.push(`[${isAbort ? "ABORTED" : "FAILED"}: ${failParts.join(" · ")}]`);
361
366
 
362
367
  // Surface partial assistant output even when the task did not complete.
@@ -398,7 +403,7 @@ export function formatFailedTask(r: TaskResult, cwd?: string): string[] {
398
403
  * Emits:
399
404
  * === <agent>: <truncated prompt> ===
400
405
  * [WARNING: <w>] (per warning, if any)
401
- * [FAILED: ...] / [OK | <duration> | <tokens> tokens · <sessionFile> · touched: <files>]
406
+ * [FAILED: ...] / [OK | <duration> | <tokens> tokens · <sessionFile> · touched (best-effort): <files>]
402
407
  *
403
408
  * <output> (success body only)
404
409
  *
@@ -410,10 +415,10 @@ export function formatCompletedTask(
410
415
  result: TaskResult,
411
416
  ): string[] {
412
417
  const parts: string[] = [];
413
- // `|| task.action` covers action-only tasks (close/list/...) where prompt is
418
+ // `|| task.sessionAction` covers action-only tasks (close/list/...) where prompt is
414
419
  // empty. Async prompt tasks always set prompt, so this is a no-op there.
415
420
  parts.push(
416
- `=== ${result.agent}: ${trunc(task.prompt || task.action || "", 80)} ===`,
421
+ `=== ${result.agent}${formatTaskId(result.id ?? task.id)}: ${trunc(task.prompt || task.sessionAction || "", 80)} ===`,
417
422
  );
418
423
  if (task.warnings?.length) {
419
424
  for (const w of task.warnings) parts.push(`[WARNING: ${w}]`);
@@ -426,7 +431,7 @@ export function formatCompletedTask(
426
431
  ];
427
432
  if (result.sessionFile) meta.push(shortenPath(result.sessionFile));
428
433
  const touched = relativeTouchedSummary(result.touchedFiles, task.cwd);
429
- if (touched) meta.push(`touched: ${touched}`);
434
+ if (touched) meta.push(`touched (best-effort): ${touched}`);
430
435
  parts.push(
431
436
  `[${meta.join(" · ")}]\n\n${renderOutputForLLM(result.output, result.agent)}`,
432
437
  );
@@ -504,3 +509,37 @@ export function relativeTouchedSummary(
504
509
  .filter((f) => f && !f.startsWith(".."));
505
510
  return rel.length ? rel.join(", ") : null;
506
511
  }
512
+
513
+ /** Find absolute paths directly attributed to more than one task result.
514
+ *
515
+ * Overlap is computed from {@link TaskResult.attributedFiles} (edit/write
516
+ * tool calls), not from {@link TaskResult.touchedFiles}, so concurrent tasks
517
+ * in the same repository do not fabricate false conflicts from shared
518
+ * repository-wide git snapshots. */
519
+ export function findTouchedOverlaps(
520
+ results: readonly { attributedFiles?: string[] }[],
521
+ ): string[] {
522
+ const counts = new Map<string, number>();
523
+ for (const r of results) {
524
+ for (const f of r.attributedFiles ?? []) {
525
+ counts.set(f, (counts.get(f) ?? 0) + 1);
526
+ }
527
+ }
528
+ return [...counts.entries()]
529
+ .filter(([, count]) => count > 1)
530
+ .map(([file]) => file)
531
+ .sort();
532
+ }
533
+
534
+ /**
535
+ * Format a post-dispatch overlap warning, or null when there is no overlap.
536
+ *
537
+ * The warning is deliberately conservative: it only reports paths that two or
538
+ * more tasks claimed to touch. It does NOT claim that disjoint touchedFiles
539
+ * mean there was no conflict, and it does NOT claim filesystem isolation or
540
+ * rollback.
541
+ */
542
+ export function formatTouchedOverlapWarning(overlaps: string[]): string | null {
543
+ if (!overlaps.length) return null;
544
+ return `WARNING: These tasks reported touching the same file(s): ${overlaps.join(", ")}. Delegate does not isolate or serialize file access and does not roll back completed writes.`;
545
+ }
package/leaf.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Session-tree leaf affinity for async tickets.
3
+ *
4
+ * An async ticket outlives the turn that spawned it. `/tree` navigation moves
5
+ * the session to a different leaf **within the same session file**: no
6
+ * `session_shutdown` fires, the extension runtime stays live, and the ticket
7
+ * keeps running. When it finishes, `deliverTicketResults` wakes the agent at
8
+ * whatever leaf is active *now* — which may be a branch that knows nothing
9
+ * about the task. See GitHub issue #30.
10
+ *
11
+ * pi exposes no "what leaf am I on?" query, so the current leaf has to be
12
+ * tracked from the `session_tree` event (`newLeafId`). That event also fires
13
+ * for extension-driven `ctx.navigateTree`, so this tracking covers navigation
14
+ * that never passed the `session_before_tree` confirm guard.
15
+ *
16
+ * State is runtime-scoped: `resetLeafTracking()` on session shutdown, since a
17
+ * replacement session starts on its own (unknown) leaf.
18
+ */
19
+ import type { AsyncTicket } from "./types.ts";
20
+
21
+ /** Leaf the session is currently on. `undefined` means no navigation has been
22
+ * observed by this runtime — i.e. the leaf the session opened on. */
23
+ let currentLeafId: string | null | undefined;
24
+
25
+ /** Record a completed `/tree` navigation. */
26
+ export function recordTreeNavigation(newLeafId: string | null): void {
27
+ currentLeafId = newLeafId;
28
+ }
29
+
30
+ /** Leaf id to stamp on a ticket at spawn time. */
31
+ export function getCurrentLeafId(): string | null | undefined {
32
+ return currentLeafId;
33
+ }
34
+
35
+ export function resetLeafTracking(): void {
36
+ currentLeafId = undefined;
37
+ }
38
+
39
+ /** True when the session has navigated away from the leaf that spawned this
40
+ * ticket, so delivering its result would wake the agent on a foreign branch.
41
+ *
42
+ * Navigating away and back to the spawn leaf yields a fresh leaf id and is
43
+ * therefore reported as cross-leaf. That false positive is deliberate: the
44
+ * cross-leaf path only downgrades delivery to non-waking, and reconstructing
45
+ * true leaf identity across a round trip is not worth the complexity. */
46
+ export function isCrossLeafTicket(ticket: AsyncTicket): boolean {
47
+ return ticket.spawnLeafId !== currentLeafId;
48
+ }