@bermudi/pi-delegate 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/schema.ts CHANGED
@@ -18,10 +18,23 @@ function StringEnum<const T extends readonly string[]>(
18
18
  });
19
19
  }
20
20
 
21
+ const TASK_ID_PATTERN = "^[A-Za-z0-9._-]{1,64}$";
22
+ const TASK_ID_RE = new RegExp(TASK_ID_PATTERN);
23
+
21
24
  export const delegateTaskSchema = Type.Object({
25
+ id: Type.Optional(
26
+ Type.String({
27
+ pattern: TASK_ID_PATTERN,
28
+ minLength: 1,
29
+ maxLength: 64,
30
+ description:
31
+ "Optional task correlation key; 1-64 chars; A-Z a-z 0-9 . _ - only; duplicate ids rejected. Omit for index.",
32
+ }),
33
+ ),
22
34
  prompt: Type.Optional(
23
35
  Type.String({
24
- description: "Task prompt; omit only for close, list, or resumeFrom.",
36
+ description:
37
+ "Self-contained task prompt; fresh context cannot see this chat. Omit only for close, list, or resumeFrom.",
25
38
  }),
26
39
  ),
27
40
  agent: Type.Optional(
@@ -37,13 +50,14 @@ export const delegateTaskSchema = Type.Object({
37
50
  ),
38
51
  systemPrompt: Type.Optional(
39
52
  Type.String({
40
- description: "Base system prompt; AgentSession adds project resources.",
53
+ description:
54
+ "Base system prompt; project resources from the task cwd are added automatically.",
41
55
  }),
42
56
  ),
43
57
  context: Type.Optional(
44
58
  StringEnum(["fresh", "with-parent-transcript"], {
45
59
  description:
46
- "fresh omits parent transcript; with-parent-transcript copies it (token-expensive).",
60
+ "fresh omits this chat; with-parent-transcript copies it (token-expensive).",
47
61
  default: "fresh",
48
62
  }),
49
63
  ),
@@ -55,7 +69,7 @@ export const delegateTaskSchema = Type.Object({
55
69
  tools: Type.Optional(
56
70
  Type.Array(Type.String(), {
57
71
  description:
58
- "`default`=parent natives; ad-hoc=*; *=read/write/edit/bash (mutating); ro=read/grep/find/ls (read-only).",
72
+ "Names/presets: *=read/write/edit/bash (mutating); ro=read/grep/find/ls (read-only). Ad-hoc defaults to *.",
59
73
  }),
60
74
  ),
61
75
  thinking: Type.Optional(
@@ -70,7 +84,7 @@ export const delegateTaskSchema = Type.Object({
70
84
  "Live pool key for multi-turn reuse; omit for one-shot tasks.",
71
85
  }),
72
86
  ),
73
- action: Type.Optional(
87
+ sessionAction: Type.Optional(
74
88
  StringEnum(["prompt", "close", "list"], {
75
89
  description:
76
90
  "Session action; close needs sessionId; list shows active pooled sessions.",
@@ -83,12 +97,20 @@ export const delegateTaskSchema = Type.Object({
83
97
  "Exact absolute .jsonl session path from retry output; never a ticket ID.",
84
98
  }),
85
99
  ),
100
+ deadlineMs: Type.Optional(
101
+ Type.Number({
102
+ description:
103
+ "Wall-clock budget (ms) from run start after queueing. Cooperative abort; side effects remain. Omit disables.",
104
+ }),
105
+ ),
86
106
  });
87
107
 
88
- // Single source of truth for registration, generated help, and the
89
- // DelegateArguments/TaskDef projections in types.ts.
108
+ // Single source of truth for registration and generated help. The exported
109
+ // argument types in types.ts project this canonical schema and add deprecated
110
+ // `action` aliases as a type-only compatibility overlay; providers never see
111
+ // those legacy fields in this schema.
90
112
  export const delegateArgumentsSchema = Type.Object({
91
- action: Type.Optional(
113
+ ticketAction: Type.Optional(
92
114
  StringEnum(["poll", "cancel", "wait"], {
93
115
  description:
94
116
  "Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
@@ -109,7 +131,7 @@ export const delegateArgumentsSchema = Type.Object({
109
131
  force: Type.Optional(
110
132
  Type.Boolean({
111
133
  description:
112
- "True cancels after preview; completed writes/commands remain.",
134
+ "With cancel: false previews active work; true confirms abort. Completed writes/commands remain.",
113
135
  default: false,
114
136
  }),
115
137
  ),
@@ -124,7 +146,7 @@ export const delegateArgumentsSchema = Type.Object({
124
146
  Type.Array(delegateTaskSchema, {
125
147
  minItems: 0,
126
148
  description:
127
- "Fields in entries; tasks run concurrently; separate dependent/shared-file work. []=help.",
149
+ "Tasks share the real filesystem and run concurrently; separate dependent/shared-file work. []=full manual.",
128
150
  }),
129
151
  ),
130
152
  });
@@ -132,6 +154,7 @@ export const delegateArgumentsSchema = Type.Object({
132
154
  /** Fields that belong to a task entry. Models sometimes place these at the
133
155
  * top level of the arguments; the shim folds them back into a single task. */
134
156
  const TASK_FIELD_NAMES = [
157
+ "id",
135
158
  "prompt",
136
159
  "agent",
137
160
  "cwd",
@@ -141,7 +164,9 @@ const TASK_FIELD_NAMES = [
141
164
  "tools",
142
165
  "thinking",
143
166
  "sessionId",
167
+ "sessionAction",
144
168
  "resumeFrom",
169
+ "deadlineMs",
145
170
  ] as const;
146
171
 
147
172
  /** Every field a task entry may carry. Anything else is a model mistake —
@@ -149,37 +174,81 @@ const TASK_FIELD_NAMES = [
149
174
  * corrective message instead of being silently ignored (observed in the
150
175
  * wild: a task-level `async: true` the caller believed had backgrounded
151
176
  * the work while the call in fact ran synchronously). */
152
- const VALID_TASK_KEYS = new Set<string>([...TASK_FIELD_NAMES, "action"]);
177
+ const VALID_TASK_KEYS = new Set<string>([...TASK_FIELD_NAMES, "sessionAction"]);
178
+
179
+ /** Top-level ticket actions the legacy `action` field may map to. */
180
+ const TICKET_ACTIONS = new Set(["poll", "cancel", "wait"]);
181
+
182
+ /** Session actions that are valid at the task level. A flat `action` at the
183
+ * top level may also fold into a wrapped task's `sessionAction`. */
184
+ const TASK_ACTIONS = new Set(["prompt", "close", "list"]);
185
+
186
+ /** Every value the legacy `action` field can carry before it is normalized to
187
+ * `ticketAction` or `sessionAction`. */
188
+ const LEGACY_ACTIONS = new Set([...TICKET_ACTIONS, ...TASK_ACTIONS]);
153
189
 
154
190
  /** Validate the three operation modes after compatibility reshaping. */
155
191
  export function validateDelegateOperation(
156
192
  params: DelegateArguments,
157
193
  ): string | undefined {
194
+ const rawParams = params as Record<string, unknown>;
158
195
  const tasks = params.tasks ?? [];
159
- const isTicketControl = params.action !== undefined;
196
+
197
+ const hasLegacyAction = typeof rawParams.action === "string";
198
+ const hasTicketAction = params.ticketAction !== undefined;
199
+
200
+ if (hasLegacyAction) {
201
+ if (!LEGACY_ACTIONS.has(rawParams.action as string)) {
202
+ return `unknown action '${rawParams.action}'; valid ticket actions are poll/cancel/wait, valid session actions are prompt/close/list.`;
203
+ }
204
+ if (hasTicketAction) {
205
+ return "ambiguous: supply only ticketAction (or only legacy action), not both.";
206
+ }
207
+ if (TASK_ACTIONS.has(rawParams.action as string) && tasks.length > 0) {
208
+ return `legacy top-level action '${rawParams.action}' cannot be combined with an explicit tasks array; move it into the task's sessionAction or remove tasks.`;
209
+ }
210
+ }
211
+
212
+ const ticketAction: string | undefined =
213
+ params.ticketAction ??
214
+ (hasLegacyAction && TICKET_ACTIONS.has(rawParams.action as string)
215
+ ? (rawParams.action as string)
216
+ : undefined);
217
+
218
+ const isTicketControl = ticketAction !== undefined;
160
219
 
161
220
  if (isTicketControl) {
162
- if (params.tasks !== undefined || params.async === true) {
163
- return "ticket control cannot include tasks or async; call it separately.";
221
+ const topLevelTaskIntentFields = [...TASK_FIELD_NAMES, "tasks"] as const;
222
+ const taskIntentFields = topLevelTaskIntentFields.filter(
223
+ (field) => rawParams[field] !== undefined,
224
+ );
225
+ if (taskIntentFields.length) {
226
+ return `ticket control cannot be combined with task-intent field(s) ${taskIntentFields
227
+ .map((field) => `'${field}'`)
228
+ .join(", ")}; call it separately.`;
229
+ }
230
+ if (params.async === true) {
231
+ return "ticket control cannot include async; call it separately.";
164
232
  }
165
- if (params.action !== "poll" && !params.ticket) {
166
- return `action '${params.action}' requires ticket.`;
233
+ if (ticketAction !== "poll" && !params.ticket) {
234
+ return `ticketAction '${ticketAction}' requires ticket.`;
167
235
  }
168
- if (params.action !== "cancel" && params.force === true) {
169
- return "force is valid only with action 'cancel'.";
236
+ if (ticketAction !== "cancel" && params.force === true) {
237
+ return "force is valid only with ticketAction 'cancel'.";
170
238
  }
171
- if (params.action !== "wait" && params.timeoutMs !== undefined) {
172
- return "timeoutMs is valid only with action 'wait'.";
239
+ if (ticketAction !== "wait" && params.timeoutMs !== undefined) {
240
+ return "timeoutMs is valid only with ticketAction 'wait'.";
173
241
  }
174
242
  return undefined;
175
243
  }
176
244
 
177
245
  if (params.ticket !== undefined) {
178
- return "ticket requires action 'poll', 'cancel', or 'wait'.";
246
+ return "ticket requires ticketAction 'poll', 'cancel', or 'wait'.";
179
247
  }
180
- if (params.force === true) return "force is valid only with action 'cancel'.";
248
+ if (params.force === true)
249
+ return "force is valid only with ticketAction 'cancel'.";
181
250
  if (params.timeoutMs !== undefined) {
182
- return "timeoutMs is valid only with action 'wait'.";
251
+ return "timeoutMs is valid only with ticketAction 'wait'.";
183
252
  }
184
253
  if (!tasks.length) {
185
254
  return params.async === true
@@ -187,10 +256,48 @@ export function validateDelegateOperation(
187
256
  : undefined; // Intentional help request.
188
257
  }
189
258
 
259
+ // Reject mixed shapes: flat task fields at the top level alongside a
260
+ // nonempty tasks array. The normalize shim only wraps flat fields when
261
+ // there is no tasks array, so a mixed call silently lets tasks win —
262
+ // a model mistake that should fail loudly.
263
+ if (tasks.length > 0) {
264
+ const flatTaskFields = [
265
+ ...new Set([...TASK_FIELD_NAMES, "sessionAction", "action"]),
266
+ ].filter((field) => rawParams[field] !== undefined);
267
+ if (flatTaskFields.length) {
268
+ return `cannot mix top-level task field(s) ${flatTaskFields
269
+ .map((field) => `'${field}'`)
270
+ .join(
271
+ ", ",
272
+ )} with an explicit tasks array; move them into a task entry or remove tasks.`;
273
+ }
274
+ }
275
+
190
276
  for (const [index, task] of tasks.entries()) {
191
- const unknownKeys = Object.keys(task).filter(
192
- (key) => !VALID_TASK_KEYS.has(key),
193
- );
277
+ const rawTask = task as Record<string, unknown>;
278
+ const hasLegacyTaskAction = typeof rawTask.action === "string";
279
+ const hasSessionAction = task.sessionAction !== undefined;
280
+
281
+ if (hasLegacyTaskAction) {
282
+ if (!TASK_ACTIONS.has(rawTask.action as string)) {
283
+ return `task ${index + 1}: unknown action '${rawTask.action}'; valid session actions are prompt/close/list.`;
284
+ }
285
+ if (hasSessionAction) {
286
+ return `task ${index + 1}: ambiguous: supply only sessionAction (or only legacy action), not both.`;
287
+ }
288
+ }
289
+
290
+ const sessionAction: string | undefined =
291
+ task.sessionAction ??
292
+ (hasLegacyTaskAction && TASK_ACTIONS.has(rawTask.action as string)
293
+ ? (rawTask.action as string)
294
+ : undefined);
295
+
296
+ const unknownKeys = Object.keys(rawTask).filter((key) => {
297
+ if (VALID_TASK_KEYS.has(key)) return false;
298
+ if (key === "action" && sessionAction !== undefined) return false;
299
+ return true;
300
+ });
194
301
  if (unknownKeys.length) {
195
302
  const asyncHint = unknownKeys.includes("async")
196
303
  ? " 'async' is a top-level flag; move it out of the task entry."
@@ -202,21 +309,35 @@ export function validateDelegateOperation(
202
309
  `Valid task fields: ${[...VALID_TASK_KEYS].join(", ")}.`
203
310
  );
204
311
  }
205
- if (task.action === "close") {
312
+ if (rawTask.id !== undefined) {
313
+ if (typeof rawTask.id !== "string" || !TASK_ID_RE.test(rawTask.id)) {
314
+ return `task ${index + 1}: id must be 1-64 characters using only A-Z, a-z, 0-9, '.', '_', or '-'.`;
315
+ }
316
+ }
317
+ if (typeof rawTask.deadlineMs === "number" && !(rawTask.deadlineMs > 0)) {
318
+ return `task ${index + 1}: deadlineMs must be a positive number of milliseconds.`;
319
+ }
320
+ if (sessionAction === "close") {
206
321
  if (!task.sessionId) {
207
- return `task ${index + 1}: action 'close' requires sessionId.`;
322
+ return `task ${index + 1}: sessionAction 'close' requires sessionId.`;
208
323
  }
209
- const extras = Object.keys(task).filter(
210
- (key) => key !== "action" && key !== "sessionId",
324
+ const extras = Object.keys(rawTask).filter(
325
+ (key) =>
326
+ key !== "sessionAction" &&
327
+ key !== "sessionId" &&
328
+ key !== "action" &&
329
+ key !== "id",
211
330
  );
212
331
  if (extras.length) {
213
- return `task ${index + 1}: action 'close' accepts only action and sessionId.`;
332
+ return `task ${index + 1}: sessionAction 'close' accepts only sessionAction and sessionId.`;
214
333
  }
215
334
  }
216
- if (task.action === "list") {
217
- const extras = Object.keys(task).filter((key) => key !== "action");
335
+ if (sessionAction === "list") {
336
+ const extras = Object.keys(rawTask).filter(
337
+ (key) => key !== "sessionAction" && key !== "action" && key !== "id",
338
+ );
218
339
  if (extras.length) {
219
- return `task ${index + 1}: action 'list' accepts only action.`;
340
+ return `task ${index + 1}: sessionAction 'list' accepts only sessionAction.`;
220
341
  }
221
342
  }
222
343
  }
@@ -224,11 +345,6 @@ export function validateDelegateOperation(
224
345
  return undefined;
225
346
  }
226
347
 
227
- /** Actions that are only valid at task level. The top-level `action` is
228
- * ticket-scoped (poll/cancel/wait), so a flat close/list/prompt belongs to
229
- * the wrapped task. */
230
- const TASK_ACTIONS = new Set(["prompt", "close", "list"]);
231
-
232
348
  function parseStringifiedArray(value: string): unknown[] | undefined {
233
349
  try {
234
350
  const parsed: unknown = JSON.parse(value);
@@ -256,9 +372,13 @@ function normalizeToolsField(value: string): unknown {
256
372
  * - task fields (`prompt`, `systemPrompt`, `tools`, ...) placed at the top
257
373
  * level instead of inside a `tasks` entry — wrapped into a single task;
258
374
  * - `tools` as a JSON string (or bare token) inside a task entry;
259
- * - `agent: ""` inside a task entry — treated as omitted (ad-hoc).
260
- * Skipped when a ticket action is in play. All other invalid input is left
261
- * for normal schema validation to reject loudly.
375
+ * - `agent: ""` inside a task entry — treated as omitted (ad-hoc);
376
+ * - legacy `action` folded into `ticketAction` (top level) or `sessionAction`
377
+ * (per task) for runtime compatibility.
378
+ * Skipped when a ticket action is in play. Conflicts between the legacy
379
+ * `action` field and its canonical replacement are left for
380
+ * `validateDelegateOperation` to report. All other invalid input is left for
381
+ * normal schema validation to reject loudly.
262
382
  *
263
383
  * Silent by design: these rewrites are lossless re-shaping, so unlike the
264
384
  * model-suffix warning in task-resolution (which fires because thinking
@@ -275,11 +395,26 @@ export function normalizeDelegateArguments(args: unknown): DelegateArguments {
275
395
  if (parsed) record.tasks = parsed;
276
396
  }
277
397
 
398
+ // Legacy top-level `action` (ticket verb) → canonical `ticketAction`.
399
+ // If both are present, leave the conflict for validateDelegateOperation.
400
+ if (
401
+ typeof record.action === "string" &&
402
+ ["poll", "cancel", "wait"].includes(record.action)
403
+ ) {
404
+ if (record.ticketAction === undefined) {
405
+ record.ticketAction = record.action;
406
+ delete record.action;
407
+ }
408
+ }
409
+
278
410
  // Flat task fields at the top level → wrap into a single task. Only fires
279
411
  // when there is no usable tasks array and no ticket action (`ticket`,
280
412
  // poll/cancel/wait) — those calls are legitimately taskless.
281
413
  const hasTasks = Array.isArray(record.tasks) && record.tasks.length > 0;
282
414
  const isTicketAction =
415
+ record.ticketAction === "poll" ||
416
+ record.ticketAction === "cancel" ||
417
+ record.ticketAction === "wait" ||
283
418
  record.action === "poll" ||
284
419
  record.action === "cancel" ||
285
420
  record.action === "wait";
@@ -291,28 +426,50 @@ export function normalizeDelegateArguments(args: unknown): DelegateArguments {
291
426
  delete record[key];
292
427
  }
293
428
  }
429
+ // Canonical `sessionAction` at the top level folds into the wrapped task.
430
+ if (typeof record.sessionAction === "string") {
431
+ if (task.sessionAction === undefined) {
432
+ task.sessionAction = record.sessionAction;
433
+ }
434
+ delete record.sessionAction;
435
+ }
436
+ // Legacy top-level session `action` folds into the wrapped task's
437
+ // `sessionAction`. A conflict with an explicit `sessionAction` is left
438
+ // for validateDelegateOperation to report.
294
439
  if (typeof record.action === "string" && TASK_ACTIONS.has(record.action)) {
295
- task.action = record.action;
440
+ if (task.sessionAction === undefined) {
441
+ task.sessionAction = record.action;
442
+ } else {
443
+ task.action = record.action;
444
+ }
296
445
  delete record.action;
297
446
  }
298
447
  if (Object.keys(task).length > 0) record.tasks = [task];
299
448
  }
300
449
 
301
450
  // Per-entry recovery: stringified (or bare-token) `tools` → real arrays,
302
- // and `agent: ""` → omitted (models emit an empty string to mean "ad-hoc";
303
- // without this it only works by truthiness accident downstream).
451
+ // `agent: ""` → omitted, and legacy `action` `sessionAction`.
304
452
  if (Array.isArray(record.tasks)) {
305
453
  record.tasks = record.tasks.map((entry: unknown) => {
306
454
  if (!entry || typeof entry !== "object") return entry;
307
455
  const e = entry as Record<string, unknown>;
308
456
  const rawTools = e.tools;
309
457
  const fixAgent = e.agent === "";
310
- if (typeof rawTools !== "string" && !fixAgent) return entry;
458
+ const needsActionNorm =
459
+ typeof e.action === "string" &&
460
+ TASK_ACTIONS.has(e.action) &&
461
+ e.sessionAction === undefined;
462
+ if (typeof rawTools !== "string" && !fixAgent && !needsActionNorm)
463
+ return entry;
311
464
  const out = { ...e };
312
465
  if (typeof rawTools === "string") {
313
466
  out.tools = normalizeToolsField(rawTools);
314
467
  }
315
468
  if (fixAgent) delete out.agent;
469
+ if (needsActionNorm) {
470
+ out.sessionAction = out.action;
471
+ delete out.action;
472
+ }
316
473
  return out;
317
474
  });
318
475
  }
package/status.ts CHANGED
@@ -13,7 +13,12 @@
13
13
  * active ticket — the moment a user is most likely to assume everything
14
14
  * is done and close the session.
15
15
  * 3. A confirm guard on the session-replacement paths pi lets extensions
16
- * cancel (`session_before_switch`, `session_before_fork`).
16
+ * cancel (`session_before_switch`, `session_before_fork`), plus a distinct
17
+ * prompt for `/tree` navigation, which re-targets results rather than
18
+ * killing them (see `guardTreeNavigation`).
19
+ * 4. A notification when a ticket completes after the session navigated away
20
+ * from its spawn leaf: delivery is downgraded to non-waking, so without
21
+ * this the human gets no signal that the ticket finished at all.
17
22
  *
18
23
  * Quit (Ctrl+C×2 / Ctrl+D / /quit) and /reload CANNOT be intercepted from an
19
24
  * extension — `session_shutdown` is advisory, not cancellable. The footer
@@ -21,7 +26,7 @@
21
26
  * there.
22
27
  */
23
28
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
24
- import { ticketRegistry } from "./tickets.ts";
29
+ import { requestTicketCancel, ticketRegistry } from "./tickets.ts";
25
30
  import type { AsyncTicket } from "./types.ts";
26
31
 
27
32
  const STATUS_KEY = "delegate";
@@ -178,6 +183,67 @@ export async function guardSessionReplacement(
178
183
  return proceed ? undefined : { cancel: true };
179
184
  }
180
185
 
186
+ /** `/tree` navigation is not a session replacement: the runtime survives and
187
+ * the subagents keep running, but the eventual result no longer belongs to
188
+ * the branch the user is on. Offer the three honest outcomes instead of the
189
+ * destructive switch/fork confirm. Dismissal keeps the user where they are —
190
+ * the conservative choice, since navigating is what creates the hazard.
191
+ *
192
+ * This guard is UX, not the correctness mechanism: navigation that never
193
+ * reaches it (dismissed dialog, headless ctx, `ctx.navigateTree` from another
194
+ * extension) is still handled at delivery time via leaf affinity. */
195
+ export async function guardTreeNavigation(
196
+ ctx: ExtensionContext,
197
+ ): Promise<{ cancel: true } | undefined> {
198
+ lastCtx = ctx;
199
+ const summary = activeTicketSummary();
200
+ if (!summary.tickets.length || !ctx.hasUI) return undefined;
201
+
202
+ const ids = summary.tickets.map((t) => t.id).join(", ");
203
+ const hold = "Navigate — hold results (poll to read them)";
204
+ const cancel = "Navigate — cancel the background subagents";
205
+ const stay = "Stay on this branch";
206
+ let choice: string | undefined;
207
+ try {
208
+ choice = await ctx.ui.select(
209
+ `${plural(summary.activeSubagents, "background subagent")} (${ids}) still running — ` +
210
+ "navigating means their results arrive on a different branch",
211
+ [hold, cancel, stay],
212
+ );
213
+ } catch {
214
+ // pi does not surface handler rejections, so a throwing dialog (stale ctx,
215
+ // TUI failure) would become an unhandled rejection. Let the navigation
216
+ // through rather than trapping the user: leaf affinity at delivery time is
217
+ // the correctness mechanism, not this prompt.
218
+ return undefined;
219
+ }
220
+
221
+ if (choice === hold) return undefined;
222
+ if (choice === cancel) {
223
+ for (const ticket of summary.tickets) requestTicketCancel(ticket);
224
+ syncDelegateStatus(ctx);
225
+ return undefined;
226
+ }
227
+ return { cancel: true };
228
+ }
229
+
230
+ /** Tell the human a ticket landed on a branch they had left. Delivery used
231
+ * `nextTurn` (no wake-up), so this notification and the pollable ticket are
232
+ * the only signals that the work finished. */
233
+ export function notifyCrossLeafDelivery(ticket: AsyncTicket): void {
234
+ if (!lastCtx) return;
235
+ try {
236
+ lastCtx.ui.notify(
237
+ `Ticket ${ticket.id} finished (${ticket.status}) on a branch you navigated away from — ` +
238
+ `results are held for your next message; read them with delegate poll.`,
239
+ "warning",
240
+ );
241
+ } catch {
242
+ lastCtx = undefined;
243
+ lastStatusText = undefined;
244
+ }
245
+ }
246
+
181
247
  /** One-line description of live work for shutdown traces. */
182
248
  export function describeActiveTickets(
183
249
  summary: ActiveTicketSummary = activeTicketSummary(),