@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/lifecycle.ts CHANGED
@@ -20,12 +20,14 @@ 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";
30
+ import { recordTask } from "./telemetry.ts";
29
31
 
30
32
  /** Internal seam for lifecycle-level tests without replacing session ownership. */
31
33
  type RunAgentSession = typeof runAgentSession;
@@ -72,6 +74,7 @@ function failTask(
72
74
  sessionFile?: string,
73
75
  ): TaskResult {
74
76
  return {
77
+ id: task.id,
75
78
  agent: task.agentName,
76
79
  output: "",
77
80
  error,
@@ -80,6 +83,7 @@ function failTask(
80
83
  usage: emptyUsage(),
81
84
  sessionFile,
82
85
  touchedFiles: [],
86
+ attributedFiles: [],
83
87
  };
84
88
  }
85
89
 
@@ -91,6 +95,7 @@ function completeSessionAction(
91
95
  elapsedMs?: number,
92
96
  ): TaskResult {
93
97
  return {
98
+ id: task.id,
94
99
  agent: task.agentName,
95
100
  output,
96
101
  durationMs: elapsedMs ?? 0,
@@ -98,6 +103,7 @@ function completeSessionAction(
98
103
  usage: emptyUsage(),
99
104
  sessionFile: undefined,
100
105
  touchedFiles: [],
106
+ attributedFiles: [],
101
107
  };
102
108
  }
103
109
 
@@ -182,8 +188,22 @@ function finishTask(
182
188
  env: TaskRunEnv,
183
189
  p: TaskProgress,
184
190
  r: TaskResult,
191
+ task: ResolvedTask,
192
+ retries = 0,
185
193
  ): TaskResult {
186
194
  updateProgressFromResult(p, r);
195
+ if (env.telemetryCallId) {
196
+ recordTask({
197
+ callId: env.telemetryCallId,
198
+ generation: env.telemetryGeneration,
199
+ async: env.async ?? false,
200
+ taskIndex: p.index,
201
+ task,
202
+ progress: p,
203
+ result: r,
204
+ retries,
205
+ });
206
+ }
187
207
  env.onStatusChange?.();
188
208
  return r;
189
209
  }
@@ -266,6 +286,7 @@ function canRetryWholeTask(
266
286
  return (
267
287
  result.failureKind !== "stalled" &&
268
288
  result.failureKind !== "model_error" &&
289
+ result.failureKind !== "deadline_exceeded" &&
269
290
  !task.sessionId &&
270
291
  !task.resumeFrom &&
271
292
  result.touchedFiles.length === 0 &&
@@ -277,16 +298,23 @@ function canRetryWholeTask(
277
298
  async function sleepForWholeTaskRetry(
278
299
  signal: AbortSignal | undefined,
279
300
  delayMs: number,
301
+ deadlineAt?: number,
280
302
  ): Promise<void> {
281
303
  if (signal?.aborted) return;
304
+ if (deadlineAt !== undefined && Date.now() >= deadlineAt) return;
305
+
306
+ const retryAt = Date.now() + delayMs;
307
+ const wakeAt =
308
+ deadlineAt !== undefined ? Math.min(retryAt, deadlineAt) : retryAt;
309
+
282
310
  await new Promise<void>((resolve) => {
283
- let timeout: ReturnType<typeof setTimeout>;
311
+ let clear: (() => void) | undefined;
284
312
  const done = () => {
285
- clearTimeout(timeout);
313
+ clear?.();
286
314
  signal?.removeEventListener("abort", done);
287
315
  resolve();
288
316
  };
289
- timeout = setTimeout(done, delayMs);
317
+ clear = scheduleDeadline(wakeAt, done);
290
318
  if (!signal) return;
291
319
  signal.addEventListener("abort", done, { once: true });
292
320
  });
@@ -516,7 +544,7 @@ function resolveResumableSessionFile(
516
544
  * Used by both sync (params.async === false) and async (params.async === true) paths.
517
545
  * When task.sessionId is set, the entire acquire/run/close lifecycle runs under
518
546
  * 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. */
547
+ * cleanly. The lock also covers sessionAction='close' and the early-busy/abort paths. */
520
548
  export async function runResolvedTask(
521
549
  env: TaskRunEnv,
522
550
  task: ResolvedTask,
@@ -540,7 +568,7 @@ async function runResolvedTaskUnlocked(
540
568
  try {
541
569
  // ── Aborted before we started? ───────────────────────────────────
542
570
  if (env.signal?.aborted) {
543
- return finishTask(env, p, failTask(task, "Aborted"));
571
+ return finishTask(env, p, failTask(task, "Aborted"), task);
544
572
  }
545
573
 
546
574
  // ── Session busy guard (defense-in-depth) ────────────────────────
@@ -550,7 +578,7 @@ async function runResolvedTaskUnlocked(
550
578
  const busyTicketId = isSessionBusy(task.sessionId);
551
579
  if (busyTicketId && busyTicketId !== env.ticketId) {
552
580
  const msg = `Session '${task.sessionId}' is already in use by ticket ${busyTicketId}. Each session can only handle one task at a time.`;
553
- return finishTask(env, p, failTask(task, msg));
581
+ return finishTask(env, p, failTask(task, msg), task);
554
582
  }
555
583
  }
556
584
 
@@ -558,12 +586,13 @@ async function runResolvedTaskUnlocked(
558
586
  p.model = task.model?.id;
559
587
 
560
588
  // ── Session action handling ───────────────────────────────────────
561
- if (task.action === "close") {
589
+ if (task.sessionAction === "close") {
562
590
  if (!task.sessionId) {
563
591
  return finishTask(
564
592
  env,
565
593
  p,
566
- failTask(task, "action='close' requires sessionId."),
594
+ failTask(task, "sessionAction='close' requires sessionId."),
595
+ task,
567
596
  );
568
597
  }
569
598
  // The per-session lock for action-based operations is already held by the
@@ -580,10 +609,11 @@ async function runResolvedTaskUnlocked(
580
609
  : `Session '${task.sessionId}' not found.`,
581
610
  Date.now() - env.delegateStartedAt,
582
611
  ),
612
+ task,
583
613
  );
584
614
  }
585
615
 
586
- if (task.action === "list") {
616
+ if (task.sessionAction === "list") {
587
617
  return finishTask(
588
618
  env,
589
619
  p,
@@ -592,6 +622,7 @@ async function runResolvedTaskUnlocked(
592
622
  `Active sessions:\n${pool.listPooledAgents().join("\n")}`,
593
623
  Date.now() - env.delegateStartedAt,
594
624
  ),
625
+ task,
595
626
  );
596
627
  }
597
628
 
@@ -599,6 +630,10 @@ async function runResolvedTaskUnlocked(
599
630
  let cumulativeTokens = 0;
600
631
  let cumulativeToolUses = 0;
601
632
  const taskStartedAt = Date.now();
633
+ const deadlineAt =
634
+ task.deadlineMs && task.deadlineMs > 0
635
+ ? taskStartedAt + task.deadlineMs
636
+ : undefined;
602
637
  let accumulatedUsage = emptyUsage();
603
638
 
604
639
  const onAttemptProgress = (u: AgentProgressUpdate): void => {
@@ -651,7 +686,10 @@ async function runResolvedTaskUnlocked(
651
686
 
652
687
  // Snapshot git status before the run so touchedFiles can diff after.
653
688
  // AgentSession owns retry/compaction internally — runAgentSession just
654
- // drives the prompt and maps events to the progress model.
689
+ // drives the prompt and maps events to the progress model. Git failures
690
+ // degrade to an undefined baseline, which tells the runner to skip
691
+ // git-based attribution entirely; see getGitChangedFiles for the
692
+ // contract.
655
693
  const gitBaseline = await getGitChangedFiles(task.cwd);
656
694
  let r = await runAgentSessionForTesting(
657
695
  acquired.session,
@@ -660,7 +698,8 @@ async function runResolvedTaskUnlocked(
660
698
  env.signal,
661
699
  onProgress,
662
700
  gitBaseline,
663
- Date.now(),
701
+ taskStartedAt,
702
+ deadlineAt,
664
703
  );
665
704
 
666
705
  cumulativeTokens += r.tokens;
@@ -682,7 +721,11 @@ async function runResolvedTaskUnlocked(
682
721
  // Pool misses (including resumeFrom) transfer ownership only on
683
722
  // successful completion; failures are owned by lifecycle and must
684
723
  // be disposed in this finally path.
685
- if (!r.error && r.failureKind !== "stalled") {
724
+ if (
725
+ !r.error &&
726
+ r.failureKind !== "stalled" &&
727
+ r.failureKind !== "deadline_exceeded"
728
+ ) {
686
729
  const committed = pool.commit(task.sessionId, {
687
730
  session: acquired.session,
688
731
  sessionManager: acquired.sessionManager,
@@ -699,28 +742,37 @@ async function runResolvedTaskUnlocked(
699
742
  sessionReleased = sessionReleased || committed;
700
743
  }
701
744
  } 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") {
745
+ // A stalled, parent-aborted, or mid-prompt deadline-exceeded pooled
746
+ // attempt is not safe to keep; the session may have been mutated.
747
+ // A pre-prompt deadline (runner never called session.prompt()) left
748
+ // the session in its pre-task state, so return it to the pool intact.
749
+ if (
750
+ r.failureKind === "stalled" ||
751
+ r.error === "Aborted" ||
752
+ (r.failureKind === "deadline_exceeded" && r.prompted !== false)
753
+ ) {
705
754
  try {
706
755
  sessionReleased =
707
756
  (await pool._closePooledAgentWithoutLock(task.sessionId)) ||
708
757
  sessionReleased;
709
758
  } catch (error) {
710
- // Preserve the primary stalled result while logging the cleanup
759
+ // Preserve the primary failure result while logging the cleanup
711
760
  // failure explicitly. A pooled session may still be removed by
712
761
  // the pool; a pool-miss remains lifecycle-owned and is handled
713
762
  // by the finally path above.
714
763
  console.error(
715
- `[delegate] failed to dispose stalled pooled session '${task.sessionId}'`,
764
+ `[delegate] failed to dispose aborted, stalled, or deadline-exceeded pooled session '${task.sessionId}'`,
716
765
  error,
717
766
  );
718
767
  }
719
- } else {
720
- // Pool hits stay owned by the pool, and non-stalled completions
721
- // (including failed attempts) must still count usage.
768
+ } else if (r.failureKind !== "deadline_exceeded") {
769
+ // Pool hits stay owned by the pool, and non-stalled, non-aborted
770
+ // completions (including failed attempts) must still count usage.
722
771
  pool.recordUse(task.sessionId, r.tokens);
723
772
  }
773
+ // Pre-prompt deadline (prompted === false): the pooled session was
774
+ // checked out but never used. Leave it in the pool with no usage
775
+ // recorded.
724
776
  }
725
777
  }
726
778
 
@@ -728,6 +780,7 @@ async function runResolvedTaskUnlocked(
728
780
  cumulativeToolUses += attemptToolUsesObserved;
729
781
 
730
782
  return {
783
+ id: task.id,
731
784
  agent: task.agentName,
732
785
  output: r.output,
733
786
  error: r.error,
@@ -747,6 +800,7 @@ async function runResolvedTaskUnlocked(
747
800
  usage: r.usage,
748
801
  sessionFile,
749
802
  touchedFiles: r.touchedFiles,
803
+ attributedFiles: r.attributedFiles ?? [],
750
804
  };
751
805
  } finally {
752
806
  // This runs for ordinary success, normal provider failure, whole-task
@@ -756,24 +810,51 @@ async function runResolvedTaskUnlocked(
756
810
  }
757
811
  };
758
812
 
813
+ const buildDeadlineExceededResult = (prior?: TaskResult): TaskResult => {
814
+ const budgetMs = Math.max(0, (deadlineAt ?? 0) - taskStartedAt);
815
+ return {
816
+ id: task.id,
817
+ agent: task.agentName,
818
+ output: prior?.output ?? "",
819
+ error: formatDeadlineExceededError(budgetMs),
820
+ failureKind: "deadline_exceeded",
821
+ durationMs: prior?.durationMs ?? 0,
822
+ tokens: prior?.tokens ?? 0,
823
+ usage: prior?.usage ?? emptyUsage(),
824
+ sessionFile: prior?.sessionFile,
825
+ touchedFiles: prior?.touchedFiles ?? [],
826
+ attributedFiles: prior?.attributedFiles ?? [],
827
+ };
828
+ };
829
+
759
830
  let result: TaskResult;
760
831
  try {
761
- result = await runAttempt();
832
+ if (deadlineAt && Date.now() >= deadlineAt) {
833
+ result = buildDeadlineExceededResult(undefined);
834
+ } else {
835
+ result = await runAttempt();
836
+ }
762
837
  } catch (err) {
763
838
  const failure = failTask(
764
839
  task,
765
840
  err instanceof Error ? err.message : String(err),
766
841
  );
767
- return finishTask(env, p, {
768
- ...failure,
769
- durationMs: Math.max(failure.durationMs, Date.now() - taskStartedAt),
770
- tokens: accumulatedUsage.totalTokens,
771
- usage: accumulatedUsage,
772
- });
842
+ return finishTask(
843
+ env,
844
+ p,
845
+ {
846
+ ...failure,
847
+ durationMs: Math.max(failure.durationMs, Date.now() - taskStartedAt),
848
+ tokens: accumulatedUsage.totalTokens,
849
+ usage: accumulatedUsage,
850
+ },
851
+ task,
852
+ );
773
853
  }
774
854
 
775
855
  const maxRetries = resolvedWholeTaskMaxRetries();
776
856
  const baseDelayMs = resolvedWholeTaskBaseDelayMs();
857
+ let retriesExecuted = 0;
777
858
  for (
778
859
  let retry = 0;
779
860
  retry < maxRetries && canRetryWholeTask(task, result, hasBashExecution);
@@ -781,7 +862,7 @@ async function runResolvedTaskUnlocked(
781
862
  ) {
782
863
  const delayMs = baseDelayMs * 2 ** retry;
783
864
  p.durationMs = Math.max(p.durationMs, Date.now() - taskStartedAt);
784
- await sleepForWholeTaskRetry(env.signal, delayMs);
865
+ await sleepForWholeTaskRetry(env.signal, delayMs, deadlineAt);
785
866
  if (env.signal?.aborted) {
786
867
  // Preserve any partial output/session path from the last failed attempt
787
868
  // while recording that the retry loop was aborted. The task already
@@ -798,10 +879,16 @@ async function runResolvedTaskUnlocked(
798
879
  break;
799
880
  }
800
881
 
882
+ if (deadlineAt && Date.now() >= deadlineAt) {
883
+ result = buildDeadlineExceededResult(result);
884
+ break;
885
+ }
886
+
801
887
  p.status = "running";
802
888
  p.error = undefined;
803
889
  p.failureKind = undefined;
804
890
  env.onStatusChange?.();
891
+ retriesExecuted++;
805
892
  try {
806
893
  result = await runAttempt();
807
894
  } catch (err) {
@@ -819,12 +906,18 @@ async function runResolvedTaskUnlocked(
819
906
  }
820
907
  }
821
908
 
822
- return finishTask(env, p, {
823
- ...result,
824
- durationMs: Math.max(result.durationMs, Date.now() - taskStartedAt),
825
- tokens: Math.max(cumulativeTokens, accumulatedUsage.totalTokens),
826
- usage: accumulatedUsage,
827
- });
909
+ return finishTask(
910
+ env,
911
+ p,
912
+ {
913
+ ...result,
914
+ durationMs: Math.max(result.durationMs, Date.now() - taskStartedAt),
915
+ tokens: Math.max(cumulativeTokens, accumulatedUsage.totalTokens),
916
+ usage: accumulatedUsage,
917
+ },
918
+ task,
919
+ retriesExecuted,
920
+ );
828
921
  } catch (err) {
829
922
  // Any acquired session is released by runAttempt's finally before an
830
923
  // exception reaches this boundary. This outer catch handles unexpected
@@ -834,6 +927,7 @@ async function runResolvedTaskUnlocked(
834
927
  env,
835
928
  p,
836
929
  failTask(task, err instanceof Error ? err.message : String(err)),
930
+ task,
837
931
  );
838
932
  }
839
933
  }
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,36 @@ 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 take one progress snapshot',
189
+ '- `delegate({ ticketAction: "wait", ticket: "abc123" })` \u2014 block until finished; omit `timeoutMs` when the result is needed this turn',
190
+ '- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 bounded wait; timeout includes the latest snapshot, so do not poll afterward',
191
+ '- `delegate({ ticketAction: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
192
+ '- `delegate({ ticketAction: "cancel", ticket: "abc123", force: true })` \u2014 abort after review',
172
193
  "",
173
194
  `See the field tables above for the full semantics. Max ${getMaxAsyncTickets()} concurrent async tickets.`,
195
+ "Async results arrive as follow-up messages, so Pi cannot fold their usage into the parent session total; displayed task usage remains informational.",
174
196
  "",
175
197
  "## Gotchas",
176
198
  "",
199
+ "- Dispatch validation is batch-wide and runs before spawning: one invalid task rejects the call without starting its siblings.",
177
200
  "- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
178
201
  '- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
179
202
  '- 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
203
  "- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
181
204
  "- 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
205
  `- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
206
+ "- `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.",
207
+ "",
208
+ "## Legacy `action` compatibility",
209
+ "",
210
+ "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
211
  "",
184
212
  "## Config",
185
213
  "",
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.3",
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
  );
package/render-result.ts CHANGED
@@ -166,6 +166,18 @@ export function renderDelegateResult(
166
166
  ticketStatus,
167
167
  };
168
168
 
169
+ // Surface the touched-file overlap warning at the top of the TUI. The same
170
+ // text already lives in the textual content, but the progress-based renderer
171
+ // ignores content, so we must render it explicitly from details. Rendering it
172
+ // before the progress tree places it at the top of the budgeted region, so it
173
+ // survives truncation from the bottom when many tasks collapse the view.
174
+ if (details?.overlapWarning) {
175
+ lines.push(
176
+ truncLine(theme.fg("warning", `⚠ ${details.overlapWarning}`), w),
177
+ "",
178
+ );
179
+ }
180
+
169
181
  if (options.isPartial) {
170
182
  renderPartialBranch(branchCtx, helpers);
171
183
  } else {