@bermudi/pi-delegate 0.1.0

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 ADDED
@@ -0,0 +1,795 @@
1
+ import type {
2
+ AgentToolResult,
3
+ AgentToolUpdateCallback,
4
+ } from "@earendil-works/pi-agent-core";
5
+ import type {
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { ASYNC_TICKET_TTL_MS } from "./constants.ts";
10
+ import {
11
+ fmtDuration,
12
+ fmtTokens,
13
+ formatCompletedTask,
14
+ shortenPath,
15
+ trunc,
16
+ getActivityAge,
17
+ formatActivityLabel,
18
+ taskMetaBase,
19
+ relativeTouchedSummary,
20
+ } from "./format.ts";
21
+ import { renderOutputForPoll } from "./spill.ts";
22
+ import { scheduleDeadline } from "./timer.ts";
23
+ import type {
24
+ AsyncTicket,
25
+ DelegateDetails,
26
+ TaskResult,
27
+ TicketWaiter,
28
+ } from "./types.ts";
29
+
30
+ const busyTicketIdsBySession = new Map<string, Set<string>>();
31
+ const busySessionsByTicket = new Map<string, Set<string>>();
32
+
33
+ function sessionIdsFor(ticket: AsyncTicket): string[] {
34
+ return ticket.resolved
35
+ .map((t) => t.sessionId)
36
+ .filter((s): s is string => typeof s === "string" && s.length > 0);
37
+ }
38
+
39
+ function removeTicketBusySessions(ticketId: string): void {
40
+ const sessions = busySessionsByTicket.get(ticketId);
41
+ if (!sessions) return;
42
+ for (const sessionId of sessions) {
43
+ const ticketIds = busyTicketIdsBySession.get(sessionId);
44
+ ticketIds?.delete(ticketId);
45
+ if (ticketIds?.size === 0) busyTicketIdsBySession.delete(sessionId);
46
+ }
47
+ busySessionsByTicket.delete(ticketId);
48
+ }
49
+
50
+ /** Add/remove a ticket's session IDs from the O(1) busy index. */
51
+ export function syncTicketBusyIndex(ticket: AsyncTicket): void {
52
+ removeTicketBusySessions(ticket.id);
53
+ if (ticket.status !== "running" && ticket.status !== "cancelling") return;
54
+ const sids = new Set<string>();
55
+ for (const sid of sessionIdsFor(ticket)) {
56
+ const ticketIds = busyTicketIdsBySession.get(sid) ?? new Set<string>();
57
+ ticketIds.add(ticket.id);
58
+ busyTicketIdsBySession.set(sid, ticketIds);
59
+ sids.add(sid);
60
+ }
61
+ if (sids.size) busySessionsByTicket.set(ticket.id, sids);
62
+ }
63
+
64
+ class TicketRegistry extends Map<string, AsyncTicket> {
65
+ set(key: string, value: AsyncTicket): this {
66
+ super.set(key, value);
67
+ syncTicketBusyIndex(value);
68
+ return this;
69
+ }
70
+
71
+ delete(key: string): boolean {
72
+ const ok = super.delete(key);
73
+ if (ok) removeTicketBusySessions(key);
74
+ return ok;
75
+ }
76
+
77
+ clear(): void {
78
+ super.clear();
79
+ busyTicketIdsBySession.clear();
80
+ busySessionsByTicket.clear();
81
+ }
82
+ }
83
+
84
+ export const ticketRegistry = new TicketRegistry();
85
+
86
+ /** Generate a short human-copyable identifier for an async ticket. */
87
+ export function generateTicketId(): string {
88
+ // 8-char alphanumeric, no lookalikes
89
+ return Math.random().toString(36).slice(2, 10);
90
+ }
91
+
92
+ /** Remove completed tickets after their retention TTL. Running tickets have no
93
+ * wall-clock deadline: per-task stall detection owns failed-agent handling. */
94
+ export function sweepTickets(): void {
95
+ const now = Date.now();
96
+ for (const [id, ticket] of ticketRegistry) {
97
+ // TTL cleanup for completed/failed/cancelled
98
+ if (
99
+ ticket.status !== "running" &&
100
+ ticket.status !== "cancelling" &&
101
+ ticket.completedAt &&
102
+ now - ticket.completedAt > ASYNC_TICKET_TTL_MS
103
+ ) {
104
+ ticketRegistry.delete(id);
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Finalize an active ticket during host shutdown and resolve any blocking
111
+ * waiters. This intentionally does not send a follow-up: the host is exiting.
112
+ */
113
+ export function cancelTicketForShutdown(ticket: AsyncTicket): void {
114
+ if (ticket.status !== "running" && ticket.status !== "cancelling") return;
115
+ ticket.controller.abort();
116
+ ticket.status = "cancelled";
117
+ ticket.completedAt = Date.now();
118
+ syncTicketBusyIndex(ticket);
119
+ settleTicketWaiters(ticket);
120
+ }
121
+
122
+ /** Check if any running async ticket holds a given sessionId.
123
+ * Backed by an O(1) map updated when tickets start/complete. */
124
+ export function isSessionBusy(sessionId: string): string | null {
125
+ return busyTicketIdsBySession.get(sessionId)?.values().next().value ?? null;
126
+ }
127
+
128
+ /**
129
+ * Determine the final status for a ticket whose task batch has settled.
130
+ *
131
+ * A ticket is "done" only when every task settled successfully. A
132
+ * partially-settled ticket (e.g. aborted mid-flight, leaving some progress
133
+ * rows still "running"/"pending") is "failed" — never "done" — so incomplete
134
+ * work is never masked as complete. Any ticket with at least one failed task
135
+ * is also "failed".
136
+ *
137
+ * Callers are expected to have already handled "cancelled" / "cancelling"
138
+ * (set by handleCancel) before invoking this; those paths set `ticket.status`
139
+ * directly and skip this function via the `if (ticket.status === "running")`
140
+ * guard in execute().
141
+ */
142
+ export function resolveFinalTicketStatus(
143
+ ticket: AsyncTicket,
144
+ ): "done" | "failed" {
145
+ const anyFailed = ticket.results.some((r) => r && "error" in r && r.error);
146
+ const allSettled = ticket.progress.every(
147
+ (p) => p.status === "done" || p.status === "failed",
148
+ );
149
+ if (allSettled && !anyFailed) return "done";
150
+ return "failed";
151
+ }
152
+
153
+ /** Format a completed ticket for LLM consumption. Reuses sync result formatting. */
154
+ export function formatCompletedTicket(
155
+ ticket: AsyncTicket,
156
+ ): AgentToolResult<DelegateDetails> {
157
+ const parts: string[] = [];
158
+ const succeeded = ticket.results.filter(
159
+ (r) => r && !("error" in r && r.error),
160
+ ).length;
161
+ const elapsedTotal = ticket.completedAt
162
+ ? ticket.completedAt - ticket.created
163
+ : 0;
164
+ // Surface the overall ticket status so a failed/cancelled batch is not
165
+ // mistaken for success. "done" tickets keep the original header; others
166
+ // get an explicit status tag up front.
167
+ const statusTag =
168
+ ticket.status === "done" ? "" : `${ticket.status.toUpperCase()} · `;
169
+ const completionLabel =
170
+ ticket.status === "cancelled"
171
+ ? "tasks completed before abort"
172
+ : "tasks completed";
173
+ parts.push(
174
+ `${statusTag}${succeeded}/${ticket.results.length} ${completionLabel} · ${fmtDuration(elapsedTotal)} wall time\n`,
175
+ );
176
+
177
+ const pendingLabelFor = (index: number): string => {
178
+ if (ticket.status !== "cancelled") {
179
+ return "PENDING — result not available";
180
+ }
181
+ return ticket.progress[index]?.status === "pending"
182
+ ? "CANCELLED — task not started"
183
+ : "CANCELLED — task aborted mid-run, partial effects possible";
184
+ };
185
+
186
+ for (let i = 0; i < ticket.results.length; i++) {
187
+ const r = ticket.results[i];
188
+ const t = ticket.resolved[i]!;
189
+ if (!r) {
190
+ parts.push(`=== ${t.agentName}: ${trunc(t.prompt || "", 80)} ===`);
191
+ parts.push(`[${pendingLabelFor(i)}]`);
192
+ continue;
193
+ }
194
+ parts.push(...formatCompletedTask(t, r));
195
+ }
196
+
197
+ if (ticket.status === "cancelled") {
198
+ parts.push(
199
+ "",
200
+ "WARNING: Cancellation stopped the remaining work. Files already written or shell commands already executed by the subagents were NOT rolled back. Review the touched files and session files above before deciding whether to retry.",
201
+ );
202
+ }
203
+
204
+ return {
205
+ content: [{ type: "text", text: parts.join("\n\n") }],
206
+ details: {
207
+ tasks: ticket.tasks,
208
+ results: [...ticket.results].map(
209
+ (r, index) => r ?? { error: pendingLabelFor(index) },
210
+ ),
211
+ progress: [...ticket.progress],
212
+ parentModel: ticket.parentModelId,
213
+ // Thread ticketId so renderResult can show the running-ticket banner and
214
+ // the human sees which ticket they polled, even in the rich tree path.
215
+ ticketId: ticket.id,
216
+ status: ticket.status,
217
+ },
218
+ };
219
+ }
220
+
221
+ // ── Waiter helpers ─────────────────────────────────────────────────────────
222
+
223
+ function buildWaitDetails(ticket: AsyncTicket): DelegateDetails {
224
+ return {
225
+ tasks: ticket.tasks,
226
+ results: ticket.results.map(
227
+ (r) => r ?? { error: "PENDING — result not available" },
228
+ ),
229
+ progress: [...ticket.progress],
230
+ parentModel: ticket.parentModelId,
231
+ ticketId: ticket.id,
232
+ status: ticket.status,
233
+ };
234
+ }
235
+
236
+ function buildWaitRunningUpdate(
237
+ ticket: AsyncTicket,
238
+ ): AgentToolResult<DelegateDetails> {
239
+ const total = ticket.progress.length;
240
+ const done = ticket.progress.filter((p) => p.status === "done").length;
241
+ const failed = ticket.progress.filter((p) => p.status === "failed").length;
242
+ const running = ticket.progress.filter((p) => p.status === "running").length;
243
+ const pending = ticket.progress.filter((p) => p.status === "pending").length;
244
+ const finalized = done + failed;
245
+
246
+ const parts: string[] = [
247
+ `Waiting for ticket ${ticket.id}: ${ticket.status.toUpperCase()}`,
248
+ ];
249
+ parts.push(`${finalized}/${total} finalized`);
250
+ if (running > 0) parts.push(`${running} active`);
251
+ if (failed > 0) parts.push(`${failed} failed`);
252
+ if (pending > 0) parts.push(`${pending} queued`);
253
+
254
+ return {
255
+ content: [{ type: "text", text: parts.join(" · ") }],
256
+ details: buildWaitDetails(ticket),
257
+ };
258
+ }
259
+
260
+ function buildWaitTimeoutResult(
261
+ ticket: AsyncTicket,
262
+ timeoutMs: number,
263
+ ): AgentToolResult<DelegateDetails> {
264
+ 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),
272
+ };
273
+ }
274
+
275
+ function buildWaitAbortResult(
276
+ ticket: AsyncTicket,
277
+ ): AgentToolResult<DelegateDetails> {
278
+ 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),
286
+ };
287
+ }
288
+
289
+ function settleWaiter(
290
+ w: TicketWaiter,
291
+ result: AgentToolResult<DelegateDetails>,
292
+ ): void {
293
+ if (w.settled) return;
294
+ w.settled = true;
295
+ w.clearDeadline?.();
296
+ w.resolve(result);
297
+ }
298
+
299
+ function abortWaiter(w: TicketWaiter, ticket: AsyncTicket): void {
300
+ settleWaiter(w, buildWaitAbortResult(ticket));
301
+ }
302
+
303
+ function timeoutWaiter(
304
+ w: TicketWaiter,
305
+ ticket: AsyncTicket,
306
+ timeoutMs: number,
307
+ ): void {
308
+ // If the ticket became terminal (e.g. completed or cancelled just before
309
+ // this timer fired), return that snapshot. Workers may still be unwinding, so
310
+ // waiting for deliverTicketResults here could strand the caller indefinitely.
311
+ sweepTickets();
312
+ if (ticket.status !== "running" && ticket.status !== "cancelling") {
313
+ settleWaiter(w, formatCompletedTicket(ticket));
314
+ return;
315
+ }
316
+ settleWaiter(w, buildWaitTimeoutResult(ticket, timeoutMs));
317
+ }
318
+
319
+ /** Schedule a waiter timeout in clamp-safe chunks, so the host timer clamp
320
+ * cannot turn a multi-week (or longer) wait into an immediate timeout. */
321
+ function scheduleWaitTimeout(
322
+ w: TicketWaiter,
323
+ ticket: AsyncTicket,
324
+ timeoutMs: number,
325
+ ): void {
326
+ const deadline = Date.now() + timeoutMs;
327
+ w.clearDeadline = scheduleDeadline(deadline, () =>
328
+ timeoutWaiter(w, ticket, timeoutMs),
329
+ );
330
+ }
331
+
332
+ function cleanWaiters(ticket: AsyncTicket): void {
333
+ if (!ticket.waiters) return;
334
+ const active = ticket.waiters.filter((w) => !w.settled && !w.signal?.aborted);
335
+ ticket.waiters = active.length ? active : undefined;
336
+ }
337
+
338
+ /** Resolve active waiters for a terminal ticket. Returns whether any waiter was
339
+ * resolved, so callers can avoid also delivering a duplicate follow-up. */
340
+ function settleTicketWaiters(ticket: AsyncTicket): boolean {
341
+ if (!ticket.waiters?.length) return false;
342
+
343
+ const formatted = formatCompletedTicket(ticket);
344
+ let hadActive = false;
345
+ for (const w of ticket.waiters) {
346
+ if (w.settled || w.signal?.aborted) continue;
347
+ settleWaiter(w, formatted);
348
+ hadActive = true;
349
+ }
350
+ cleanWaiters(ticket);
351
+ return hadActive;
352
+ }
353
+
354
+ /** Forward current progress to all active blocking waiters. Called whenever an
355
+ * async task reports a progress or status change. */
356
+ export function notifyWaiters(ticket: AsyncTicket): void {
357
+ if (!ticket.waiters?.length) return;
358
+ // Progress frames make sense while the ticket is running or cancelling.
359
+ // Terminal tickets (done/failed/cancelled) are resolved by deliverTicketResults.
360
+ if (ticket.status !== "running" && ticket.status !== "cancelling") return;
361
+ const active: TicketWaiter[] = [];
362
+ for (const w of ticket.waiters) {
363
+ if (w.settled) continue;
364
+ if (w.signal?.aborted) {
365
+ abortWaiter(w, ticket);
366
+ continue;
367
+ }
368
+ active.push(w);
369
+ if (w.onUpdate) w.onUpdate(buildWaitRunningUpdate(ticket));
370
+ }
371
+ ticket.waiters = active.length ? active : undefined;
372
+ }
373
+
374
+ /** Push results into parent session via sendMessage when background ticket completes.
375
+ * If there are active blocking waiters, resolve them directly and suppress the
376
+ * automatic follow-up so completion is delivered exactly once. */
377
+ export function deliverTicketResults(
378
+ pi: ExtensionAPI,
379
+ ticket: AsyncTicket,
380
+ ): void {
381
+ if (!ticket.completedAt) return;
382
+
383
+ // Resolve active blocking waiters directly. Stale/aborted waiters are
384
+ // cleaned but not resolved here (their abort handlers already returned).
385
+ if (settleTicketWaiters(ticket)) return;
386
+
387
+ const formatted = formatCompletedTicket(ticket);
388
+ const text = formatted.content
389
+ .filter(
390
+ (c): c is { type: "text"; text: string } =>
391
+ c.type === "text" && typeof c.text === "string",
392
+ )
393
+ .map((c) => c.text)
394
+ .join("\n");
395
+
396
+ pi.sendMessage(
397
+ {
398
+ customType: "async_delegate_result",
399
+ content: text,
400
+ display: true,
401
+ details: {
402
+ ...formatted.details,
403
+ ticketId: ticket.id,
404
+ status: ticket.status,
405
+ },
406
+ },
407
+ {
408
+ deliverAs: "steer",
409
+ triggerTurn: true,
410
+ },
411
+ );
412
+ }
413
+
414
+ /** Return a snapshot of one async ticket or the complete ticket roster. */
415
+ export function handlePoll(
416
+ params: { ticket?: string },
417
+ ctx: ExtensionContext,
418
+ ): AgentToolResult<DelegateDetails> {
419
+ sweepTickets();
420
+ const parentModelId = ctx.model?.id;
421
+
422
+ // Only use top-level ticket param — per-task prompt is NOT a ticket ID
423
+ const ticketId = params.ticket;
424
+
425
+ // No ticket specified — list all
426
+ if (!ticketId) {
427
+ const tickets = [...ticketRegistry.values()];
428
+ if (!tickets.length) {
429
+ return {
430
+ content: [
431
+ {
432
+ type: "text",
433
+ text: [
434
+ "No async tickets.",
435
+ "",
436
+ "To spawn a subagent: delegate({ tasks: [{ agent, prompt }] }).",
437
+ "For the full manual and agent list, call delegate({ tasks: [] }) with no top-level `action`.",
438
+ ].join("\n"),
439
+ },
440
+ ],
441
+ details: {
442
+ tasks: [],
443
+ results: [],
444
+ progress: [],
445
+ parentModel: parentModelId,
446
+ },
447
+ };
448
+ }
449
+ const lines = tickets.map((t) => {
450
+ const icon =
451
+ t.status === "running" || t.status === "cancelling"
452
+ ? "⏳"
453
+ : t.status === "done"
454
+ ? "✓"
455
+ : "✗";
456
+ const done = t.progress.filter((p) => p.status === "done").length;
457
+ const age = fmtDuration(Date.now() - t.created);
458
+ // Agent roster — compact, deduplicated (a ticket may run the same agent
459
+ // several times). Helps a human tell tickets apart at a glance.
460
+ const agentSet = [
461
+ ...new Set(t.progress.map((p) => p.agent).filter(Boolean)),
462
+ ];
463
+ const agents = agentSet.length
464
+ ? ` · ${agentSet.slice(0, 3).join(", ")}${agentSet.length > 3 ? ` +${agentSet.length - 3}` : ""}`
465
+ : "";
466
+ let line = `${icon} ${t.id}${agents} · ${done}/${t.progress.length} tasks · ${t.status} · ${age}`;
467
+ // Copy-pasteable controls for running/cancelling tickets — a human can grab these
468
+ // straight out of the TUI without retyping the ticket id.
469
+ if (t.status === "running" || t.status === "cancelling") {
470
+ line += `\n poll: delegate({ action: "poll", ticket: "${t.id}" })`;
471
+ if (t.status === "running") {
472
+ line += `\n cancel: delegate({ action: "cancel", ticket: "${t.id}", force: true })`;
473
+ }
474
+ }
475
+ return line;
476
+ });
477
+ return {
478
+ content: [{ type: "text", text: `Async tickets:\n${lines.join("\n")}` }],
479
+ details: {
480
+ tasks: [],
481
+ results: [],
482
+ progress: [],
483
+ parentModel: parentModelId,
484
+ },
485
+ };
486
+ }
487
+
488
+ // Specific ticket
489
+ const ticket = ticketRegistry.get(ticketId);
490
+ if (!ticket) {
491
+ return {
492
+ content: [
493
+ {
494
+ type: "text",
495
+ text: `Ticket '${ticketId}' not found. It may have expired or never existed.`,
496
+ },
497
+ ],
498
+ details: {
499
+ tasks: [],
500
+ results: [],
501
+ progress: [],
502
+ parentModel: parentModelId,
503
+ },
504
+ };
505
+ }
506
+
507
+ if (ticket.status === "running" || ticket.status === "cancelling") {
508
+ const failedCount = ticket.progress.filter(
509
+ (p) => p.status === "failed",
510
+ ).length;
511
+ // Settled = done + failed. Used for the "all finished" guidance check.
512
+ const settledCount = ticket.progress.filter(
513
+ (p) => p.status === "done" || p.status === "failed",
514
+ ).length;
515
+ const totalCount = ticket.progress.length;
516
+ const runningCount = ticket.progress.filter(
517
+ (p) => p.status === "running",
518
+ ).length;
519
+ const pendingCount = ticket.progress.filter(
520
+ (p) => p.status === "pending",
521
+ ).length;
522
+ const totalTools = ticket.progress.reduce((sum, p) => sum + p.toolUses, 0);
523
+ const totalTokens = ticket.progress.reduce((sum, p) => sum + p.tokens, 0);
524
+ const lines: string[] = [];
525
+ // Index-aligned sparse array — same shape as ticket.results, so consumers
526
+ // can correlate results[i] with progress[i] and tasks[i].
527
+ const completedResults: (TaskResult | undefined)[] = new Array(
528
+ ticket.progress.length,
529
+ ).fill(undefined);
530
+
531
+ for (let i = 0; i < ticket.progress.length; i++) {
532
+ const p = ticket.progress[i]!;
533
+ const r = ticket.results[i];
534
+
535
+ if (p.status === "done" && r) {
536
+ const meta = taskMetaBase(r);
537
+ if (r.touchedFiles.length > 0) {
538
+ const t = ticket.resolved[i]!;
539
+ const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
540
+ if (touched) meta.push(`touched: ${touched}`);
541
+ }
542
+ lines.push(`✓ ${r.agent} · ${meta.join(" · ")}`);
543
+ if (r.output && r.output !== "(no output)") {
544
+ lines.push(renderOutputForPoll(r.output));
545
+ }
546
+ completedResults[i] = r;
547
+ } else if (p.status === "failed" && r) {
548
+ const meta = taskMetaBase(r);
549
+ if (r.touchedFiles.length > 0) {
550
+ const t = ticket.resolved[i]!;
551
+ const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
552
+ if (touched) meta.push(`touched: ${touched}`);
553
+ }
554
+ const errorText = r.error ?? "unknown error";
555
+ lines.push(`✗ ${r.agent} · ${errorText} · ${meta.join(" · ")}`);
556
+ if (r.sessionFile)
557
+ lines.push(` session: ${shortenPath(r.sessionFile)}`);
558
+ if (r.output && r.output !== "(no output)")
559
+ lines.push(renderOutputForPoll(r.output));
560
+ completedResults[i] = r;
561
+ } else if (p.status === "running") {
562
+ const parts: string[] = [formatActivityLabel(p)];
563
+ if (p.toolUses > 0)
564
+ parts.push(`${p.toolUses} tool${p.toolUses === 1 ? "" : "s"}`);
565
+ if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
566
+ const age = getActivityAge(p.lastActivityAt);
567
+ if (age) parts.push(age);
568
+ lines.push(`⏳ ${p.agent} · ${parts.join(" · ")}`);
569
+ } else {
570
+ lines.push(`○ ${p.agent} · waiting…`);
571
+ }
572
+ }
573
+
574
+ const headerStatus =
575
+ ticket.status === "cancelling" ? "CANCELLING" : "RUNNING";
576
+ const headerParts: string[] = [
577
+ `Ticket ${ticket.id}: ${headerStatus}`,
578
+ `${settledCount}/${totalCount} finalized`,
579
+ ];
580
+ if (runningCount > 0) headerParts.push(`${runningCount} active`);
581
+ if (pendingCount > 0) headerParts.push(`${pendingCount} queued`);
582
+ if (failedCount > 0) headerParts.push(`${failedCount} failed`);
583
+ headerParts.push(`${totalTools} tool${totalTools === 1 ? "" : "s"}`);
584
+ headerParts.push(`${fmtTokens(totalTokens)} tokens`);
585
+ headerParts.push(`(${fmtDuration(Date.now() - ticket.created)})`);
586
+ const header = headerParts.join(" · ");
587
+ const guidance =
588
+ ticket.status === "cancelling"
589
+ ? "Cancellation requested. Active subagents are aborting and returning partial results; poll again for the final status."
590
+ : settledCount === totalCount
591
+ ? ""
592
+ : settledCount > 0
593
+ ? "Tasks are progressing. Do other work while remaining tasks finish — results will be delivered automatically when all complete."
594
+ : "Tasks are still running. Do other work while you wait — polling again immediately will not speed them up. Results are delivered automatically when all tasks complete.";
595
+
596
+ return {
597
+ content: [
598
+ {
599
+ type: "text",
600
+ text: `${header}\n${lines.join("\n")}${guidance ? `\n\n${guidance}` : ""}`,
601
+ },
602
+ ],
603
+ details: {
604
+ tasks: ticket.tasks,
605
+ results: completedResults.map(
606
+ (r) => r ?? { error: "PENDING — result not available" },
607
+ ),
608
+ progress: [...ticket.progress],
609
+ parentModel: ticket.parentModelId,
610
+ // Thread ticketId so the rich renderResult path shows the ticket banner
611
+ // (friction #2). The LLM-facing content still names the ticket id too.
612
+ ticketId: ticket.id,
613
+ status: ticket.status,
614
+ },
615
+ };
616
+ }
617
+
618
+ // Done / Failed / Cancelled — full results
619
+ return formatCompletedTicket(ticket);
620
+ }
621
+
622
+ function buildCancelPreview(ticket: AsyncTicket): string {
623
+ const finalized = ticket.progress.filter(
624
+ (p) => p.status === "done" || p.status === "failed",
625
+ ).length;
626
+ const running = ticket.progress.filter((p) => p.status === "running").length;
627
+ const pending = ticket.progress.filter((p) => p.status === "pending").length;
628
+ const lines: string[] = [
629
+ `Ticket ${ticket.id}: cancellation preview`,
630
+ `${finalized}/${ticket.progress.length} finalized · ${running} active · ${pending} queued`,
631
+ ];
632
+
633
+ for (let i = 0; i < ticket.progress.length; i++) {
634
+ const p = ticket.progress[i]!;
635
+ if (p.status === "done") {
636
+ lines.push(`✓ ${p.agent} · completed`);
637
+ } else if (p.status === "failed") {
638
+ lines.push(`✗ ${p.agent} · ${p.error ?? "failed"}`);
639
+ } else if (p.status === "running") {
640
+ const parts: string[] = [formatActivityLabel(p)];
641
+ if (p.toolUses > 0)
642
+ parts.push(`${p.toolUses} tool${p.toolUses === 1 ? "" : "s"}`);
643
+ if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
644
+ const age = getActivityAge(p.lastActivityAt);
645
+ if (age) parts.push(age);
646
+ lines.push(`⏳ ${p.agent} · ${parts.join(" · ")}`);
647
+ } else {
648
+ lines.push(`○ ${p.agent} · waiting…`);
649
+ }
650
+ }
651
+
652
+ lines.push(
653
+ "",
654
+ "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 }).`,
656
+ );
657
+ return lines.join("\n");
658
+ }
659
+
660
+ /** Preview or request cancellation of a running async ticket. */
661
+ export function handleCancel(params: {
662
+ ticket?: string;
663
+ force?: boolean;
664
+ }): AgentToolResult<DelegateDetails> {
665
+ sweepTickets();
666
+ const ticketId = params.ticket;
667
+
668
+ if (!ticketId) {
669
+ return {
670
+ content: [
671
+ { type: "text", text: "action='cancel' requires a ticket ID." },
672
+ ],
673
+ details: { tasks: [], results: [], progress: [] },
674
+ };
675
+ }
676
+ const ticket = ticketRegistry.get(ticketId);
677
+ if (!ticket) {
678
+ return {
679
+ content: [{ type: "text", text: `Ticket '${ticketId}' not found.` }],
680
+ details: { tasks: [], results: [], progress: [] },
681
+ };
682
+ }
683
+ if (ticket.status !== "running") {
684
+ return {
685
+ content: [
686
+ {
687
+ type: "text",
688
+ text: `Ticket '${ticketId}' is already ${ticket.status}.`,
689
+ },
690
+ ],
691
+ details: { tasks: [], results: [], progress: [] },
692
+ };
693
+ }
694
+ if (!params.force) {
695
+ return {
696
+ content: [{ type: "text", text: buildCancelPreview(ticket) }],
697
+ details: buildWaitDetails(ticket),
698
+ };
699
+ }
700
+ ticket.controller.abort();
701
+ ticket.status = "cancelling";
702
+ syncTicketBusyIndex(ticket);
703
+ 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),
711
+ };
712
+ }
713
+
714
+ /** Block until a ticket reaches a terminal state or `timeoutMs` expires.
715
+ * Progress is streamed through `onUpdate` without consuming model turns.
716
+ * Parent-tool abort or timeout detaches the waiter and leaves the ticket
717
+ * running; cancellation remains explicit (`action: "cancel"`). */
718
+ export function handleWait(
719
+ params: { ticket?: string; timeoutMs?: number },
720
+ signal: AbortSignal | undefined,
721
+ onUpdate: AgentToolUpdateCallback<DelegateDetails> | undefined,
722
+ ctx: ExtensionContext,
723
+ ): Promise<AgentToolResult<DelegateDetails>> {
724
+ sweepTickets();
725
+ const parentModelId = ctx.model?.id;
726
+
727
+ const ticketId = params.ticket;
728
+ if (!ticketId) {
729
+ return Promise.resolve({
730
+ content: [{ type: "text", text: "action='wait' requires a ticket ID." }],
731
+ details: {
732
+ tasks: [],
733
+ results: [],
734
+ progress: [],
735
+ parentModel: parentModelId,
736
+ },
737
+ });
738
+ }
739
+
740
+ const ticket = ticketRegistry.get(ticketId);
741
+ if (!ticket) {
742
+ return Promise.resolve({
743
+ content: [{ type: "text", text: `Ticket '${ticketId}' not found.` }],
744
+ details: {
745
+ tasks: [],
746
+ results: [],
747
+ progress: [],
748
+ parentModel: parentModelId,
749
+ },
750
+ });
751
+ }
752
+
753
+ if (ticket.status !== "running" && ticket.status !== "cancelling") {
754
+ return Promise.resolve(formatCompletedTicket(ticket));
755
+ }
756
+
757
+ // Already aborted parent signal → detach immediately.
758
+ if (signal?.aborted) {
759
+ return Promise.resolve(buildWaitAbortResult(ticket));
760
+ }
761
+
762
+ return new Promise((resolve, reject) => {
763
+ const waiter: TicketWaiter = {
764
+ signal,
765
+ onUpdate,
766
+ resolve,
767
+ reject,
768
+ settled: false,
769
+ };
770
+
771
+ if (signal) {
772
+ signal.addEventListener(
773
+ "abort",
774
+ () => {
775
+ abortWaiter(waiter, ticket);
776
+ },
777
+ { once: true },
778
+ );
779
+ }
780
+
781
+ if (
782
+ params.timeoutMs !== undefined &&
783
+ params.timeoutMs >= 0 &&
784
+ Number.isFinite(params.timeoutMs)
785
+ ) {
786
+ scheduleWaitTimeout(waiter, ticket, params.timeoutMs);
787
+ }
788
+
789
+ ticket.waiters = ticket.waiters ?? [];
790
+ ticket.waiters.push(waiter);
791
+
792
+ // Immediate progress frame so the TUI shows the current state.
793
+ notifyWaiters(ticket);
794
+ });
795
+ }