@bermudi/pi-delegate 0.1.18 → 0.1.20

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.
@@ -54,6 +54,7 @@ export interface RenderState {
54
54
 
55
55
  /** Shared inputs for the partial and final render branches. */
56
56
  export interface BranchCtx {
57
+ pauseState?: import("./pause.ts").PauseState;
57
58
  progress: TaskProgress[];
58
59
  taskResults: (TaskResult | { error: string })[];
59
60
  total: number;
@@ -98,7 +99,11 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
98
99
  // Keep the live and final summaries in the same order so the header remains
99
100
  // easy to scan as a partial result resolves into its final form.
100
101
  const headerParts: string[] = [];
101
- if (running > 0) headerParts.push(`${running} running`);
102
+ const paused = progress.filter(
103
+ (p) => p.status === "running" && p.paused,
104
+ ).length;
105
+ if (running > paused) headerParts.push(`${running - paused} running`);
106
+ if (paused > 0) headerParts.push(`${paused} paused`);
102
107
  headerParts.push(`${finished}/${total} finished`);
103
108
  if (failed > 0) headerParts.push(`${failed} failed`);
104
109
  headerParts.push(
@@ -111,7 +116,9 @@ export function renderPartialBranch(ctx: BranchCtx, h: RenderHelpers): void {
111
116
  const stateLabel =
112
117
  ctx.ticketStatus === "cancelling"
113
118
  ? `${theme.fg("error", "■ cancelling")} · `
114
- : "";
119
+ : ctx.pauseState && ctx.pauseState !== "running"
120
+ ? `${theme.fg("warning", `Ⅱ ${ctx.pauseState}`)} · `
121
+ : "";
115
122
  const expandHint = toolExpandHint();
116
123
  const detailHint =
117
124
  !expanded && running > 0 && expandHint
@@ -362,7 +369,11 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
362
369
  if (ticketId && ticketIsLive) {
363
370
  // Background ticket — frame it as in-progress, not a finished result.
364
371
  const ticketParts = [`${finalized}/${total} finished`];
365
- if (running > 0) ticketParts.push(`${running} active`);
372
+ const paused = progress.filter(
373
+ (p) => p.status === "running" && p.paused,
374
+ ).length;
375
+ if (running > paused) ticketParts.push(`${running - paused} active`);
376
+ if (paused > 0) ticketParts.push(`${paused} paused`);
366
377
  if (pending > 0) ticketParts.push(`${pending} queued`);
367
378
  if (failed > 0) ticketParts.push(`${failed} failed`);
368
379
  if (cancelled > 0) ticketParts.push(`${cancelled} cancelled`);
@@ -373,7 +384,9 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
373
384
  const stateLabel =
374
385
  ticketStatus === "cancelling"
375
386
  ? ` ${theme.fg("error", "cancelling")}`
376
- : "";
387
+ : ctx.pauseState && ctx.pauseState !== "running"
388
+ ? ` ${theme.fg("warning", ctx.pauseState)}`
389
+ : "";
377
390
  lines.push(
378
391
  `${glyph}${stateLabel} ${theme.fg("muted", `${ticketLabel}${ticketParts.join(" · ")}`)}${detailHint}`,
379
392
  "",
@@ -523,7 +536,8 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
523
536
  const integration = r.integration;
524
537
  const tone =
525
538
  integration.status === "applied_unverified" ||
526
- integration.status === "no_changes"
539
+ integration.status === "no_changes" ||
540
+ integration.status === "retained"
527
541
  ? "warning"
528
542
  : "error";
529
543
  lines.push(
package/render-result.ts CHANGED
@@ -202,6 +202,7 @@ export function renderDelegateResult(
202
202
  lines,
203
203
  ticketId,
204
204
  ticketStatus,
205
+ pauseState: details.pauseState,
205
206
  elapsedMs: details.elapsedMs,
206
207
  };
207
208
 
@@ -213,6 +214,12 @@ export function renderDelegateResult(
213
214
  lines.push(truncLine(theme.fg("warning", `⚠ ${warning}`), w), "");
214
215
  }
215
216
  }
217
+ if (details?.serializedNotice) {
218
+ const notice = sanitizeTerminalLine(details.serializedNotice);
219
+ if (notice) {
220
+ lines.push(truncLine(theme.fg("dim", `⏳ ${notice}`), w), "");
221
+ }
222
+ }
216
223
  if (details?.overlapWarning) {
217
224
  const warning = sanitizeTerminalLine(details.overlapWarning);
218
225
  if (warning) {
package/runner.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  AgentSession,
4
4
  AgentSessionEvent,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { AssistantPreview } from "./assistant-preview.ts";
6
7
  import {
7
8
  getGitChangedFiles,
8
9
  extractAttributedFromActivities,
@@ -127,6 +128,10 @@ export async function runAgentSession(
127
128
  deadlineAt?: number,
128
129
  /** Dispatch-scoped delegate.json snapshot for the stall timeout. */
129
130
  delegateConfig?: import("./config.ts").DelegateConfig,
131
+ pause?: {
132
+ controller: import("./pause.ts").PauseController;
133
+ index: number;
134
+ },
130
135
  ): Promise<{
131
136
  output: string;
132
137
  error?: string;
@@ -176,6 +181,8 @@ export async function runAgentSession(
176
181
  let recoveryBarrier: QuiescenceBarrier | undefined;
177
182
  let abandonmentSafety: Promise<void> | undefined;
178
183
  let unsubscribeFull: (() => void) | undefined;
184
+ let unsubscribePause: (() => void) | undefined;
185
+ let pausedAtTurnBoundary = false;
179
186
  let unsubscribeRecovery: (() => void) | undefined;
180
187
  const safeLog = (message: string, error?: unknown): void => {
181
188
  try {
@@ -187,11 +194,18 @@ export async function runAgentSession(
187
194
  const removeFullListener = (): void => {
188
195
  const remove = unsubscribeFull;
189
196
  unsubscribeFull = undefined;
197
+ const removePause = unsubscribePause;
198
+ unsubscribePause = undefined;
190
199
  try {
191
200
  remove?.();
192
201
  } catch (error) {
193
202
  safeLog("[delegate] full AgentSession listener cleanup failed", error);
194
203
  }
204
+ try {
205
+ removePause?.();
206
+ } catch (error) {
207
+ safeLog("[delegate] turn-pause listener cleanup failed", error);
208
+ }
195
209
  };
196
210
  const removeRecoveryListener = (): void => {
197
211
  const remove = unsubscribeRecovery;
@@ -206,6 +220,7 @@ export async function runAgentSession(
206
220
  }
207
221
  };
208
222
  const activities: ToolActivity[] = [];
223
+ const assistantPreview = new AssistantPreview();
209
224
  const pendingById = new Map<string, ToolActivity>();
210
225
  let notifyCancellationRequested!: () => void;
211
226
  const cancellationRequested = new Promise<void>((resolve) => {
@@ -377,6 +392,8 @@ export async function runAgentSession(
377
392
  try {
378
393
  const cancellationSource = currentCancellationSource();
379
394
  onProgress({
395
+ assistantPreview: assistantPreview.text,
396
+ activity: phase,
380
397
  tokens: delta,
381
398
  toolUses,
382
399
  durationMs: Date.now() - startTime,
@@ -415,7 +432,13 @@ export async function runAgentSession(
415
432
  };
416
433
  const armStallWatchdog = (graceMs = 0) => {
417
434
  clearStallWatchdog();
418
- if (!stallTimeoutMs || stalled || signal?.aborted || deadlineExceeded)
435
+ if (
436
+ !stallTimeoutMs ||
437
+ stalled ||
438
+ signal?.aborted ||
439
+ deadlineExceeded ||
440
+ pausedAtTurnBoundary
441
+ )
419
442
  return;
420
443
 
421
444
  const grace = Number.isFinite(graceMs) && graceMs > 0 ? graceMs : 0;
@@ -625,6 +648,35 @@ export async function runAgentSession(
625
648
  // result, isError) — AgentSession forwards the underlying agent events
626
649
  // verbatim. Retry and compaction events are handled below; queue/bookkeeping
627
650
  // events and thinking changes are intentionally ignored.
651
+ // AgentSession subscribers are notifications, not an awaited barrier.
652
+ // Pi core explicitly awaits Agent subscribers before proceeding from
653
+ // turn_start to the next model request, including retries/continuations.
654
+ // Never gate turn_end: a final turn should be allowed to finish the task.
655
+ if (pause) {
656
+ unsubscribePause = session.agent.subscribe(async (event, turnSignal) => {
657
+ if (event.type !== "turn_start" || pause.controller.state === "running")
658
+ return;
659
+ pausedAtTurnBoundary = true;
660
+ clearStallWatchdog();
661
+ try {
662
+ await pause.controller.checkpoint(
663
+ pause.index,
664
+ turnSignal && signal
665
+ ? AbortSignal.any([turnSignal, signal])
666
+ : (turnSignal ?? signal),
667
+ );
668
+ if (turnSignal?.aborted || signal?.aborted) {
669
+ throw new Error(
670
+ "Paused turn cancelled before the next model request",
671
+ );
672
+ }
673
+ } finally {
674
+ pausedAtTurnBoundary = false;
675
+ noteActivity("resuming at turn boundary");
676
+ }
677
+ });
678
+ }
679
+
628
680
  unsubscribeFull = session.subscribe((event: AgentSessionEvent) => {
629
681
  barrier.noteEvent();
630
682
  recoveryBarrier?.noteEvent();
@@ -678,12 +730,14 @@ export async function runAgentSession(
678
730
  case "message_start":
679
731
  case "message_update":
680
732
  if (event.message?.role === "assistant") {
733
+ assistantPreview.update(extractOutput([event.message]));
681
734
  rememberPartialAssistant(event.message);
682
735
  }
683
736
  noteActivity("streaming model output");
684
737
  break;
685
738
  case "message_end":
686
739
  if (event.message.role === "assistant") {
740
+ assistantPreview.finish(extractOutput([event.message]));
687
741
  assistantMessagesForAttempt.push(event.message);
688
742
  // Keep a text-bearing partial if the host follows a provider
689
743
  // exception with an empty synthetic failure message. A real
package/runtime.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { SessionPool, defaultSessionPool } from "./pool.ts";
2
+ import { TicketRegistry, ticketRegistry } from "./tickets.ts";
3
+
4
+ /**
5
+ * Injectable runtime context for one delegate extension lifetime.
6
+ *
7
+ * A runtime bundles a {@link SessionPool} and a {@link TicketRegistry} so
8
+ * tests and nested callers can create fully isolated dispatch/lifecycle
9
+ * environments without touching the module-level default pool or ticket
10
+ * registry. Production Pi uses the single default runtime returned by
11
+ * {@link getDefaultDelegateRuntime}; the public barrel still exposes the
12
+ * familiar checkout/commit/ticket wrapper functions for compatibility.
13
+ */
14
+ export interface DelegateRuntime {
15
+ pool: SessionPool;
16
+ tickets: TicketRegistry;
17
+ }
18
+
19
+ /** Create a fresh, isolated runtime with its own pool and ticket registry. */
20
+ export function createDelegateRuntime(): DelegateRuntime {
21
+ return {
22
+ pool: new SessionPool(),
23
+ tickets: new TicketRegistry(),
24
+ };
25
+ }
26
+
27
+ const defaultRuntime: DelegateRuntime = {
28
+ pool: defaultSessionPool,
29
+ tickets: ticketRegistry,
30
+ };
31
+
32
+ /** The runtime used by the one-argument Pi extension entry point and the
33
+ * default-runtime compatibility wrappers exported from the barrel. */
34
+ export function getDefaultDelegateRuntime(): DelegateRuntime {
35
+ return defaultRuntime;
36
+ }
package/schema.ts CHANGED
@@ -42,7 +42,7 @@ export const delegateTaskSchema = Type.Object({
42
42
  agent: Type.Optional(
43
43
  Type.String({
44
44
  description:
45
- "default mirrors the parent's tools; scout/coder/reviewer specialists. Ad-hoc tasks get * tools even when the parent is narrower.",
45
+ "Use built-ins first: default=general; scout/coder/reviewer specialize. Omit only for inline; it gets * even if parent is narrower.",
46
46
  }),
47
47
  ),
48
48
  cwd: Type.Optional(
@@ -65,19 +65,20 @@ export const delegateTaskSchema = Type.Object({
65
65
  ),
66
66
  model: Type.Optional(
67
67
  Type.String({
68
- description: "Model override; omit to inherit parent.",
68
+ description:
69
+ "Override only if requested or required; else keep its default.",
69
70
  }),
70
71
  ),
71
72
  tools: Type.Optional(
72
73
  Type.Array(Type.String(), {
73
74
  description:
74
- "Names/presets: *=read/write/edit/bash (mutating); ro=read/grep/find/ls (read-only). Ad-hoc defaults to *.",
75
+ "Override only if requested or required. *=read/write/edit/bash; ro=read/grep/find/ls (read-only).",
75
76
  }),
76
77
  ),
77
78
  thinking: Type.Optional(
78
79
  StringEnum(VALID_THINKING_LEVELS, {
79
80
  description:
80
- "off/minimal/low/medium/high/xhigh/max. Omit for agents it overrides delegate.json tiers; default inherits.",
81
+ "off/minimal/low/medium/high/xhigh/max. Omit by default: overrides the agent's configured thinking budget.",
81
82
  }),
82
83
  ),
83
84
  sessionId: Type.Optional(
@@ -101,7 +102,7 @@ export const delegateTaskSchema = Type.Object({
101
102
  workspace: Type.Optional(
102
103
  StringEnum(["shared", "scratch", "isolated"], {
103
104
  description:
104
- "shared edits source; scratch discards; isolated orders Git worktree proposals; none confine access.",
105
+ "shared/scratch/isolated. Override if requested or required. scratch discards; isolated reconciles Git; not a security boundary.",
105
106
  }),
106
107
  ),
107
108
  });
@@ -112,9 +113,9 @@ export const delegateTaskSchema = Type.Object({
112
113
  export const delegateArgumentsSchema = Type.Object(
113
114
  {
114
115
  ticketAction: Type.Optional(
115
- StringEnum(["poll", "cancel", "wait"], {
116
+ StringEnum(["poll", "cancel", "wait", "pause", "resume"], {
116
117
  description:
117
- "Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
118
+ "poll=snapshot; wait=await results; pause=stop between turns; resume=continue; cancel=abort. Prefer wait over polling.",
118
119
  }),
119
120
  ),
120
121
  sessionAction: Type.Optional(
@@ -159,7 +160,7 @@ export const delegateArgumentsSchema = Type.Object(
159
160
  Type.Array(delegateTaskSchema, {
160
161
  minItems: 0,
161
162
  description:
162
- "Tasks run concurrently; shared workspaces share files. scratch uses a disposable CoW copy. []=full manual.",
163
+ "Use built-in defaults first. Tasks run concurrently; overlapping shared writers run in task order. []=manual.",
163
164
  }),
164
165
  ),
165
166
  },
@@ -212,7 +213,7 @@ export function validateDelegateOperation(
212
213
  const rawParams = params as Record<string, unknown>;
213
214
  if ("action" in rawParams) {
214
215
  return (
215
- "unsupported field 'action'; use 'ticketAction' for poll/cancel/wait " +
216
+ "unsupported field 'action'; use 'ticketAction' for poll/cancel/wait/pause/resume " +
216
217
  "or 'sessionAction' for close/list."
217
218
  );
218
219
  }
@@ -286,9 +287,21 @@ function validateTicketMode(params: DelegateArguments): string | undefined {
286
287
  [...TASK_FIELD_NAMES, "tasks", "sessionAction"] as const
287
288
  ).filter((field) => rawParams[field] !== undefined);
288
289
  if (incompatibleFields.length) {
289
- return `ticket control cannot be combined with field(s) ${incompatibleFields
290
+ const base = `ticket control cannot be combined with field(s) ${incompatibleFields
290
291
  .map((field) => `'${field}'`)
291
292
  .join(", ")}; call it separately.`;
293
+ // Kitchen-sink callers attach default-shaped ticket control to a real
294
+ // dispatch and then repeat the identical rejected call (observed in the
295
+ // wild: glm-5.3 sent `ticketAction:"wait"` + tasks + empty ticket six
296
+ // times in a row). With tasks present and no ticket id, address the
297
+ // likely intent — dispatch already blocks; async creates the tickets
298
+ // that ticketAction manages — instead of restating the field rule.
299
+ const wantsDispatch =
300
+ Array.isArray(params.tasks) && params.tasks.length > 0 && !params.ticket;
301
+ if (wantsDispatch) {
302
+ return `${base} Dispatched tasks run to completion before returning — omit ticketAction entirely; only async:true produces a ticket to wait on.`;
303
+ }
304
+ return base;
292
305
  }
293
306
  if (params.async === true) {
294
307
  return "ticket control cannot include async; call it separately.";
@@ -314,7 +327,7 @@ function validateDispatchOrHelpMode(
314
327
  const tasks = params.tasks ?? [];
315
328
 
316
329
  if (params.ticket !== undefined) {
317
- return "ticket requires ticketAction 'poll', 'cancel', or 'wait'.";
330
+ return "ticket requires ticketAction 'poll', 'cancel', 'wait', 'pause', or 'resume'.";
318
331
  }
319
332
  if (params.force === true)
320
333
  return "force is valid only with ticketAction 'cancel'.";
@@ -327,10 +340,6 @@ function validateDispatchOrHelpMode(
327
340
  : undefined; // Intentional help request.
328
341
  }
329
342
 
330
- if (params.async && tasks.some((task) => task.workspace === "isolated")) {
331
- return 'workspace "isolated" is synchronous; remove async.';
332
- }
333
-
334
343
  // Reject mixed shapes: flat task fields at the top level alongside a
335
344
  // nonempty tasks array. The normalize shim only wraps flat fields when
336
345
  // there is no tasks array, so a mixed call silently lets tasks win —
@@ -406,12 +415,14 @@ function normalizeToolsField(value: string): unknown {
406
415
 
407
416
  /** True when `record` carries a top-level ticket-control intent that makes a
408
417
  * flat task-field wrap illegitimate: an explicit `ticketAction`, or a bare
409
- * `ticket` id (which only makes sense with poll/cancel/wait). */
418
+ * `ticket` id (which only makes sense with ticket controls). */
410
419
  function hasTicketControlIntent(record: Record<string, unknown>): boolean {
411
420
  return (
412
421
  record.ticketAction === "poll" ||
413
422
  record.ticketAction === "cancel" ||
414
423
  record.ticketAction === "wait" ||
424
+ record.ticketAction === "pause" ||
425
+ record.ticketAction === "resume" ||
415
426
  record.ticket !== undefined
416
427
  );
417
428
  }
@@ -450,7 +461,7 @@ function wrapFlatTaskFields(record: Record<string, unknown>): void {
450
461
  }
451
462
 
452
463
  /** Per-entry recovery for one task: stringified (or bare-token) `tools` → a
453
- * real array, and `agent: ""` → omitted (ad-hoc). Other malformed input is
464
+ * real array, and `agent: ""` → omitted (inline). Other malformed input is
454
465
  * left for schema validation to reject loudly. */
455
466
  function normalizeTaskEntry(entry: unknown): unknown {
456
467
  if (!entry || typeof entry !== "object") return entry;
@@ -476,7 +487,7 @@ function normalizeTaskEntry(entry: unknown): unknown {
476
487
  * unless ticket- or session-control intent makes the call legitimately
477
488
  * taskless (see `hasTicketControlIntent` / `hasSessionControlIntent`);
478
489
  * - `tools` as a JSON string (or bare token) inside a task entry;
479
- * - `agent: ""` inside a task entry — treated as omitted (ad-hoc);
490
+ * - `agent: ""` inside a task entry — treated as omitted (inline);
480
491
  * All other invalid input is left for normal schema validation to reject
481
492
  * loudly.
482
493
  *
package/status.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  * there.
27
27
  */
28
28
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
29
- import { requestTicketCancel, ticketRegistry } from "./tickets.ts";
29
+ import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
30
30
  import type { AsyncTicket } from "./types.ts";
31
31
 
32
32
  const STATUS_KEY = "delegate";
@@ -39,10 +39,12 @@ export interface ActiveTicketSummary {
39
39
  }
40
40
 
41
41
  /** Snapshot the live background work from the ticket registry. */
42
- export function activeTicketSummary(): ActiveTicketSummary {
42
+ export function activeTicketSummary(
43
+ runtime: DelegateRuntime = getDefaultDelegateRuntime(),
44
+ ): ActiveTicketSummary {
43
45
  const tickets: AsyncTicket[] = [];
44
46
  let activeSubagents = 0;
45
- for (const ticket of ticketRegistry.values()) {
47
+ for (const ticket of runtime.tickets.values()) {
46
48
  if (ticket.status !== "running" && ticket.status !== "cancelling") continue;
47
49
  tickets.push(ticket);
48
50
  activeSubagents += ticket.progress.filter(
@@ -60,8 +62,22 @@ function plural(n: number, noun: string): string {
60
62
  export function buildStatusText(
61
63
  summary: ActiveTicketSummary,
62
64
  ): string | undefined {
65
+ const text = buildStatusSummary(summary);
66
+ return text === undefined ? undefined : `${text} · /subagents`;
67
+ }
68
+
69
+ function buildStatusSummary(summary: ActiveTicketSummary): string | undefined {
63
70
  const { tickets, activeSubagents } = summary;
64
71
  if (tickets.length === 0) return undefined;
72
+ const held = tickets.filter(
73
+ (ticket) =>
74
+ ticket.status === "running" &&
75
+ ticket.pause &&
76
+ ticket.pause.state !== "running",
77
+ );
78
+ if (held.length) {
79
+ return `Ⅱ ${held.map((ticket) => `${ticket.id} ${ticket.pause!.state}`).join(" · ")}${tickets.length > held.length ? ` · ${tickets.length - held.length} other ticket(s)` : ""}`;
80
+ }
65
81
  // Wind-down window: tasks have settled but the ticket has not flipped to a
66
82
  // terminal status yet. "settling" is more honest than "0 subagents".
67
83
  if (activeSubagents === 0) {
@@ -88,10 +104,13 @@ const settledWarnedTicketIds = new Set<string>();
88
104
  * Called on every ticket lifecycle mutation (create, progress, complete,
89
105
  * cancel); event-driven only — no timers, so the text never goes stale
90
106
  * (counts are the only content). */
91
- export function syncDelegateStatus(ctx?: ExtensionContext): void {
107
+ export function syncDelegateStatus(
108
+ ctx?: ExtensionContext,
109
+ runtime?: DelegateRuntime,
110
+ ): void {
92
111
  if (ctx) lastCtx = ctx;
93
112
 
94
- const summary = activeTicketSummary();
113
+ const summary = activeTicketSummary(runtime);
95
114
  const text = buildStatusText(summary);
96
115
 
97
116
  if (settledWarnedTicketIds.size) {
@@ -127,9 +146,12 @@ export function clearDelegateStatusContext(): void {
127
146
  /** Warn once per ticket at the first agent_settled with that ticket active —
128
147
  * the "looks idle but isn't" moment. The persistent footer status carries
129
148
  * the information from then on, so later settles stay quiet. */
130
- export function notifyActiveTicketsOnSettled(ctx: ExtensionContext): void {
149
+ export function notifyActiveTicketsOnSettled(
150
+ ctx: ExtensionContext,
151
+ runtime?: DelegateRuntime,
152
+ ): void {
131
153
  lastCtx = ctx;
132
- const summary = activeTicketSummary();
154
+ const summary = activeTicketSummary(runtime);
133
155
  const fresh = summary.tickets.filter(
134
156
  (t) => !settledWarnedTicketIds.has(t.id),
135
157
  );
@@ -167,9 +189,10 @@ export function notifyActiveTicketsOnSettled(ctx: ExtensionContext): void {
167
189
  export async function guardSessionReplacement(
168
190
  ctx: ExtensionContext,
169
191
  action: "switch" | "fork",
192
+ runtime?: DelegateRuntime,
170
193
  ): Promise<{ cancel: true } | undefined> {
171
194
  lastCtx = ctx;
172
- const summary = activeTicketSummary();
195
+ const summary = activeTicketSummary(runtime);
173
196
  if (!summary.tickets.length || !ctx.hasUI) return undefined;
174
197
 
175
198
  const ids = summary.tickets.map((t) => t.id).join(", ");
@@ -194,9 +217,11 @@ export async function guardSessionReplacement(
194
217
  * extension) is still handled at delivery time via leaf affinity. */
195
218
  export async function guardTreeNavigation(
196
219
  ctx: ExtensionContext,
220
+ runtime?: DelegateRuntime,
197
221
  ): Promise<{ cancel: true } | undefined> {
198
222
  lastCtx = ctx;
199
- const summary = activeTicketSummary();
223
+ const rt = runtime ?? getDefaultDelegateRuntime();
224
+ const summary = activeTicketSummary(rt);
200
225
  if (!summary.tickets.length || !ctx.hasUI) return undefined;
201
226
 
202
227
  const ids = summary.tickets.map((t) => t.id).join(", ");
@@ -220,8 +245,10 @@ export async function guardTreeNavigation(
220
245
 
221
246
  if (choice === hold) return undefined;
222
247
  if (choice === cancel) {
223
- for (const ticket of summary.tickets) requestTicketCancel(ticket);
224
- syncDelegateStatus(ctx);
248
+ for (const ticket of summary.tickets) {
249
+ rt.tickets.requestTicketCancel(ticket);
250
+ }
251
+ syncDelegateStatus(ctx, rt);
225
252
  return undefined;
226
253
  }
227
254
  return { cancel: true };
@@ -12,8 +12,7 @@ import {
12
12
  availableToolNames,
13
13
  resolveToolGroups,
14
14
  } from "./tools.ts";
15
- import { configFor } from "./pool.ts";
16
- import { isSessionBusy } from "./tickets.ts";
15
+ import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
17
16
  import {
18
17
  isResumeFromQuarantined,
19
18
  isSessionIdQuarantined,
@@ -115,6 +114,7 @@ export function validateTasks(
115
114
  tasks: DispatchableTask[],
116
115
  agents: Map<string, AgentConfig>,
117
116
  parentModelId: string | undefined,
117
+ runtime: DelegateRuntime = getDefaultDelegateRuntime(),
118
118
  ): DelegateToolResult | null {
119
119
  const unknown: string[] = [];
120
120
  for (const task of tasks) {
@@ -203,7 +203,7 @@ export function validateTasks(
203
203
  // Disallow sessionIds already claimed by a running async ticket.
204
204
  const busyConflicts: string[] = [];
205
205
  for (const sid of sessionIds) {
206
- const owner = isSessionBusy(sid);
206
+ const owner = runtime.tickets.isSessionBusy(sid);
207
207
  if (owner) busyConflicts.push(`${sid} (ticket ${owner})`);
208
208
  }
209
209
  if (busyConflicts.length) {
@@ -247,6 +247,7 @@ export function resolveTasks(
247
247
  agents: Map<string, AgentConfig>,
248
248
  parentDefaults: ParentAgentDefaults,
249
249
  dispatchConfig: DelegateConfig = getDelegateConfigSnapshot(),
250
+ runtime: DelegateRuntime = getDefaultDelegateRuntime(),
250
251
  ): ResolveTasksResult {
251
252
  // Build parent transcript lazily — only computed once if any task uses with-parent-transcript
252
253
  let parentTranscript: string | null = null;
@@ -302,10 +303,12 @@ export function resolveTasks(
302
303
  : undefined;
303
304
 
304
305
  // Build system prompt. Explicit task prompts and named agent prompts
305
- // win; ad-hoc subagents inherit the parent's base prompt when Pi exposes
306
+ // win; inline subagents inherit the parent's base prompt when Pi exposes
306
307
  // it. The assembled parent project-context section was stripped above;
307
308
  // the child ResourceLoader supplies context for this task's cwd.
308
- const pooledConfig = t.sessionId ? configFor(t.sessionId) : undefined;
309
+ const pooledConfig = t.sessionId
310
+ ? runtime.pool.configFor(t.sessionId)
311
+ : undefined;
309
312
  const isPoolHit = pooledConfig !== undefined;
310
313
  const parentNativeTools = parentDefaults.tools.filter((name) =>
311
314
  Object.hasOwn(TOOL_FACTORIES, name),
@@ -625,12 +628,12 @@ export function resolveTasks(
625
628
  // display code treats "" and absent alike (`t.prompt || …`).
626
629
  prompt: prompt ?? "",
627
630
  // Keep the built-in selector visible in progress/results. Omitted-agent
628
- // inline tasks retain the established `ad-hoc` label and config namespace
629
- // — except resumes: a continued transcript is not a fresh ad-hoc spawn,
630
- // so it carries the resumed-transcript identity instead.
631
+ // tasks use the `inline` label and config namespace — except resumes: a
632
+ // continued transcript is not a fresh inline spawn, so it carries the
633
+ // resumed-transcript identity instead.
631
634
  agentName:
632
635
  agent?.name ??
633
- (resumeFromDisplay ? `resume:${resumeFromDisplay}` : "ad-hoc"),
636
+ (resumeFromDisplay ? `resume:${resumeFromDisplay}` : "inline"),
634
637
  resumeFromDisplay,
635
638
  warnings,
636
639
  reuseIntent: {
@@ -0,0 +1,81 @@
1
+ import {
2
+ createTestSession,
3
+ type TestSession,
4
+ type TestSessionOptions,
5
+ } from "@marcfargas/pi-test-harness";
6
+ import type {
7
+ AgentSession,
8
+ AgentToolResult,
9
+ ExtensionContext,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import type { Component } from "@earendil-works/pi-tui";
12
+ import type { DelegateDetails, TaskResult } from "./types.ts";
13
+ import { resolve } from "node:path";
14
+
15
+ export const DELEGATE_EXTENSION = resolve(import.meta.dirname, "./delegate.ts");
16
+
17
+ export { type TestSession };
18
+
19
+ export function createDelegateTestSession(
20
+ options: TestSessionOptions = {},
21
+ ): Promise<TestSession> {
22
+ return createTestSession({
23
+ ...options,
24
+ extensions: [...(options.extensions ?? []), DELEGATE_EXTENSION],
25
+ });
26
+ }
27
+
28
+ export interface TestToolDefinition {
29
+ name: string;
30
+ label: string;
31
+ description: string;
32
+ promptSnippet?: string;
33
+ promptGuidelines?: string[];
34
+ parameters: unknown;
35
+ prepareArguments?: (args: unknown) => unknown;
36
+ execute(...args: unknown[]): Promise<AgentToolResult<DelegateDetails>>;
37
+ renderCall: (...args: unknown[]) => Component;
38
+ renderResult: (...args: unknown[]) => Component;
39
+ }
40
+
41
+ export function getToolDef(ts: TestSession, name: string): TestToolDefinition {
42
+ const tool = (ts.session as AgentSession).extensionRunner.getToolDefinition(
43
+ name,
44
+ );
45
+ if (!tool) throw new Error(`${name} tool not found`);
46
+ return tool as unknown as TestToolDefinition;
47
+ }
48
+
49
+ export function getDelegateTool(ts: TestSession): TestToolDefinition {
50
+ return getToolDef(ts, "delegate");
51
+ }
52
+
53
+ export function getExecContext(ts: TestSession): ExtensionContext {
54
+ return (ts.session as AgentSession).extensionRunner.createContext();
55
+ }
56
+
57
+ export function firstText(result: { content: readonly unknown[] }): string {
58
+ const content = result.content[0];
59
+ if (
60
+ !content ||
61
+ typeof content !== "object" ||
62
+ !("type" in content) ||
63
+ content.type !== "text" ||
64
+ !("text" in content) ||
65
+ typeof content.text !== "string"
66
+ ) {
67
+ throw new Error("Expected first tool result content item to be text");
68
+ }
69
+ return content.text;
70
+ }
71
+
72
+ export function taskResultAt(
73
+ results: readonly (TaskResult | { error: string })[],
74
+ index: number,
75
+ ): TaskResult {
76
+ const result = results[index];
77
+ if (!result || !("agent" in result)) {
78
+ throw new Error(`Expected task result at index ${index}`);
79
+ }
80
+ return result;
81
+ }