@bermudi/pi-delegate 0.1.0 → 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/tickets.ts CHANGED
@@ -11,22 +11,30 @@ import {
11
11
  fmtDuration,
12
12
  fmtTokens,
13
13
  formatCompletedTask,
14
+ formatTaskId,
14
15
  shortenPath,
15
16
  trunc,
16
17
  getActivityAge,
17
18
  formatActivityLabel,
18
19
  taskMetaBase,
19
20
  relativeTouchedSummary,
21
+ findTouchedOverlaps,
22
+ formatTouchedOverlapWarning,
20
23
  } from "./format.ts";
24
+ import { isCrossLeafTicket } from "./leaf.ts";
21
25
  import { renderOutputForPoll } from "./spill.ts";
22
26
  import { scheduleDeadline } from "./timer.ts";
27
+ import { emptyUsage } from "./usage.ts";
23
28
  import type {
24
29
  AsyncTicket,
25
30
  DelegateDetails,
31
+ ResolvedTask,
26
32
  TaskResult,
27
33
  TicketWaiter,
28
34
  } from "./types.ts";
29
35
 
36
+ const PENDING_RESULT_ERROR = "PENDING — result not available";
37
+
30
38
  const busyTicketIdsBySession = new Map<string, Set<string>>();
31
39
  const busySessionsByTicket = new Map<string, Set<string>>();
32
40
 
@@ -119,6 +127,17 @@ export function cancelTicketForShutdown(ticket: AsyncTicket): void {
119
127
  settleTicketWaiters(ticket);
120
128
  }
121
129
 
130
+ /** Request cooperative cancellation of a live ticket: abort the workers and
131
+ * move to "cancelling" so they settle and report what actually ran. Unlike
132
+ * `cancelTicketForShutdown` this leaves the ticket deliverable — the runtime
133
+ * is still alive, so the final "cancelled" result still reaches the user. */
134
+ export function requestTicketCancel(ticket: AsyncTicket): void {
135
+ if (ticket.status !== "running") return;
136
+ ticket.controller.abort();
137
+ ticket.status = "cancelling";
138
+ syncTicketBusyIndex(ticket);
139
+ }
140
+
122
141
  /** Check if any running async ticket holds a given sessionId.
123
142
  * Backed by an O(1) map updated when tickets start/complete. */
124
143
  export function isSessionBusy(sessionId: string): string | null {
@@ -187,13 +206,23 @@ export function formatCompletedTicket(
187
206
  const r = ticket.results[i];
188
207
  const t = ticket.resolved[i]!;
189
208
  if (!r) {
190
- parts.push(`=== ${t.agentName}: ${trunc(t.prompt || "", 80)} ===`);
209
+ parts.push(
210
+ `=== ${t.agentName}${formatTaskId(t.id)}: ${trunc(t.prompt || "", 80)} ===`,
211
+ );
191
212
  parts.push(`[${pendingLabelFor(i)}]`);
192
213
  continue;
193
214
  }
194
215
  parts.push(...formatCompletedTask(t, r));
195
216
  }
196
217
 
218
+ const completedResults = ticket.results.filter(
219
+ (r): r is TaskResult => r !== undefined && "touchedFiles" in r,
220
+ );
221
+ const overlapWarning = formatTouchedOverlapWarning(
222
+ findTouchedOverlaps(completedResults),
223
+ );
224
+ if (overlapWarning) parts.push("", overlapWarning);
225
+
197
226
  if (ticket.status === "cancelled") {
198
227
  parts.push(
199
228
  "",
@@ -206,7 +235,11 @@ export function formatCompletedTicket(
206
235
  details: {
207
236
  tasks: ticket.tasks,
208
237
  results: [...ticket.results].map(
209
- (r, index) => r ?? { error: pendingLabelFor(index) },
238
+ (r, index) =>
239
+ r ?? {
240
+ ...pendingResultPlaceholder(ticket.resolved[index]),
241
+ error: pendingLabelFor(index),
242
+ },
210
243
  ),
211
244
  progress: [...ticket.progress],
212
245
  parentModel: ticket.parentModelId,
@@ -214,22 +247,49 @@ export function formatCompletedTicket(
214
247
  // the human sees which ticket they polled, even in the rich tree path.
215
248
  ticketId: ticket.id,
216
249
  status: ticket.status,
250
+ overlapWarning: overlapWarning || undefined,
217
251
  },
218
252
  };
219
253
  }
220
254
 
221
255
  // ── Waiter helpers ─────────────────────────────────────────────────────────
222
256
 
257
+ function pendingResultPlaceholder(task: ResolvedTask | undefined): TaskResult {
258
+ return {
259
+ id: task?.id,
260
+ agent: task?.agentName ?? "unknown",
261
+ output: "",
262
+ durationMs: 0,
263
+ tokens: 0,
264
+ usage: emptyUsage(),
265
+ touchedFiles: [],
266
+ attributedFiles: [],
267
+ // Machine-visible and human-readable marker so structured consumers do not
268
+ // mistake a pending placeholder for a successful result.
269
+ error: PENDING_RESULT_ERROR,
270
+ };
271
+ }
272
+
223
273
  function buildWaitDetails(ticket: AsyncTicket): DelegateDetails {
274
+ const completedResults = ticket.results.filter(
275
+ (r): r is TaskResult => r !== undefined && "touchedFiles" in r,
276
+ );
277
+ const overlapWarning = formatTouchedOverlapWarning(
278
+ findTouchedOverlaps(completedResults),
279
+ );
280
+ const results: TaskResult[] = [];
281
+ for (let i = 0; i < ticket.resolved.length; i++) {
282
+ const r = ticket.results[i];
283
+ results.push(r ?? pendingResultPlaceholder(ticket.resolved[i]));
284
+ }
224
285
  return {
225
286
  tasks: ticket.tasks,
226
- results: ticket.results.map(
227
- (r) => r ?? { error: "PENDING — result not available" },
228
- ),
287
+ results,
229
288
  progress: [...ticket.progress],
230
289
  parentModel: ticket.parentModelId,
231
290
  ticketId: ticket.id,
232
291
  status: ticket.status,
292
+ overlapWarning: overlapWarning || undefined,
233
293
  };
234
294
  }
235
295
 
@@ -251,9 +311,13 @@ function buildWaitRunningUpdate(
251
311
  if (failed > 0) parts.push(`${failed} failed`);
252
312
  if (pending > 0) parts.push(`${pending} queued`);
253
313
 
314
+ const details = buildWaitDetails(ticket);
315
+ const text =
316
+ parts.join(" · ") +
317
+ (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
254
318
  return {
255
- content: [{ type: "text", text: parts.join(" · ") }],
256
- details: buildWaitDetails(ticket),
319
+ content: [{ type: "text", text }],
320
+ details,
257
321
  };
258
322
  }
259
323
 
@@ -261,28 +325,26 @@ function buildWaitTimeoutResult(
261
325
  ticket: AsyncTicket,
262
326
  timeoutMs: number,
263
327
  ): AgentToolResult<DelegateDetails> {
328
+ const details = buildWaitDetails(ticket);
329
+ const base = `Ticket ${ticket.id} still ${ticket.status} after ${fmtDuration(timeoutMs)} · wait timed out (ticket continues in background)`;
330
+ const text =
331
+ base + (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
264
332
  return {
265
- content: [
266
- {
267
- type: "text",
268
- text: `Ticket ${ticket.id} still ${ticket.status} after ${fmtDuration(timeoutMs)} · wait timed out (ticket continues in background)`,
269
- },
270
- ],
271
- details: buildWaitDetails(ticket),
333
+ content: [{ type: "text", text }],
334
+ details,
272
335
  };
273
336
  }
274
337
 
275
338
  function buildWaitAbortResult(
276
339
  ticket: AsyncTicket,
277
340
  ): AgentToolResult<DelegateDetails> {
341
+ const details = buildWaitDetails(ticket);
342
+ const base = `Wait for ticket ${ticket.id} aborted · ticket continues ${ticket.status} in the background`;
343
+ const text =
344
+ base + (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
278
345
  return {
279
- content: [
280
- {
281
- type: "text",
282
- text: `Wait for ticket ${ticket.id} aborted · ticket continues ${ticket.status} in the background`,
283
- },
284
- ],
285
- details: buildWaitDetails(ticket),
346
+ content: [{ type: "text", text }],
347
+ details,
286
348
  };
287
349
  }
288
350
 
@@ -366,23 +428,57 @@ export function notifyWaiters(ticket: AsyncTicket): void {
366
428
  continue;
367
429
  }
368
430
  active.push(w);
369
- if (w.onUpdate) w.onUpdate(buildWaitRunningUpdate(ticket));
431
+ if (w.onUpdate) {
432
+ try {
433
+ w.onUpdate(buildWaitRunningUpdate(ticket));
434
+ } catch (error) {
435
+ // Progress delivery is an observer boundary. A host callback must not
436
+ // be able to fail the worker that reported the update or reject the
437
+ // wait promise; keep the waiter attached for the terminal result and
438
+ // leave the failure visible for diagnosis.
439
+ console.error(
440
+ `[delegate] wait progress callback for ticket '${ticket.id}' threw; continuing`,
441
+ error,
442
+ );
443
+ }
444
+ }
370
445
  }
371
446
  ticket.waiters = active.length ? active : undefined;
372
447
  }
373
448
 
449
+ /** Prefix for a result whose spawn leaf is no longer the active one. The model
450
+ * would otherwise read a foreign branch's work as current-turn context. */
451
+ const CROSS_LEAF_NOTICE =
452
+ "NOTE: this async delegate ticket was spawned on a different branch of the " +
453
+ "session tree; the conversation has since navigated elsewhere (/tree). These " +
454
+ "results may not relate to the current line of work — verify relevance before " +
455
+ "acting on them.";
456
+
457
+ /** How a completed ticket was handed back. `deferred` means the result was
458
+ * queued without waking the agent because the session navigated away from
459
+ * the spawn leaf; callers surface that to the human (see status.ts). */
460
+ export type TicketDelivery = "none" | "waiters" | "steer" | "deferred";
461
+
374
462
  /** Push results into parent session via sendMessage when background ticket completes.
375
463
  * If there are active blocking waiters, resolve them directly and suppress the
376
- * automatic follow-up so completion is delivered exactly once. */
464
+ * automatic follow-up so completion is delivered exactly once.
465
+ *
466
+ * Delivery mode depends on leaf affinity (see leaf.ts). Same leaf: `steer` +
467
+ * `triggerTurn`, the agent picks the result up immediately. Different leaf:
468
+ * `nextTurn`, which explicitly "does not interrupt or trigger anything" — the
469
+ * result waits for the human's next prompt instead of waking the agent on a
470
+ * branch the task was never part of. The ticket stays pollable either way. */
377
471
  export function deliverTicketResults(
378
472
  pi: ExtensionAPI,
379
473
  ticket: AsyncTicket,
380
- ): void {
381
- if (!ticket.completedAt) return;
474
+ ): TicketDelivery {
475
+ if (!ticket.completedAt) return "none";
382
476
 
383
477
  // Resolve active blocking waiters directly. Stale/aborted waiters are
384
478
  // cleaned but not resolved here (their abort handlers already returned).
385
- if (settleTicketWaiters(ticket)) return;
479
+ // A waiter is a tool call on the live leaf by construction, so leaf
480
+ // affinity does not apply to it.
481
+ if (settleTicketWaiters(ticket)) return "waiters";
386
482
 
387
483
  const formatted = formatCompletedTicket(ticket);
388
484
  const text = formatted.content
@@ -393,10 +489,12 @@ export function deliverTicketResults(
393
489
  .map((c) => c.text)
394
490
  .join("\n");
395
491
 
492
+ const crossLeaf = isCrossLeafTicket(ticket);
493
+
396
494
  pi.sendMessage(
397
495
  {
398
496
  customType: "async_delegate_result",
399
- content: text,
497
+ content: crossLeaf ? `${CROSS_LEAF_NOTICE}\n\n${text}` : text,
400
498
  display: true,
401
499
  details: {
402
500
  ...formatted.details,
@@ -404,11 +502,11 @@ export function deliverTicketResults(
404
502
  status: ticket.status,
405
503
  },
406
504
  },
407
- {
408
- deliverAs: "steer",
409
- triggerTurn: true,
410
- },
505
+ crossLeaf
506
+ ? { deliverAs: "nextTurn" }
507
+ : { deliverAs: "steer", triggerTurn: true },
411
508
  );
509
+ return crossLeaf ? "deferred" : "steer";
412
510
  }
413
511
 
414
512
  /** Return a snapshot of one async ticket or the complete ticket roster. */
@@ -434,7 +532,7 @@ export function handlePoll(
434
532
  "No async tickets.",
435
533
  "",
436
534
  "To spawn a subagent: delegate({ tasks: [{ agent, prompt }] }).",
437
- "For the full manual and agent list, call delegate({ tasks: [] }) with no top-level `action`.",
535
+ "For the full manual and agent list, call delegate({ tasks: [] }) with no top-level `ticketAction`.",
438
536
  ].join("\n"),
439
537
  },
440
538
  ],
@@ -467,9 +565,9 @@ export function handlePoll(
467
565
  // Copy-pasteable controls for running/cancelling tickets — a human can grab these
468
566
  // straight out of the TUI without retyping the ticket id.
469
567
  if (t.status === "running" || t.status === "cancelling") {
470
- line += `\n poll: delegate({ action: "poll", ticket: "${t.id}" })`;
568
+ line += `\n poll: delegate({ ticketAction: "poll", ticket: "${t.id}" })`;
471
569
  if (t.status === "running") {
472
- line += `\n cancel: delegate({ action: "cancel", ticket: "${t.id}", force: true })`;
570
+ line += `\n cancel: delegate({ ticketAction: "cancel", ticket: "${t.id}", force: true })`;
473
571
  }
474
572
  }
475
573
  return line;
@@ -537,9 +635,9 @@ export function handlePoll(
537
635
  if (r.touchedFiles.length > 0) {
538
636
  const t = ticket.resolved[i]!;
539
637
  const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
540
- if (touched) meta.push(`touched: ${touched}`);
638
+ if (touched) meta.push(`touched (best-effort): ${touched}`);
541
639
  }
542
- lines.push(`✓ ${r.agent} · ${meta.join(" · ")}`);
640
+ lines.push(`✓ ${r.agent}${formatTaskId(r.id)} · ${meta.join(" · ")}`);
543
641
  if (r.output && r.output !== "(no output)") {
544
642
  lines.push(renderOutputForPoll(r.output));
545
643
  }
@@ -549,10 +647,12 @@ export function handlePoll(
549
647
  if (r.touchedFiles.length > 0) {
550
648
  const t = ticket.resolved[i]!;
551
649
  const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
552
- if (touched) meta.push(`touched: ${touched}`);
650
+ if (touched) meta.push(`touched (best-effort): ${touched}`);
553
651
  }
554
652
  const errorText = r.error ?? "unknown error";
555
- lines.push(`✗ ${r.agent} · ${errorText} · ${meta.join(" · ")}`);
653
+ lines.push(
654
+ `✗ ${r.agent}${formatTaskId(r.id)} · ${errorText} · ${meta.join(" · ")}`,
655
+ );
556
656
  if (r.sessionFile)
557
657
  lines.push(` session: ${shortenPath(r.sessionFile)}`);
558
658
  if (r.output && r.output !== "(no output)")
@@ -565,12 +665,19 @@ export function handlePoll(
565
665
  if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
566
666
  const age = getActivityAge(p.lastActivityAt);
567
667
  if (age) parts.push(age);
568
- lines.push(`⏳ ${p.agent} · ${parts.join(" · ")}`);
668
+ lines.push(`⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`);
569
669
  } else {
570
- lines.push(`○ ${p.agent} · waiting…`);
670
+ lines.push(`○ ${p.agent}${formatTaskId(p.id)} · waiting…`);
571
671
  }
572
672
  }
573
673
 
674
+ const completedForOverlap = completedResults.filter(
675
+ (r): r is TaskResult => r !== undefined,
676
+ );
677
+ const overlapWarning = formatTouchedOverlapWarning(
678
+ findTouchedOverlaps(completedForOverlap),
679
+ );
680
+
574
681
  const headerStatus =
575
682
  ticket.status === "cancelling" ? "CANCELLING" : "RUNNING";
576
683
  const headerParts: string[] = [
@@ -597,13 +704,13 @@ export function handlePoll(
597
704
  content: [
598
705
  {
599
706
  type: "text",
600
- text: `${header}\n${lines.join("\n")}${guidance ? `\n\n${guidance}` : ""}`,
707
+ text: `${header}\n${lines.join("\n")}${guidance ? `\n\n${guidance}` : ""}${overlapWarning ? `\n\n${overlapWarning}` : ""}`,
601
708
  },
602
709
  ],
603
710
  details: {
604
711
  tasks: ticket.tasks,
605
712
  results: completedResults.map(
606
- (r) => r ?? { error: "PENDING — result not available" },
713
+ (r, i) => r ?? pendingResultPlaceholder(ticket.resolved[i]),
607
714
  ),
608
715
  progress: [...ticket.progress],
609
716
  parentModel: ticket.parentModelId,
@@ -611,6 +718,7 @@ export function handlePoll(
611
718
  // (friction #2). The LLM-facing content still names the ticket id too.
612
719
  ticketId: ticket.id,
613
720
  status: ticket.status,
721
+ overlapWarning: overlapWarning || undefined,
614
722
  },
615
723
  };
616
724
  }
@@ -633,9 +741,9 @@ function buildCancelPreview(ticket: AsyncTicket): string {
633
741
  for (let i = 0; i < ticket.progress.length; i++) {
634
742
  const p = ticket.progress[i]!;
635
743
  if (p.status === "done") {
636
- lines.push(`✓ ${p.agent} · completed`);
744
+ lines.push(`✓ ${p.agent}${formatTaskId(p.id)} · completed`);
637
745
  } else if (p.status === "failed") {
638
- lines.push(`✗ ${p.agent} · ${p.error ?? "failed"}`);
746
+ lines.push(`✗ ${p.agent}${formatTaskId(p.id)} · ${p.error ?? "failed"}`);
639
747
  } else if (p.status === "running") {
640
748
  const parts: string[] = [formatActivityLabel(p)];
641
749
  if (p.toolUses > 0)
@@ -643,16 +751,16 @@ function buildCancelPreview(ticket: AsyncTicket): string {
643
751
  if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
644
752
  const age = getActivityAge(p.lastActivityAt);
645
753
  if (age) parts.push(age);
646
- lines.push(`⏳ ${p.agent} · ${parts.join(" · ")}`);
754
+ lines.push(`⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`);
647
755
  } else {
648
- lines.push(`○ ${p.agent} · waiting…`);
756
+ lines.push(`○ ${p.agent}${formatTaskId(p.id)} · waiting…`);
649
757
  }
650
758
  }
651
759
 
652
760
  lines.push(
653
761
  "",
654
762
  "WARNING: Cancelling now will abort active subagents. Files already written or shell commands already executed are NOT rolled back.",
655
- `To proceed, call delegate({ action: "cancel", ticket: "${ticket.id}", force: true }).`,
763
+ `To proceed, call delegate({ ticketAction: "cancel", ticket: "${ticket.id}", force: true }).`,
656
764
  );
657
765
  return lines.join("\n");
658
766
  }
@@ -668,7 +776,7 @@ export function handleCancel(params: {
668
776
  if (!ticketId) {
669
777
  return {
670
778
  content: [
671
- { type: "text", text: "action='cancel' requires a ticket ID." },
779
+ { type: "text", text: "ticketAction='cancel' requires a ticket ID." },
672
780
  ],
673
781
  details: { tasks: [], results: [], progress: [] },
674
782
  };
@@ -692,29 +800,30 @@ export function handleCancel(params: {
692
800
  };
693
801
  }
694
802
  if (!params.force) {
803
+ const details = buildWaitDetails(ticket);
804
+ const text =
805
+ buildCancelPreview(ticket) +
806
+ (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
695
807
  return {
696
- content: [{ type: "text", text: buildCancelPreview(ticket) }],
697
- details: buildWaitDetails(ticket),
808
+ content: [{ type: "text", text }],
809
+ details,
698
810
  };
699
811
  }
700
- ticket.controller.abort();
701
- ticket.status = "cancelling";
702
- syncTicketBusyIndex(ticket);
812
+ requestTicketCancel(ticket);
813
+ const details = buildWaitDetails(ticket);
814
+ const base = `Ticket '${ticketId}' is cancelling; workers are settling. Poll for final status.`;
815
+ const text =
816
+ base + (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
703
817
  return {
704
- content: [
705
- {
706
- type: "text",
707
- text: `Ticket '${ticketId}' is cancelling; workers are settling. Poll for final status.`,
708
- },
709
- ],
710
- details: buildWaitDetails(ticket),
818
+ content: [{ type: "text", text }],
819
+ details,
711
820
  };
712
821
  }
713
822
 
714
823
  /** Block until a ticket reaches a terminal state or `timeoutMs` expires.
715
824
  * Progress is streamed through `onUpdate` without consuming model turns.
716
825
  * Parent-tool abort or timeout detaches the waiter and leaves the ticket
717
- * running; cancellation remains explicit (`action: "cancel"`). */
826
+ * running; cancellation remains explicit (`ticketAction: "cancel"`). */
718
827
  export function handleWait(
719
828
  params: { ticket?: string; timeoutMs?: number },
720
829
  signal: AbortSignal | undefined,
@@ -727,7 +836,9 @@ export function handleWait(
727
836
  const ticketId = params.ticket;
728
837
  if (!ticketId) {
729
838
  return Promise.resolve({
730
- content: [{ type: "text", text: "action='wait' requires a ticket ID." }],
839
+ content: [
840
+ { type: "text", text: "ticketAction='wait' requires a ticket ID." },
841
+ ],
731
842
  details: {
732
843
  tasks: [],
733
844
  results: [],
package/tools.ts CHANGED
@@ -12,20 +12,22 @@ import { DEFAULT_TOOLS, READONLY_TOOLS } from "./constants.ts";
12
12
 
13
13
  /** Shorthand → concrete tool list. `*` = full agent (bash subsumes search);
14
14
  * `ro` = read-only scout (search without shell). */
15
- const TOOL_GROUPS: Record<string, string[]> = {
16
- "*": DEFAULT_TOOLS,
17
- ro: READONLY_TOOLS,
18
- };
15
+ const TOOL_GROUPS: Record<string, string[]> = Object.create(null) as Record<
16
+ string,
17
+ string[]
18
+ >;
19
+ TOOL_GROUPS["*"] = DEFAULT_TOOLS;
20
+ TOOL_GROUPS.ro = READONLY_TOOLS;
19
21
 
20
- export const TOOL_FACTORIES: Record<string, (cwd: string) => AgentTool<any>> = {
21
- read: createReadTool,
22
- write: createWriteTool,
23
- edit: createEditTool,
24
- bash: createBashTool,
25
- grep: createGrepTool,
26
- find: createFindTool,
27
- ls: createLsTool,
28
- };
22
+ export const TOOL_FACTORIES: Record<string, (cwd: string) => AgentTool<any>> =
23
+ Object.create(null) as Record<string, (cwd: string) => AgentTool<any>>;
24
+ TOOL_FACTORIES.read = createReadTool;
25
+ TOOL_FACTORIES.write = createWriteTool;
26
+ TOOL_FACTORIES.edit = createEditTool;
27
+ TOOL_FACTORIES.bash = createBashTool;
28
+ TOOL_FACTORIES.grep = createGrepTool;
29
+ TOOL_FACTORIES.find = createFindTool;
30
+ TOOL_FACTORIES.ls = createLsTool;
29
31
 
30
32
  /** Expand tool-group shorthands (`*`, `ro`) into concrete tool lists.
31
33
  * Unknown names pass through unchanged for the caller to validate.
@@ -33,8 +35,7 @@ export const TOOL_FACTORIES: Record<string, (cwd: string) => AgentTool<any>> = {
33
35
  export function resolveToolGroups(tools: string[]): string[] {
34
36
  const resolved: string[] = [];
35
37
  for (const t of tools) {
36
- const group = TOOL_GROUPS[t];
37
- if (group) resolved.push(...group);
38
+ if (Object.hasOwn(TOOL_GROUPS, t)) resolved.push(...TOOL_GROUPS[t]);
38
39
  else resolved.push(t);
39
40
  }
40
41
  return [...new Set(resolved)];
package/types.ts CHANGED
@@ -25,18 +25,37 @@ export interface AgentConfig {
25
25
  }
26
26
 
27
27
  // ── Tool parameter types — derived from the TypeBox schema ────────────────
28
- // `delegateArgumentsSchema` in schema.ts is the single source of truth; these are
29
- // projections of it, so schema and types cannot drift. Field semantics live
30
- // in the schema's `description`s (which the calling model also sees).
31
- // The import is type-only, so the schema.ts ↔ types.ts cycle is erased at
32
- // compile time.
33
-
34
- export type DelegateArguments = Static<typeof delegateArgumentsSchema>;
35
- export type TaskDef = NonNullable<DelegateArguments["tasks"]>[number];
28
+ // `delegateArgumentsSchema` in schema.ts is the canonical provider-visible
29
+ // shape. The public types add deprecated `action` aliases so existing TypeScript
30
+ // callers remain source-compatible without advertising the overloaded fields to
31
+ // models. The import is type-only, so the schema.ts ↔ types.ts cycle is erased
32
+ // at compile time.
33
+
34
+ type CanonicalDelegateArguments = Static<typeof delegateArgumentsSchema>;
35
+ type CanonicalTaskDef = NonNullable<
36
+ CanonicalDelegateArguments["tasks"]
37
+ >[number];
38
+
36
39
  /** Top-level async ticket action: "poll" | "cancel" | "wait". */
37
- export type DelegateAction = NonNullable<DelegateArguments["action"]>;
40
+ export type TicketAction = NonNullable<
41
+ CanonicalDelegateArguments["ticketAction"]
42
+ >;
38
43
  /** Per-task session action: "prompt" | "close" | "list". */
39
- export type SessionAction = NonNullable<TaskDef["action"]>;
44
+ export type SessionAction = NonNullable<CanonicalTaskDef["sessionAction"]>;
45
+
46
+ export type TaskDef = CanonicalTaskDef & {
47
+ /** @deprecated Use `sessionAction` instead. Runtime normalization still accepts this alias. */
48
+ action?: SessionAction;
49
+ };
50
+
51
+ export type DelegateArguments = Omit<CanonicalDelegateArguments, "tasks"> & {
52
+ /** @deprecated Use `ticketAction` instead. Runtime normalization still accepts this alias. */
53
+ action?: TicketAction;
54
+ tasks?: TaskDef[];
55
+ };
56
+
57
+ /** @deprecated Use `TicketAction` instead. */
58
+ export type DelegateAction = TicketAction;
40
59
 
41
60
  // ── Async Ticket Types ─────────────────────────────────────────────────────
42
61
 
@@ -61,10 +80,22 @@ export interface AsyncTicket {
61
80
  controller: AbortController;
62
81
  error?: string;
63
82
  parentModelId?: string;
83
+ /** Session-tree leaf active when the ticket was spawned (see leaf.ts).
84
+ * `undefined` = the leaf the session opened on. Compared at delivery time
85
+ * so results are not used to wake the agent on a foreign branch. */
86
+ spawnLeafId?: string | null;
64
87
  /** Active blocking waiters. Resolved by terminal delivery or timeout/abort. */
65
88
  waiters?: TicketWaiter[];
66
89
  }
67
90
 
91
+ /** Live parent settings captured when a delegate call starts. The built-in
92
+ * `default` profile mirrors these settings, limited to tools delegate can
93
+ * safely recreate without loading the parent's extensions. */
94
+ export interface ParentAgentDefaults {
95
+ thinking: ThinkingLevel;
96
+ tools: string[];
97
+ }
98
+
68
99
  export interface ReuseIntent {
69
100
  /** Explicit model requested by this call/profile; omitted means use frozen. */
70
101
  model?: Model<Api>;
@@ -73,6 +104,7 @@ export interface ReuseIntent {
73
104
  }
74
105
 
75
106
  export interface ResolvedTask {
107
+ id?: string;
76
108
  prompt: string;
77
109
  agent?: string;
78
110
  model: Model<Api>;
@@ -82,8 +114,10 @@ export interface ResolvedTask {
82
114
  cwd: string;
83
115
  context?: "fresh" | "with-parent-transcript";
84
116
  sessionId?: string;
85
- action?: SessionAction;
117
+ sessionAction?: SessionAction;
86
118
  resumeFrom?: string;
119
+ /** Hard wall-clock budget in milliseconds, measured from task start. */
120
+ deadlineMs?: number;
87
121
  agentName: string;
88
122
  warnings: string[];
89
123
  /** Explicit settings that must match a live pooled session on reuse. */
@@ -109,10 +143,14 @@ export interface ToolActivity {
109
143
  * - `model_error`: the failure is attributable to the resolved model/provider
110
144
  * (account usage limit, quota exhausted, auth lost) — not transient for that
111
145
  * model, so same-model retry is pointless. The parent should resume with a
112
- * different `model` (see `resumeFrom` + `model`). */
113
- export type TaskFailureKind = "stalled" | "model_error";
146
+ * different `model` (see `resumeFrom` + `model`).
147
+ * - `deadline_exceeded`: the task's `deadlineMs` wall-clock budget expired
148
+ * (measured from when the task left the concurrency queue). The prompt was
149
+ * cooperatively aborted; completed side effects are not rolled back. */
150
+ export type TaskFailureKind = "stalled" | "model_error" | "deadline_exceeded";
114
151
 
115
152
  export interface TaskProgress {
153
+ id?: string;
116
154
  index: number;
117
155
  agent: string;
118
156
  task: string;
@@ -138,9 +176,13 @@ export interface DelegateDetails {
138
176
  ticketId?: string;
139
177
  /** Terminal/live ticket status when this result comes from an async ticket. */
140
178
  status?: AsyncTicket["status"];
179
+ /** Global overlap warning derived from result.attributedFiles, surfaced in both
180
+ * the textual content and the custom TUI. */
181
+ overlapWarning?: string;
141
182
  }
142
183
 
143
184
  export interface TaskResult {
185
+ id?: string;
144
186
  agent: string;
145
187
  output: string;
146
188
  error?: string;
@@ -159,7 +201,14 @@ export interface TaskResult {
159
201
  * Pi sums `cost.total` for nested usage anyway. */
160
202
  usage: Usage;
161
203
  sessionFile?: string;
204
+ /** All files the subagent is known to have touched, including bash mutations
205
+ * captured via git diff and other tasks' concurrent git changes. This is a
206
+ * best-effort, repository-wide list for display, not for attribution. */
162
207
  touchedFiles: string[];
208
+ /** Files directly attributable to this task's edit/write tool calls. Used
209
+ * for overlap detection so concurrent tasks in the same repo do not
210
+ * fabricate false conflicts from shared git snapshots. */
211
+ attributedFiles?: string[];
163
212
  }
164
213
 
165
214
  /** Single source of truth for a subagent's runtime configuration.