@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/README.md +92 -0
- package/agents.ts +347 -0
- package/concurrency.ts +126 -0
- package/config.ts +358 -0
- package/constants.ts +41 -0
- package/delegate.ts +115 -0
- package/dispatch.ts +362 -0
- package/extension.ts +126 -0
- package/file-tracking.ts +57 -0
- package/format.ts +506 -0
- package/host-compat.ts +73 -0
- package/host.ts +814 -0
- package/lifecycle.ts +704 -0
- package/manual.ts +184 -0
- package/model.ts +81 -0
- package/package.json +43 -0
- package/parent-context.ts +42 -0
- package/pool.ts +420 -0
- package/render-branches.ts +380 -0
- package/render-result.ts +182 -0
- package/runner.ts +686 -0
- package/schema.ts +289 -0
- package/sessions.ts +102 -0
- package/settings.ts +78 -0
- package/spill.ts +161 -0
- package/task-resolution.ts +321 -0
- package/tickets.ts +795 -0
- package/timer.ts +46 -0
- package/tools.ts +41 -0
- package/types.ts +243 -0
- package/usage.ts +121 -0
- package/utils.ts +131 -0
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
|
+
}
|