@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/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
+ }
package/lifecycle.ts CHANGED
@@ -20,12 +20,13 @@ import {
20
20
  persistSessionHeader,
21
21
  setParentSession,
22
22
  } from "./sessions.ts";
23
- import { runAgentSession } from "./runner.ts";
23
+ import { runAgentSession, formatDeadlineExceededError } from "./runner.ts";
24
24
  import { getGitChangedFiles } from "./file-tracking.ts";
25
25
  import { getHostDeps } from "./host.ts";
26
26
  import { resolveCwd, validateResumeFromPath } from "./utils.ts";
27
27
  import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
28
28
  import { addUsage, emptyUsage } from "./usage.ts";
29
+ import { scheduleDeadline } from "./timer.ts";
29
30
 
30
31
  /** Internal seam for lifecycle-level tests without replacing session ownership. */
31
32
  type RunAgentSession = typeof runAgentSession;
@@ -72,6 +73,7 @@ function failTask(
72
73
  sessionFile?: string,
73
74
  ): TaskResult {
74
75
  return {
76
+ id: task.id,
75
77
  agent: task.agentName,
76
78
  output: "",
77
79
  error,
@@ -80,6 +82,7 @@ function failTask(
80
82
  usage: emptyUsage(),
81
83
  sessionFile,
82
84
  touchedFiles: [],
85
+ attributedFiles: [],
83
86
  };
84
87
  }
85
88
 
@@ -91,6 +94,7 @@ function completeSessionAction(
91
94
  elapsedMs?: number,
92
95
  ): TaskResult {
93
96
  return {
97
+ id: task.id,
94
98
  agent: task.agentName,
95
99
  output,
96
100
  durationMs: elapsedMs ?? 0,
@@ -98,6 +102,7 @@ function completeSessionAction(
98
102
  usage: emptyUsage(),
99
103
  sessionFile: undefined,
100
104
  touchedFiles: [],
105
+ attributedFiles: [],
101
106
  };
102
107
  }
103
108
 
@@ -266,6 +271,7 @@ function canRetryWholeTask(
266
271
  return (
267
272
  result.failureKind !== "stalled" &&
268
273
  result.failureKind !== "model_error" &&
274
+ result.failureKind !== "deadline_exceeded" &&
269
275
  !task.sessionId &&
270
276
  !task.resumeFrom &&
271
277
  result.touchedFiles.length === 0 &&
@@ -277,16 +283,23 @@ function canRetryWholeTask(
277
283
  async function sleepForWholeTaskRetry(
278
284
  signal: AbortSignal | undefined,
279
285
  delayMs: number,
286
+ deadlineAt?: number,
280
287
  ): Promise<void> {
281
288
  if (signal?.aborted) return;
289
+ if (deadlineAt !== undefined && Date.now() >= deadlineAt) return;
290
+
291
+ const retryAt = Date.now() + delayMs;
292
+ const wakeAt =
293
+ deadlineAt !== undefined ? Math.min(retryAt, deadlineAt) : retryAt;
294
+
282
295
  await new Promise<void>((resolve) => {
283
- let timeout: ReturnType<typeof setTimeout>;
296
+ let clear: (() => void) | undefined;
284
297
  const done = () => {
285
- clearTimeout(timeout);
298
+ clear?.();
286
299
  signal?.removeEventListener("abort", done);
287
300
  resolve();
288
301
  };
289
- timeout = setTimeout(done, delayMs);
302
+ clear = scheduleDeadline(wakeAt, done);
290
303
  if (!signal) return;
291
304
  signal.addEventListener("abort", done, { once: true });
292
305
  });
@@ -516,7 +529,7 @@ function resolveResumableSessionFile(
516
529
  * Used by both sync (params.async === false) and async (params.async === true) paths.
517
530
  * When task.sessionId is set, the entire acquire/run/close lifecycle runs under
518
531
  * a per-session mutex so concurrent tasks with the same sessionId serialize
519
- * cleanly. The lock also covers action='close' and the early-busy/abort paths. */
532
+ * cleanly. The lock also covers sessionAction='close' and the early-busy/abort paths. */
520
533
  export async function runResolvedTask(
521
534
  env: TaskRunEnv,
522
535
  task: ResolvedTask,
@@ -558,12 +571,12 @@ async function runResolvedTaskUnlocked(
558
571
  p.model = task.model?.id;
559
572
 
560
573
  // ── Session action handling ───────────────────────────────────────
561
- if (task.action === "close") {
574
+ if (task.sessionAction === "close") {
562
575
  if (!task.sessionId) {
563
576
  return finishTask(
564
577
  env,
565
578
  p,
566
- failTask(task, "action='close' requires sessionId."),
579
+ failTask(task, "sessionAction='close' requires sessionId."),
567
580
  );
568
581
  }
569
582
  // The per-session lock for action-based operations is already held by the
@@ -583,7 +596,7 @@ async function runResolvedTaskUnlocked(
583
596
  );
584
597
  }
585
598
 
586
- if (task.action === "list") {
599
+ if (task.sessionAction === "list") {
587
600
  return finishTask(
588
601
  env,
589
602
  p,
@@ -599,6 +612,10 @@ async function runResolvedTaskUnlocked(
599
612
  let cumulativeTokens = 0;
600
613
  let cumulativeToolUses = 0;
601
614
  const taskStartedAt = Date.now();
615
+ const deadlineAt =
616
+ task.deadlineMs && task.deadlineMs > 0
617
+ ? taskStartedAt + task.deadlineMs
618
+ : undefined;
602
619
  let accumulatedUsage = emptyUsage();
603
620
 
604
621
  const onAttemptProgress = (u: AgentProgressUpdate): void => {
@@ -651,7 +668,10 @@ async function runResolvedTaskUnlocked(
651
668
 
652
669
  // Snapshot git status before the run so touchedFiles can diff after.
653
670
  // AgentSession owns retry/compaction internally — runAgentSession just
654
- // drives the prompt and maps events to the progress model.
671
+ // drives the prompt and maps events to the progress model. Git failures
672
+ // degrade to an undefined baseline, which tells the runner to skip
673
+ // git-based attribution entirely; see getGitChangedFiles for the
674
+ // contract.
655
675
  const gitBaseline = await getGitChangedFiles(task.cwd);
656
676
  let r = await runAgentSessionForTesting(
657
677
  acquired.session,
@@ -660,7 +680,8 @@ async function runResolvedTaskUnlocked(
660
680
  env.signal,
661
681
  onProgress,
662
682
  gitBaseline,
663
- Date.now(),
683
+ taskStartedAt,
684
+ deadlineAt,
664
685
  );
665
686
 
666
687
  cumulativeTokens += r.tokens;
@@ -682,7 +703,11 @@ async function runResolvedTaskUnlocked(
682
703
  // Pool misses (including resumeFrom) transfer ownership only on
683
704
  // successful completion; failures are owned by lifecycle and must
684
705
  // be disposed in this finally path.
685
- if (!r.error && r.failureKind !== "stalled") {
706
+ if (
707
+ !r.error &&
708
+ r.failureKind !== "stalled" &&
709
+ r.failureKind !== "deadline_exceeded"
710
+ ) {
686
711
  const committed = pool.commit(task.sessionId, {
687
712
  session: acquired.session,
688
713
  sessionManager: acquired.sessionManager,
@@ -699,28 +724,37 @@ async function runResolvedTaskUnlocked(
699
724
  sessionReleased = sessionReleased || committed;
700
725
  }
701
726
  } else {
702
- // A stalled pooled attempt is not safe to keep; remove from the
703
- // pool and let the lifecycle-owned finalizer handle disposal.
704
- if (r.failureKind === "stalled") {
727
+ // A stalled, parent-aborted, or mid-prompt deadline-exceeded pooled
728
+ // attempt is not safe to keep; the session may have been mutated.
729
+ // A pre-prompt deadline (runner never called session.prompt()) left
730
+ // the session in its pre-task state, so return it to the pool intact.
731
+ if (
732
+ r.failureKind === "stalled" ||
733
+ r.error === "Aborted" ||
734
+ (r.failureKind === "deadline_exceeded" && r.prompted !== false)
735
+ ) {
705
736
  try {
706
737
  sessionReleased =
707
738
  (await pool._closePooledAgentWithoutLock(task.sessionId)) ||
708
739
  sessionReleased;
709
740
  } catch (error) {
710
- // Preserve the primary stalled result while logging the cleanup
741
+ // Preserve the primary failure result while logging the cleanup
711
742
  // failure explicitly. A pooled session may still be removed by
712
743
  // the pool; a pool-miss remains lifecycle-owned and is handled
713
744
  // by the finally path above.
714
745
  console.error(
715
- `[delegate] failed to dispose stalled pooled session '${task.sessionId}'`,
746
+ `[delegate] failed to dispose aborted, stalled, or deadline-exceeded pooled session '${task.sessionId}'`,
716
747
  error,
717
748
  );
718
749
  }
719
- } else {
720
- // Pool hits stay owned by the pool, and non-stalled completions
721
- // (including failed attempts) must still count usage.
750
+ } else if (r.failureKind !== "deadline_exceeded") {
751
+ // Pool hits stay owned by the pool, and non-stalled, non-aborted
752
+ // completions (including failed attempts) must still count usage.
722
753
  pool.recordUse(task.sessionId, r.tokens);
723
754
  }
755
+ // Pre-prompt deadline (prompted === false): the pooled session was
756
+ // checked out but never used. Leave it in the pool with no usage
757
+ // recorded.
724
758
  }
725
759
  }
726
760
 
@@ -728,6 +762,7 @@ async function runResolvedTaskUnlocked(
728
762
  cumulativeToolUses += attemptToolUsesObserved;
729
763
 
730
764
  return {
765
+ id: task.id,
731
766
  agent: task.agentName,
732
767
  output: r.output,
733
768
  error: r.error,
@@ -747,6 +782,7 @@ async function runResolvedTaskUnlocked(
747
782
  usage: r.usage,
748
783
  sessionFile,
749
784
  touchedFiles: r.touchedFiles,
785
+ attributedFiles: r.attributedFiles ?? [],
750
786
  };
751
787
  } finally {
752
788
  // This runs for ordinary success, normal provider failure, whole-task
@@ -756,9 +792,30 @@ async function runResolvedTaskUnlocked(
756
792
  }
757
793
  };
758
794
 
795
+ const buildDeadlineExceededResult = (prior?: TaskResult): TaskResult => {
796
+ const budgetMs = Math.max(0, (deadlineAt ?? 0) - taskStartedAt);
797
+ return {
798
+ id: task.id,
799
+ agent: task.agentName,
800
+ output: prior?.output ?? "",
801
+ error: formatDeadlineExceededError(budgetMs),
802
+ failureKind: "deadline_exceeded",
803
+ durationMs: prior?.durationMs ?? 0,
804
+ tokens: prior?.tokens ?? 0,
805
+ usage: prior?.usage ?? emptyUsage(),
806
+ sessionFile: prior?.sessionFile,
807
+ touchedFiles: prior?.touchedFiles ?? [],
808
+ attributedFiles: prior?.attributedFiles ?? [],
809
+ };
810
+ };
811
+
759
812
  let result: TaskResult;
760
813
  try {
761
- result = await runAttempt();
814
+ if (deadlineAt && Date.now() >= deadlineAt) {
815
+ result = buildDeadlineExceededResult(undefined);
816
+ } else {
817
+ result = await runAttempt();
818
+ }
762
819
  } catch (err) {
763
820
  const failure = failTask(
764
821
  task,
@@ -781,7 +838,7 @@ async function runResolvedTaskUnlocked(
781
838
  ) {
782
839
  const delayMs = baseDelayMs * 2 ** retry;
783
840
  p.durationMs = Math.max(p.durationMs, Date.now() - taskStartedAt);
784
- await sleepForWholeTaskRetry(env.signal, delayMs);
841
+ await sleepForWholeTaskRetry(env.signal, delayMs, deadlineAt);
785
842
  if (env.signal?.aborted) {
786
843
  // Preserve any partial output/session path from the last failed attempt
787
844
  // while recording that the retry loop was aborted. The task already
@@ -798,6 +855,11 @@ async function runResolvedTaskUnlocked(
798
855
  break;
799
856
  }
800
857
 
858
+ if (deadlineAt && Date.now() >= deadlineAt) {
859
+ result = buildDeadlineExceededResult(result);
860
+ break;
861
+ }
862
+
801
863
  p.status = "running";
802
864
  p.error = undefined;
803
865
  p.failureKind = undefined;
package/manual.ts CHANGED
@@ -73,13 +73,33 @@ export function getSubagentManualMarkdown(
73
73
  'delegate({ tasks: [{ agent: "default", prompt: "Investigate the auth module" }] })',
74
74
  "```",
75
75
  "",
76
- "Delegate subagents to execute tasks in parallel. Each subagent gets an independent context. Use the built-in `default` profile when it should mirror the live parent's model, thinking level, delegatable native tools, and base system prompt. Custom agents can be defined inline in a task or persisted as Markdown files.",
76
+ "Delegate subagents to execute tasks in parallel. Each subagent gets an independent conversation but uses the real filesystem at its task `cwd`; tasks sharing a directory can observe and overwrite one another's changes. Fresh prompts must therefore be self-contained, and dependent or shared-file work should run separately.",
77
+ "",
78
+ "The three handles have different lifetimes:",
79
+ "",
80
+ "- **ticket** — controls one async batch with `poll`, `wait`, or `cancel`.",
81
+ "- **sessionId** — a caller-chosen key for a live multi-turn worker, retained until close or parent shutdown.",
82
+ "- **resumeFrom** — an absolute `.jsonl` transcript path used to recover an interrupted worker.",
83
+ "",
84
+ "Each task entry may also carry an optional `id` — a caller-provided per-dispatch correlation key. Duplicate `id` values in the same call are rejected; when omitted, tasks are identified by array index, agent, and prompt.",
85
+ "",
86
+ "Subagents cannot call `delegate` recursively. Their tool activity runs at `cwd`, while Pi stores the runtime session transcript in its own session directory outside that `cwd`.",
87
+ "",
88
+ "## Touched Files (best-effort)",
89
+ "",
90
+ "The `touched:` list in each task result is a **best-effort lower bound**, not an authoritative record.",
91
+ "",
92
+ "- `write` and `edit` tool calls are captured reliably from the activity log.",
93
+ "- `bash` mutations are captured only when the task `cwd` is inside a git repo and git is available, via `git status` against the pre-run baseline.",
94
+ "- In a non-git directory, bash-mutated files are not reported.",
95
+ "- Git failures degrade to an empty diff.",
96
+ "- A path missing from `touched:` does **not** mean the file was unchanged. Delegate does not isolate file access or roll back writes.",
77
97
  "",
78
98
  "## Built-in Agent",
79
99
  "",
80
100
  "- **default**: mirrors the live parent model, thinking level, delegatable native tools, and base system prompt.",
81
101
  "",
82
- "Parent extension/MCP tools are not copied, and project context is rebuilt safely for the task's `cwd`. Per-task fields remain explicit overrides.",
102
+ "Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context is rebuilt safely for the task's `cwd`; per-task fields remain explicit overrides.",
83
103
  "",
84
104
  "## Available Custom Agents",
85
105
  "",
@@ -118,10 +138,10 @@ export function getSubagentManualMarkdown(
118
138
  'delegate({ tasks: [{ prompt: "Now check the tests for that module", sessionId: "auth-research" }] })',
119
139
  "",
120
140
  "// Clean up when done",
121
- 'delegate({ tasks: [{ sessionId: "auth-research", action: "close" }] })',
141
+ 'delegate({ tasks: [{ sessionId: "auth-research", sessionAction: "close" }] })',
122
142
  "```",
123
143
  "",
124
- 'Pooled agents remain live until `action: "close"` or parent Pi session shutdown.',
144
+ 'Pooled agents remain live until `sessionAction: "close"` or parent Pi session shutdown.',
125
145
  "",
126
146
  "## Resuming Previous Sessions",
127
147
  "",
@@ -158,28 +178,35 @@ export function getSubagentManualMarkdown(
158
178
  "",
159
179
  "## Async Mode",
160
180
  "",
161
- "Set `async: true` to run tasks in the background. The top-level `action` controls the ticket:",
181
+ "Set `async: true` to run tasks in the background. The top-level `ticketAction` controls the ticket:",
162
182
  "",
163
183
  "```ts",
164
184
  'delegate({ async: true, tasks: [{ prompt: "Investigate auth", systemPrompt: "You are a focused investigator.", tools: ["read", "grep", "find", "ls"] }] })',
165
185
  "```",
166
186
  "",
167
- '- `delegate({ action: "poll" })` \u2014 list all tickets',
168
- '- `delegate({ action: "poll", ticket: "abc123" })` \u2014 check one ticket',
169
- '- `delegate({ action: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 block until finished or timeout',
170
- '- `delegate({ action: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
171
- '- `delegate({ action: "cancel", ticket: "abc123", force: true })` \u2014 abort after review',
187
+ '- `delegate({ ticketAction: "poll" })` \u2014 list all tickets',
188
+ '- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014 check one ticket',
189
+ '- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 block until finished or timeout',
190
+ '- `delegate({ ticketAction: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
191
+ '- `delegate({ ticketAction: "cancel", ticket: "abc123", force: true })` \u2014 abort after review',
172
192
  "",
173
193
  `See the field tables above for the full semantics. Max ${getMaxAsyncTickets()} concurrent async tickets.`,
194
+ "Async results arrive as follow-up messages, so Pi cannot fold their usage into the parent session total; displayed task usage remains informational.",
174
195
  "",
175
196
  "## Gotchas",
176
197
  "",
198
+ "- Dispatch validation is batch-wide and runs before spawning: one invalid task rejects the call without starting its siblings.",
177
199
  "- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
178
200
  '- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
179
201
  '- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt. Omitting `agent` creates an ad-hoc task with delegate defaults.',
180
202
  "- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
181
203
  "- Subagents inherit all skills discovered in their `cwd` (via AgentSession's resource loader). Per-task skill filtering is not supported — curate the cwd's skill set instead.",
182
204
  `- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
205
+ "- `deadlineMs` is a per-task wall-clock budget measured from when the task starts running (after queuing). It requests cooperative abort and is not a hard kill; completed writes/commands remain. Omission disables the deadline.",
206
+ "",
207
+ "## Legacy `action` compatibility",
208
+ "",
209
+ "The overloaded `action` field was split into `ticketAction` (poll/wait/cancel) and `sessionAction` (prompt/close/list). Legacy `action` values are still accepted at runtime through automatic normalization, but new calls should use the canonical fields. Programmatic TypeScript consumers should note the exported type `DelegateAction` is now `TicketAction`.",
183
210
  "",
184
211
  "## Config",
185
212
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -32,6 +32,9 @@
32
32
  "prettier": "^3.8.4",
33
33
  "typescript": "^5.9.0"
34
34
  },
35
+ "patchedDependencies": {
36
+ "@marcfargas/pi-test-harness@0.6.1": "patches/@marcfargas%2Fpi-test-harness@0.6.1.patch"
37
+ },
35
38
  "private": false,
36
39
  "scripts": {
37
40
  "test": "bun test",
@@ -0,0 +1,13 @@
1
+ diff --git a/dist/session.js b/dist/session.js
2
+ index f7dc5de9be959c541ee4bab0ad1424656993bbe9..e515073926f0fbb0e2bc566a9b17f5f15f80a1a1 100644
3
+ --- a/dist/session.js
4
+ +++ b/dist/session.js
5
+ @@ -12,7 +12,7 @@ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import * as os from "node:os";
8
+ import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
9
+ -import { getModel } from "@earendil-works/pi-ai";
10
+ +import { getModel } from "@earendil-works/pi-ai/compat";
11
+ import { createPlaybookStreamFn } from "./playbook.js";
12
+ import { interceptToolExecution } from "./mock-tools.js";
13
+ import { createMockUIContext } from "./mock-ui.js";
@@ -3,6 +3,7 @@ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
4
  fmtDuration,
5
5
  fmtTokens,
6
+ formatTaskId,
6
7
  getActivityAge,
7
8
  indent,
8
9
  tree,
@@ -107,7 +108,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
107
108
  case "done":
108
109
  lines.push(
109
110
  truncLine(
110
- `${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(p.agent)}${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
111
+ `${tree(i, total)} ${theme.fg("success", "✓")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
111
112
  w,
112
113
  ),
113
114
  );
@@ -126,7 +127,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
126
127
  case "failed":
127
128
  lines.push(
128
129
  truncLine(
129
- `${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(p.agent)}${modelLabel(p)}${p.error ? theme.fg("error", ` ${p.error}`) : ""}`,
130
+ `${tree(i, total)} ${theme.fg("error", "✗")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${p.error ? theme.fg("error", ` ${p.error}`) : ""}`,
130
131
  w,
131
132
  ),
132
133
  );
@@ -146,14 +147,19 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
146
147
  {
147
148
  const activityAge = getActivityAge(p.lastActivityAt);
148
149
  const ageTag = activityAge ? ` · ${activityAge}` : "";
149
- const stallTag =
150
- p.failureKind === "stalled"
151
- ? theme.fg("warning", " · stall detected · cancellation pending")
152
- : "";
150
+ const issueTag =
151
+ p.failureKind === "deadline_exceeded"
152
+ ? theme.fg("error", " · deadline exceeded · cancellation pending")
153
+ : p.failureKind === "stalled"
154
+ ? theme.fg(
155
+ "warning",
156
+ " · stall detected · cancellation pending",
157
+ )
158
+ : "";
153
159
  const glyph = theme.fg("warning", spinnerFrame());
154
160
  lines.push(
155
161
  truncLine(
156
- `${tree(i, total)} ${glyph} ${theme.bold(p.agent)}${modelLabel(p)}${statJoin(runParts)}${stallTag}${theme.fg("muted", ageTag)}`,
162
+ `${tree(i, total)} ${glyph} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)}${statJoin(runParts)}${issueTag}${theme.fg("muted", ageTag)}`,
157
163
  w,
158
164
  ),
159
165
  );
@@ -229,7 +235,7 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
229
235
  );
230
236
  lines.push(
231
237
  truncLine(
232
- `${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(p.agent)}${modelLabel(p)} ${queuedTag}`,
238
+ `${tree(i, total)} ${theme.fg("muted", "○")} ${theme.bold(p.agent)}${p.id ? theme.fg("accent", formatTaskId(p.id)) : ""}${modelLabel(p)} ${queuedTag}`,
233
239
  w,
234
240
  ),
235
241
  );
@@ -260,12 +266,18 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
260
266
  const totalTokens = progress.reduce((sum, p) => sum + p.tokens, 0);
261
267
  const ticketId = ctx.ticketId;
262
268
  const ticketStatus = ctx.ticketStatus;
263
- const isLive = ticketStatus === "running" || ticketStatus === "cancelling";
269
+ // A terminal ticket can retain a stale running/pending row while its workers
270
+ // unwind. Keep the row presentation terminal in that case; an absent status
271
+ // is the synchronous-render path, where task status remains authoritative.
272
+ const ticketIsLive =
273
+ ticketStatus === undefined ||
274
+ ticketStatus === "running" ||
275
+ ticketStatus === "cancelling";
264
276
  const elapsed = state.startedAt
265
277
  ? fmtDuration(Date.now() - state.startedAt)
266
278
  : fmtDuration(progress.reduce((sum, p) => sum + p.durationMs, 0));
267
279
 
268
- if (ticketId && isLive) {
280
+ if (ticketId && ticketIsLive) {
269
281
  // Background ticket — frame it as in-progress, not a finished result.
270
282
  const ticketParts = [
271
283
  `ticket ${ticketId}`,
@@ -307,9 +319,15 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
307
319
  : p.status === "running"
308
320
  ? theme.fg("warning", "◐")
309
321
  : theme.fg("muted", "○");
310
- const taskPreview = theme.fg("muted", trunc(p.task, w - 30));
322
+ const taskId = p.id ? formatTaskId(p.id) : "";
323
+ const taskIdTag = p.id ? theme.fg("accent", taskId) : "";
324
+ const taskIdWidth = p.id ? taskId.length : 0;
325
+ const previewBudget = Math.max(1, w - 30 - taskIdWidth);
326
+ const taskPreview = theme.fg("muted", trunc(p.task, previewBudget));
311
327
  const isLive =
312
- p.status === "running" || (p.status === "pending" && !isCancelledPending);
328
+ ticketIsLive &&
329
+ (p.status === "running" ||
330
+ (p.status === "pending" && !isCancelledPending));
313
331
  // Live tasks show an activity/waiting hint instead of final stats.
314
332
  const liveTail =
315
333
  p.status === "running"
@@ -322,7 +340,7 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
322
340
  : "";
323
341
  lines.push(
324
342
  truncLine(
325
- `${tree(i, total)} ${icon} ${theme.bold(p.agent)}${modelLabel(p)} ${taskPreview}${isLive ? liveTail : cancelledTail || statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
343
+ `${tree(i, total)} ${icon} ${theme.bold(p.agent)}${modelLabel(p)}${taskIdTag} ${taskPreview}${isLive ? liveTail : cancelledTail || statJoin([fmtDuration(p.durationMs), `${fmtTokens(p.tokens)} tokens`])}`,
326
344
  w,
327
345
  ),
328
346
  );