@bermudi/pi-delegate 0.1.9 → 0.1.11

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
@@ -9,21 +9,21 @@ import type {
9
9
  import { ASYNC_TICKET_TTL_MS } from "./constants.ts";
10
10
  import {
11
11
  fmtDuration,
12
- fmtTokens,
13
12
  formatCompletedTask,
14
13
  formatTaskId,
15
- shortenPath,
16
14
  trunc,
17
- getActivityAge,
18
- formatActivityLabel,
19
- taskMetaBase,
20
- relativeTouchedSummary,
21
15
  findTouchedOverlaps,
22
16
  formatTouchedOverlapWarning,
23
17
  } from "./format.ts";
24
18
  import { isCrossLeafTicket } from "./leaf.ts";
25
- import { renderOutputForPoll } from "./spill.ts";
26
19
  import { scheduleDeadline } from "./timer.ts";
20
+ import {
21
+ emptyTicketPollResult,
22
+ formatCancelPreview,
23
+ formatLiveTicketPoll,
24
+ missingTicketPollResult,
25
+ rosterTicketPollResult,
26
+ } from "./ticket-format.ts";
27
27
  import { aggregateTaskResults, emptyUsage } from "./usage.ts";
28
28
  import { recordCall } from "./telemetry.ts";
29
29
  import type {
@@ -115,6 +115,39 @@ export function sweepTickets(): void {
115
115
  }
116
116
  }
117
117
 
118
+ /** Options for `settleTicket`. */
119
+ export interface SettleTicketOptions {
120
+ status: "done" | "failed" | "cancelled";
121
+ /** Recorded on the ticket (unexpected worker failure); omitted leaves any
122
+ * existing error untouched. */
123
+ error?: string;
124
+ }
125
+
126
+ /**
127
+ * Transition an active ticket to a terminal state. Single owner of the
128
+ * terminal transition — status, completion timestamp, error, and the
129
+ * busy-session index — so the normal-completion, unexpected-error, and
130
+ * shutdown paths cannot drift apart. Result delivery is deliberately outside
131
+ * this transition: a host `sendMessage()` failure must not undo or re-enter
132
+ * worker settlement. Settling is idempotent: `completedAt` is the settle
133
+ * marker, and a second settle attempt is a loud no-op.
134
+ */
135
+ export function settleTicket(
136
+ ticket: AsyncTicket,
137
+ opts: SettleTicketOptions,
138
+ ): void {
139
+ if (ticket.completedAt) {
140
+ console.error(
141
+ `[delegate] ticket '${ticket.id}' already settled as '${ticket.status}'; ignoring settle as '${opts.status}'`,
142
+ );
143
+ return;
144
+ }
145
+ ticket.status = opts.status;
146
+ if (opts.error !== undefined) ticket.error = opts.error;
147
+ ticket.completedAt = Date.now();
148
+ syncTicketBusyIndex(ticket);
149
+ }
150
+
118
151
  /**
119
152
  * Finalize an active ticket during host shutdown and resolve any blocking
120
153
  * waiters. This intentionally does not send a follow-up: the host is exiting.
@@ -122,17 +155,16 @@ export function sweepTickets(): void {
122
155
  export function cancelTicketForShutdown(ticket: AsyncTicket): void {
123
156
  if (ticket.status !== "running" && ticket.status !== "cancelling") return;
124
157
  ticket.controller.abort();
125
- ticket.status = "cancelled";
126
- ticket.completedAt = Date.now();
127
- syncTicketBusyIndex(ticket);
158
+ settleTicket(ticket, { status: "cancelled" });
128
159
  settleTicketWaiters(ticket);
129
160
  if (ticket.callRecord) {
161
+ const completedAt = ticket.completedAt ?? Date.now();
130
162
  const { totalTokens, totalCost } = aggregateTaskResults(ticket.results);
131
163
  recordCall(
132
164
  {
133
165
  ...ticket.callRecord,
134
166
  status: "cancelled",
135
- wall_ms: ticket.completedAt - (ticket.callStartedAt ?? ticket.created),
167
+ wall_ms: completedAt - (ticket.callStartedAt ?? ticket.created),
136
168
  total_tokens: totalTokens,
137
169
  total_cost: totalCost,
138
170
  },
@@ -168,9 +200,9 @@ export function isSessionBusy(sessionId: string): string | null {
168
200
  * is also "failed".
169
201
  *
170
202
  * Callers are expected to have already handled "cancelled" / "cancelling"
171
- * (set by handleCancel) before invoking this; those paths set `ticket.status`
172
- * directly and skip this function via the `if (ticket.status === "running")`
173
- * guard in execute().
203
+ * (set by handleCancel / cancelTicketForShutdown) before invoking this;
204
+ * dispatchAsync's completion path routes both through `settleTicket`, which
205
+ * only calls this for a still-"running" ticket.
174
206
  */
175
207
  export function resolveFinalTicketStatus(
176
208
  ticket: AsyncTicket,
@@ -517,21 +549,31 @@ export function deliverTicketResults(
517
549
 
518
550
  const crossLeaf = isCrossLeafTicket(ticket);
519
551
 
520
- pi.sendMessage(
521
- {
522
- customType: "async_delegate_result",
523
- content: crossLeaf ? `${CROSS_LEAF_NOTICE}\n\n${text}` : text,
524
- display: true,
525
- details: {
526
- ...formatted.details,
527
- ticketId: ticket.id,
528
- status: ticket.status,
552
+ try {
553
+ pi.sendMessage(
554
+ {
555
+ customType: "async_delegate_result",
556
+ content: crossLeaf ? `${CROSS_LEAF_NOTICE}\n\n${text}` : text,
557
+ display: true,
558
+ details: {
559
+ ...formatted.details,
560
+ ticketId: ticket.id,
561
+ status: ticket.status,
562
+ },
529
563
  },
530
- },
531
- crossLeaf
532
- ? { deliverAs: "nextTurn" }
533
- : { deliverAs: "steer", triggerTurn: true },
534
- );
564
+ crossLeaf
565
+ ? { deliverAs: "nextTurn" }
566
+ : { deliverAs: "steer", triggerTurn: true },
567
+ );
568
+ } catch (error) {
569
+ // Delivery is an observer boundary. The terminal ticket remains pollable
570
+ // even when the host cannot accept an unsolicited follow-up.
571
+ console.error(
572
+ `[delegate] failed to deliver results for ticket '${ticket.id}'; it remains pollable`,
573
+ error,
574
+ );
575
+ return "none";
576
+ }
535
577
  return crossLeaf ? "deferred" : "steer";
536
578
  }
537
579
 
@@ -545,195 +587,23 @@ export function handlePoll(
545
587
 
546
588
  // Only use top-level ticket param — per-task prompt is NOT a ticket ID
547
589
  const ticketId = params.ticket;
548
-
549
- // No ticket specified — list all
550
590
  if (!ticketId) {
551
591
  const tickets = [...ticketRegistry.values()];
552
- if (!tickets.length) {
553
- return {
554
- content: [
555
- {
556
- type: "text",
557
- text: [
558
- "No async tickets.",
559
- "",
560
- "To spawn a subagent: delegate({ tasks: [{ agent, prompt }] }).",
561
- "For the full manual and agent list, call delegate({ tasks: [] }) with no top-level `ticketAction`.",
562
- ].join("\n"),
563
- },
564
- ],
565
- details: {
566
- tasks: [],
567
- results: [],
568
- progress: [],
569
- parentModel: parentModelId,
570
- },
571
- };
572
- }
573
- const lines = tickets.map((t) => {
574
- const icon =
575
- t.status === "running" || t.status === "cancelling"
576
- ? "⏳"
577
- : t.status === "done"
578
- ? "✓"
579
- : "✗";
580
- const done = t.progress.filter((p) => p.status === "done").length;
581
- const age = fmtDuration(Date.now() - t.created);
582
- // Agent roster — compact, deduplicated (a ticket may run the same agent
583
- // several times). Helps a human tell tickets apart at a glance.
584
- const agentSet = [
585
- ...new Set(t.progress.map((p) => p.agent).filter(Boolean)),
586
- ];
587
- const agents = agentSet.length
588
- ? ` · ${agentSet.slice(0, 3).join(", ")}${agentSet.length > 3 ? ` +${agentSet.length - 3}` : ""}`
589
- : "";
590
- let line = `${icon} ${t.id}${agents} · ${done}/${t.progress.length} tasks · ${t.status} · ${age}`;
591
- // Copy-pasteable controls for running/cancelling tickets — a human can grab these
592
- // straight out of the TUI without retyping the ticket id.
593
- if (t.status === "running" || t.status === "cancelling") {
594
- line += `\n poll: delegate({ ticketAction: "poll", ticket: "${t.id}" })`;
595
- if (t.status === "running") {
596
- line += `\n cancel: delegate({ ticketAction: "cancel", ticket: "${t.id}", force: true })`;
597
- }
598
- }
599
- return line;
600
- });
601
- return {
602
- content: [{ type: "text", text: `Async tickets:\n${lines.join("\n")}` }],
603
- details: {
604
- tasks: [],
605
- results: [],
606
- progress: [],
607
- parentModel: parentModelId,
608
- },
609
- };
592
+ return tickets.length
593
+ ? rosterTicketPollResult(tickets, parentModelId)
594
+ : emptyTicketPollResult(parentModelId);
610
595
  }
611
596
 
612
- // Specific ticket
613
597
  const ticket = ticketRegistry.get(ticketId);
614
- if (!ticket) {
615
- return {
616
- content: [
617
- {
618
- type: "text",
619
- text: `Ticket '${ticketId}' not found. It may have expired or never existed.`,
620
- },
621
- ],
622
- details: {
623
- tasks: [],
624
- results: [],
625
- progress: [],
626
- parentModel: parentModelId,
627
- },
628
- };
629
- }
598
+ if (!ticket) return missingTicketPollResult(ticketId, parentModelId);
630
599
 
631
600
  if (ticket.status === "running" || ticket.status === "cancelling") {
632
- const failedCount = ticket.progress.filter(
633
- (p) => p.status === "failed",
634
- ).length;
635
- // Settled = done + failed. Used for the "all finished" guidance check.
636
- const settledCount = ticket.progress.filter(
637
- (p) => p.status === "done" || p.status === "failed",
638
- ).length;
639
- const totalCount = ticket.progress.length;
640
- const runningCount = ticket.progress.filter(
641
- (p) => p.status === "running",
642
- ).length;
643
- const pendingCount = ticket.progress.filter(
644
- (p) => p.status === "pending",
645
- ).length;
646
- const totalTools = ticket.progress.reduce((sum, p) => sum + p.toolUses, 0);
647
- const totalTokens = ticket.progress.reduce((sum, p) => sum + p.tokens, 0);
648
- const lines: string[] = [];
649
- // Index-aligned sparse array — same shape as ticket.results, so consumers
650
- // can correlate results[i] with progress[i] and tasks[i].
651
- const completedResults: (TaskResult | undefined)[] = new Array(
652
- ticket.progress.length,
653
- ).fill(undefined);
654
-
655
- for (let i = 0; i < ticket.progress.length; i++) {
656
- const p = ticket.progress[i]!;
657
- const r = ticket.results[i];
658
-
659
- if (p.status === "done" && r) {
660
- const meta = taskMetaBase(r);
661
- if (r.touchedFiles.length > 0) {
662
- const t = ticket.resolved[i]!;
663
- const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
664
- if (touched) meta.push(`touched (best-effort): ${touched}`);
665
- }
666
- lines.push(`✓ ${r.agent}${formatTaskId(r.id)} · ${meta.join(" · ")}`);
667
- if (r.output && r.output !== "(no output)") {
668
- lines.push(renderOutputForPoll(r.output));
669
- }
670
- completedResults[i] = r;
671
- } else if (p.status === "failed" && r) {
672
- const meta = taskMetaBase(r);
673
- if (r.touchedFiles.length > 0) {
674
- const t = ticket.resolved[i]!;
675
- const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
676
- if (touched) meta.push(`touched (best-effort): ${touched}`);
677
- }
678
- const errorText = r.error ?? "unknown error";
679
- lines.push(
680
- `✗ ${r.agent}${formatTaskId(r.id)} · ${errorText} · ${meta.join(" · ")}`,
681
- );
682
- if (r.sessionFile)
683
- lines.push(` session: ${shortenPath(r.sessionFile)}`);
684
- if (r.output && r.output !== "(no output)")
685
- lines.push(renderOutputForPoll(r.output));
686
- completedResults[i] = r;
687
- } else if (p.status === "running") {
688
- const parts: string[] = [formatActivityLabel(p)];
689
- if (p.toolUses > 0)
690
- parts.push(`${p.toolUses} tool${p.toolUses === 1 ? "" : "s"}`);
691
- if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
692
- const age = getActivityAge(p.lastActivityAt);
693
- if (age) parts.push(age);
694
- lines.push(`⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`);
695
- } else {
696
- lines.push(`○ ${p.agent}${formatTaskId(p.id)} · waiting…`);
697
- }
698
- }
699
-
700
- const completedForOverlap = completedResults.filter(
701
- (r): r is TaskResult => r !== undefined,
702
- );
703
- const overlapWarning = formatTouchedOverlapWarning(
704
- findTouchedOverlaps(completedForOverlap),
705
- );
706
-
707
- const headerStatus =
708
- ticket.status === "cancelling" ? "CANCELLING" : "RUNNING";
709
- const headerParts: string[] = [
710
- `Ticket ${ticket.id}: ${headerStatus}`,
711
- `${settledCount}/${totalCount} finalized`,
712
- ];
713
- if (runningCount > 0) headerParts.push(`${runningCount} active`);
714
- if (pendingCount > 0) headerParts.push(`${pendingCount} queued`);
715
- if (failedCount > 0) headerParts.push(`${failedCount} failed`);
716
- headerParts.push(`${totalTools} tool${totalTools === 1 ? "" : "s"}`);
717
- headerParts.push(`${fmtTokens(totalTokens)} tokens`);
718
- headerParts.push(`(${fmtDuration(Date.now() - ticket.created)})`);
719
- const header = headerParts.join(" · ");
720
- const guidance =
721
- ticket.status === "cancelling"
722
- ? "Cancellation requested. Active subagents are aborting and returning partial results. Wait without timeoutMs for final status; do not repeatedly poll."
723
- : settledCount === totalCount
724
- ? ""
725
- : "If you need the final result in this turn, call wait once with timeoutMs omitted. Otherwise stop calling ticket controls and let the final result auto-deliver after this turn; repeated polling will not speed it up.";
726
-
601
+ const snapshot = formatLiveTicketPoll(ticket);
727
602
  return {
728
- content: [
729
- {
730
- type: "text",
731
- text: `${header}\n${lines.join("\n")}${guidance ? `\n\n${guidance}` : ""}${overlapWarning ? `\n\n${overlapWarning}` : ""}`,
732
- },
733
- ],
603
+ content: [{ type: "text", text: snapshot.text }],
734
604
  details: {
735
605
  tasks: ticket.tasks,
736
- results: completedResults.map(
606
+ results: snapshot.completedResults.map(
737
607
  (r, i) => r ?? pendingResultPlaceholder(ticket.resolved[i]),
738
608
  ),
739
609
  progress: [...ticket.progress],
@@ -742,53 +612,14 @@ export function handlePoll(
742
612
  // (friction #2). The LLM-facing content still names the ticket id too.
743
613
  ticketId: ticket.id,
744
614
  status: ticket.status,
745
- overlapWarning: overlapWarning || undefined,
615
+ overlapWarning: snapshot.overlapWarning || undefined,
746
616
  },
747
617
  };
748
618
  }
749
619
 
750
- // Done / Failed / Cancelled — full results
751
620
  return formatCompletedTicket(ticket);
752
621
  }
753
622
 
754
- function buildCancelPreview(ticket: AsyncTicket): string {
755
- const finalized = ticket.progress.filter(
756
- (p) => p.status === "done" || p.status === "failed",
757
- ).length;
758
- const running = ticket.progress.filter((p) => p.status === "running").length;
759
- const pending = ticket.progress.filter((p) => p.status === "pending").length;
760
- const lines: string[] = [
761
- `Ticket ${ticket.id}: cancellation preview`,
762
- `${finalized}/${ticket.progress.length} finalized · ${running} active · ${pending} queued`,
763
- ];
764
-
765
- for (let i = 0; i < ticket.progress.length; i++) {
766
- const p = ticket.progress[i]!;
767
- if (p.status === "done") {
768
- lines.push(`✓ ${p.agent}${formatTaskId(p.id)} · completed`);
769
- } else if (p.status === "failed") {
770
- lines.push(`✗ ${p.agent}${formatTaskId(p.id)} · ${p.error ?? "failed"}`);
771
- } else if (p.status === "running") {
772
- const parts: string[] = [formatActivityLabel(p)];
773
- if (p.toolUses > 0)
774
- parts.push(`${p.toolUses} tool${p.toolUses === 1 ? "" : "s"}`);
775
- if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
776
- const age = getActivityAge(p.lastActivityAt);
777
- if (age) parts.push(age);
778
- lines.push(`⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`);
779
- } else {
780
- lines.push(`○ ${p.agent}${formatTaskId(p.id)} · waiting…`);
781
- }
782
- }
783
-
784
- lines.push(
785
- "",
786
- "WARNING: Cancelling now will abort active subagents. Files already written or shell commands already executed are NOT rolled back.",
787
- `To proceed, call delegate({ ticketAction: "cancel", ticket: "${ticket.id}", force: true }).`,
788
- );
789
- return lines.join("\n");
790
- }
791
-
792
623
  /** Preview or request cancellation of a running async ticket. */
793
624
  export function handleCancel(params: {
794
625
  ticket?: string;
@@ -826,7 +657,7 @@ export function handleCancel(params: {
826
657
  if (!params.force) {
827
658
  const details = buildWaitDetails(ticket);
828
659
  const text =
829
- buildCancelPreview(ticket) +
660
+ formatCancelPreview(ticket) +
830
661
  (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
831
662
  return {
832
663
  content: [{ type: "text", text }],
package/tools.ts CHANGED
@@ -29,6 +29,18 @@ TOOL_FACTORIES.grep = createGrepTool;
29
29
  TOOL_FACTORIES.find = createFindTool;
30
30
  TOOL_FACTORIES.ls = createLsTool;
31
31
 
32
+ /** Tools supplied by the allowlisted provider extension rather than Pi core. */
33
+ const PROVIDER_TOOLS: Readonly<Record<string, readonly string[]>> = {
34
+ "openai-codex": ["web_search"],
35
+ };
36
+
37
+ export function availableToolNames(modelProvider?: string): string[] {
38
+ return [
39
+ ...Object.keys(TOOL_FACTORIES),
40
+ ...(modelProvider ? (PROVIDER_TOOLS[modelProvider] ?? []) : []),
41
+ ];
42
+ }
43
+
32
44
  /** Expand tool-group shorthands (`*`, `ro`) into concrete tool lists.
33
45
  * Unknown names pass through unchanged for the caller to validate.
34
46
  * Returns a deduped list. */
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Path containment primitives for the subagent trust boundary.
3
+ *
4
+ * These decide whether a resolved package directory is allowed to become
5
+ * executable subagent code, so they compare **canonical** paths: a symlink
6
+ * whose target escapes an install root must not be able to launder itself
7
+ * through a lexically-innocent path.
8
+ */
9
+ import { realpathSync } from "node:fs";
10
+ import { isAbsolute, relative, resolve, sep } from "node:path";
11
+
12
+ /**
13
+ * Resolve a path through symlinks, or `undefined` if the path cannot be
14
+ * canonicalized.
15
+ *
16
+ * A path that cannot be resolved to a real filesystem target is never accepted
17
+ * as trusted: returning `undefined` lets callers fail closed rather than
18
+ * falling back to lexical resolution, which a symlink whose target escapes an
19
+ * install root could launder itself through.
20
+ */
21
+ export function canonicalPath(candidate: string): string | undefined {
22
+ try {
23
+ return realpathSync(candidate);
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ /** Whether `candidate` is `directory` itself or lies beneath it, after symlink
30
+ * resolution. Returns `false` when either path cannot be canonicalized. */
31
+ export function isPathWithinDirectory(
32
+ directory: string | undefined,
33
+ candidate: string | undefined,
34
+ ): boolean {
35
+ if (directory === undefined || candidate === undefined) return false;
36
+ const canonicalDirectory = canonicalPath(directory);
37
+ const canonicalCandidate = canonicalPath(candidate);
38
+ if (canonicalDirectory === undefined || canonicalCandidate === undefined) {
39
+ return false;
40
+ }
41
+ const relativePath = relative(canonicalDirectory, canonicalCandidate);
42
+ return (
43
+ relativePath === "" ||
44
+ (relativePath !== ".." &&
45
+ !relativePath.startsWith(`..${sep}`) &&
46
+ !isAbsolute(relativePath))
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Whether `candidate` is `directory` itself or lies beneath it, using lexical
52
+ * resolution only (no symlink resolution).
53
+ *
54
+ * Use this for containment checks on paths that are already trusted or that
55
+ * need not exist on disk (e.g. classifying load failures by root). For the
56
+ * subagent trust boundary, use `isPathWithinDirectory` instead — a symlink
57
+ * whose target escapes an install root must not launder itself through a
58
+ * lexical check.
59
+ */
60
+ export function isPathWithinDirectoryLexical(
61
+ directory: string,
62
+ candidate: string,
63
+ ): boolean {
64
+ const relativePath = relative(resolve(directory), resolve(candidate));
65
+ return (
66
+ relativePath === "" ||
67
+ (relativePath !== ".." &&
68
+ !relativePath.startsWith(`..${sep}`) &&
69
+ !isAbsolute(relativePath))
70
+ );
71
+ }
package/types.ts CHANGED
@@ -23,20 +23,26 @@ export interface AgentConfig {
23
23
  thinking?: ThinkingLevel;
24
24
  tools: string[];
25
25
  systemPrompt: string;
26
- /** Built-in profiles are immutable and cannot be shadowed by Markdown. */
26
+ /** Built-in profiles can be overridden by a same-named Markdown file. */
27
27
  builtin?: boolean;
28
28
  /** Default workspace for a built-in profile. Custom agents use shared. */
29
29
  workspace?: WorkspaceMode;
30
30
  /** Origin of the profile. `claude` denotes imported .claude/agents files. */
31
31
  scope?: "project" | "global" | "claude";
32
+ /** Whether `tools` was explicitly set in the Markdown frontmatter (vs inherited default). */
33
+ explicitTools?: boolean;
34
+ /** Whether `model` was explicitly set in the Markdown frontmatter. */
35
+ explicitModel?: boolean;
36
+ /** Whether `thinking` was explicitly set in the Markdown frontmatter. */
37
+ explicitThinking?: boolean;
38
+ /** Denylist applied to a built-in `default` override with no explicit allowlist – materialized against parentNativeTools at resolution. */
39
+ deniedTools?: string[];
32
40
  }
33
41
 
34
42
  // ── Tool parameter types — derived from the TypeBox schema ────────────────
35
43
  // `delegateArgumentsSchema` in schema.ts is the canonical provider-visible
36
- // shape. The public types add deprecated `action` aliases so existing TypeScript
37
- // callers remain source-compatible without advertising the overloaded fields to
38
- // models. The import is type-only, so the schema.ts ↔ types.ts cycle is erased
39
- // at compile time.
44
+ // shape; these types are its `Static<>` projections. The import is type-only,
45
+ // so the schema.ts types.ts cycle is erased at compile time.
40
46
 
41
47
  type CanonicalDelegateArguments = Static<typeof delegateArgumentsSchema>;
42
48
  type CanonicalTaskDef = NonNullable<
@@ -52,19 +58,9 @@ export type SessionAction = NonNullable<CanonicalTaskDef["sessionAction"]>;
52
58
  /** Filesystem mode: shared source tree or an ephemeral CoW scratch copy. */
53
59
  export type WorkspaceMode = NonNullable<CanonicalTaskDef["workspace"]>;
54
60
 
55
- export type TaskDef = CanonicalTaskDef & {
56
- /** @deprecated Use `sessionAction` instead. Runtime normalization still accepts this alias. */
57
- action?: SessionAction;
58
- };
61
+ export type TaskDef = CanonicalTaskDef;
59
62
 
60
- export type DelegateArguments = Omit<CanonicalDelegateArguments, "tasks"> & {
61
- /** @deprecated Use `ticketAction` instead. Runtime normalization still accepts this alias. */
62
- action?: TicketAction;
63
- tasks?: TaskDef[];
64
- };
65
-
66
- /** @deprecated Use `TicketAction` instead. */
67
- export type DelegateAction = TicketAction;
63
+ export type DelegateArguments = CanonicalDelegateArguments;
68
64
 
69
65
  // ── Async Ticket Types ─────────────────────────────────────────────────────
70
66