@bermudi/pi-delegate 0.1.0 → 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/README.md +55 -10
- package/agents.ts +176 -19
- package/concurrency.ts +70 -7
- package/constants.ts +3 -0
- package/delegate.ts +26 -1
- package/dispatch.ts +65 -9
- package/extension.ts +86 -6
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/host-compat.ts +47 -12
- package/host.ts +93 -16
- package/leaf.ts +48 -0
- package/lifecycle.ts +292 -95
- package/manual.ts +45 -11
- package/model.ts +3 -4
- package/package.json +25 -20
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/pool.ts +169 -51
- package/render-branches.ts +52 -16
- package/render-result.ts +12 -0
- package/runner.ts +255 -51
- package/schema.ts +252 -63
- package/status.ts +269 -0
- package/task-resolution.ts +196 -77
- package/tickets.ts +173 -62
- package/tools.ts +16 -15
- package/types.ts +62 -13
package/tickets.ts
CHANGED
|
@@ -11,22 +11,30 @@ import {
|
|
|
11
11
|
fmtDuration,
|
|
12
12
|
fmtTokens,
|
|
13
13
|
formatCompletedTask,
|
|
14
|
+
formatTaskId,
|
|
14
15
|
shortenPath,
|
|
15
16
|
trunc,
|
|
16
17
|
getActivityAge,
|
|
17
18
|
formatActivityLabel,
|
|
18
19
|
taskMetaBase,
|
|
19
20
|
relativeTouchedSummary,
|
|
21
|
+
findTouchedOverlaps,
|
|
22
|
+
formatTouchedOverlapWarning,
|
|
20
23
|
} from "./format.ts";
|
|
24
|
+
import { isCrossLeafTicket } from "./leaf.ts";
|
|
21
25
|
import { renderOutputForPoll } from "./spill.ts";
|
|
22
26
|
import { scheduleDeadline } from "./timer.ts";
|
|
27
|
+
import { emptyUsage } from "./usage.ts";
|
|
23
28
|
import type {
|
|
24
29
|
AsyncTicket,
|
|
25
30
|
DelegateDetails,
|
|
31
|
+
ResolvedTask,
|
|
26
32
|
TaskResult,
|
|
27
33
|
TicketWaiter,
|
|
28
34
|
} from "./types.ts";
|
|
29
35
|
|
|
36
|
+
const PENDING_RESULT_ERROR = "PENDING — result not available";
|
|
37
|
+
|
|
30
38
|
const busyTicketIdsBySession = new Map<string, Set<string>>();
|
|
31
39
|
const busySessionsByTicket = new Map<string, Set<string>>();
|
|
32
40
|
|
|
@@ -119,6 +127,17 @@ export function cancelTicketForShutdown(ticket: AsyncTicket): void {
|
|
|
119
127
|
settleTicketWaiters(ticket);
|
|
120
128
|
}
|
|
121
129
|
|
|
130
|
+
/** Request cooperative cancellation of a live ticket: abort the workers and
|
|
131
|
+
* move to "cancelling" so they settle and report what actually ran. Unlike
|
|
132
|
+
* `cancelTicketForShutdown` this leaves the ticket deliverable — the runtime
|
|
133
|
+
* is still alive, so the final "cancelled" result still reaches the user. */
|
|
134
|
+
export function requestTicketCancel(ticket: AsyncTicket): void {
|
|
135
|
+
if (ticket.status !== "running") return;
|
|
136
|
+
ticket.controller.abort();
|
|
137
|
+
ticket.status = "cancelling";
|
|
138
|
+
syncTicketBusyIndex(ticket);
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
/** Check if any running async ticket holds a given sessionId.
|
|
123
142
|
* Backed by an O(1) map updated when tickets start/complete. */
|
|
124
143
|
export function isSessionBusy(sessionId: string): string | null {
|
|
@@ -187,13 +206,23 @@ export function formatCompletedTicket(
|
|
|
187
206
|
const r = ticket.results[i];
|
|
188
207
|
const t = ticket.resolved[i]!;
|
|
189
208
|
if (!r) {
|
|
190
|
-
parts.push(
|
|
209
|
+
parts.push(
|
|
210
|
+
`=== ${t.agentName}${formatTaskId(t.id)}: ${trunc(t.prompt || "", 80)} ===`,
|
|
211
|
+
);
|
|
191
212
|
parts.push(`[${pendingLabelFor(i)}]`);
|
|
192
213
|
continue;
|
|
193
214
|
}
|
|
194
215
|
parts.push(...formatCompletedTask(t, r));
|
|
195
216
|
}
|
|
196
217
|
|
|
218
|
+
const completedResults = ticket.results.filter(
|
|
219
|
+
(r): r is TaskResult => r !== undefined && "touchedFiles" in r,
|
|
220
|
+
);
|
|
221
|
+
const overlapWarning = formatTouchedOverlapWarning(
|
|
222
|
+
findTouchedOverlaps(completedResults),
|
|
223
|
+
);
|
|
224
|
+
if (overlapWarning) parts.push("", overlapWarning);
|
|
225
|
+
|
|
197
226
|
if (ticket.status === "cancelled") {
|
|
198
227
|
parts.push(
|
|
199
228
|
"",
|
|
@@ -206,7 +235,11 @@ export function formatCompletedTicket(
|
|
|
206
235
|
details: {
|
|
207
236
|
tasks: ticket.tasks,
|
|
208
237
|
results: [...ticket.results].map(
|
|
209
|
-
(r, index) =>
|
|
238
|
+
(r, index) =>
|
|
239
|
+
r ?? {
|
|
240
|
+
...pendingResultPlaceholder(ticket.resolved[index]),
|
|
241
|
+
error: pendingLabelFor(index),
|
|
242
|
+
},
|
|
210
243
|
),
|
|
211
244
|
progress: [...ticket.progress],
|
|
212
245
|
parentModel: ticket.parentModelId,
|
|
@@ -214,22 +247,49 @@ export function formatCompletedTicket(
|
|
|
214
247
|
// the human sees which ticket they polled, even in the rich tree path.
|
|
215
248
|
ticketId: ticket.id,
|
|
216
249
|
status: ticket.status,
|
|
250
|
+
overlapWarning: overlapWarning || undefined,
|
|
217
251
|
},
|
|
218
252
|
};
|
|
219
253
|
}
|
|
220
254
|
|
|
221
255
|
// ── Waiter helpers ─────────────────────────────────────────────────────────
|
|
222
256
|
|
|
257
|
+
function pendingResultPlaceholder(task: ResolvedTask | undefined): TaskResult {
|
|
258
|
+
return {
|
|
259
|
+
id: task?.id,
|
|
260
|
+
agent: task?.agentName ?? "unknown",
|
|
261
|
+
output: "",
|
|
262
|
+
durationMs: 0,
|
|
263
|
+
tokens: 0,
|
|
264
|
+
usage: emptyUsage(),
|
|
265
|
+
touchedFiles: [],
|
|
266
|
+
attributedFiles: [],
|
|
267
|
+
// Machine-visible and human-readable marker so structured consumers do not
|
|
268
|
+
// mistake a pending placeholder for a successful result.
|
|
269
|
+
error: PENDING_RESULT_ERROR,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
223
273
|
function buildWaitDetails(ticket: AsyncTicket): DelegateDetails {
|
|
274
|
+
const completedResults = ticket.results.filter(
|
|
275
|
+
(r): r is TaskResult => r !== undefined && "touchedFiles" in r,
|
|
276
|
+
);
|
|
277
|
+
const overlapWarning = formatTouchedOverlapWarning(
|
|
278
|
+
findTouchedOverlaps(completedResults),
|
|
279
|
+
);
|
|
280
|
+
const results: TaskResult[] = [];
|
|
281
|
+
for (let i = 0; i < ticket.resolved.length; i++) {
|
|
282
|
+
const r = ticket.results[i];
|
|
283
|
+
results.push(r ?? pendingResultPlaceholder(ticket.resolved[i]));
|
|
284
|
+
}
|
|
224
285
|
return {
|
|
225
286
|
tasks: ticket.tasks,
|
|
226
|
-
results
|
|
227
|
-
(r) => r ?? { error: "PENDING — result not available" },
|
|
228
|
-
),
|
|
287
|
+
results,
|
|
229
288
|
progress: [...ticket.progress],
|
|
230
289
|
parentModel: ticket.parentModelId,
|
|
231
290
|
ticketId: ticket.id,
|
|
232
291
|
status: ticket.status,
|
|
292
|
+
overlapWarning: overlapWarning || undefined,
|
|
233
293
|
};
|
|
234
294
|
}
|
|
235
295
|
|
|
@@ -251,9 +311,13 @@ function buildWaitRunningUpdate(
|
|
|
251
311
|
if (failed > 0) parts.push(`${failed} failed`);
|
|
252
312
|
if (pending > 0) parts.push(`${pending} queued`);
|
|
253
313
|
|
|
314
|
+
const details = buildWaitDetails(ticket);
|
|
315
|
+
const text =
|
|
316
|
+
parts.join(" · ") +
|
|
317
|
+
(details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
254
318
|
return {
|
|
255
|
-
content: [{ type: "text", text
|
|
256
|
-
details
|
|
319
|
+
content: [{ type: "text", text }],
|
|
320
|
+
details,
|
|
257
321
|
};
|
|
258
322
|
}
|
|
259
323
|
|
|
@@ -261,28 +325,26 @@ function buildWaitTimeoutResult(
|
|
|
261
325
|
ticket: AsyncTicket,
|
|
262
326
|
timeoutMs: number,
|
|
263
327
|
): AgentToolResult<DelegateDetails> {
|
|
328
|
+
const details = buildWaitDetails(ticket);
|
|
329
|
+
const base = `Ticket ${ticket.id} still ${ticket.status} after ${fmtDuration(timeoutMs)} · wait timed out (ticket continues in background)`;
|
|
330
|
+
const text =
|
|
331
|
+
base + (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
264
332
|
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),
|
|
333
|
+
content: [{ type: "text", text }],
|
|
334
|
+
details,
|
|
272
335
|
};
|
|
273
336
|
}
|
|
274
337
|
|
|
275
338
|
function buildWaitAbortResult(
|
|
276
339
|
ticket: AsyncTicket,
|
|
277
340
|
): AgentToolResult<DelegateDetails> {
|
|
341
|
+
const details = buildWaitDetails(ticket);
|
|
342
|
+
const base = `Wait for ticket ${ticket.id} aborted · ticket continues ${ticket.status} in the background`;
|
|
343
|
+
const text =
|
|
344
|
+
base + (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
278
345
|
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),
|
|
346
|
+
content: [{ type: "text", text }],
|
|
347
|
+
details,
|
|
286
348
|
};
|
|
287
349
|
}
|
|
288
350
|
|
|
@@ -366,23 +428,57 @@ export function notifyWaiters(ticket: AsyncTicket): void {
|
|
|
366
428
|
continue;
|
|
367
429
|
}
|
|
368
430
|
active.push(w);
|
|
369
|
-
if (w.onUpdate)
|
|
431
|
+
if (w.onUpdate) {
|
|
432
|
+
try {
|
|
433
|
+
w.onUpdate(buildWaitRunningUpdate(ticket));
|
|
434
|
+
} catch (error) {
|
|
435
|
+
// Progress delivery is an observer boundary. A host callback must not
|
|
436
|
+
// be able to fail the worker that reported the update or reject the
|
|
437
|
+
// wait promise; keep the waiter attached for the terminal result and
|
|
438
|
+
// leave the failure visible for diagnosis.
|
|
439
|
+
console.error(
|
|
440
|
+
`[delegate] wait progress callback for ticket '${ticket.id}' threw; continuing`,
|
|
441
|
+
error,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
370
445
|
}
|
|
371
446
|
ticket.waiters = active.length ? active : undefined;
|
|
372
447
|
}
|
|
373
448
|
|
|
449
|
+
/** Prefix for a result whose spawn leaf is no longer the active one. The model
|
|
450
|
+
* would otherwise read a foreign branch's work as current-turn context. */
|
|
451
|
+
const CROSS_LEAF_NOTICE =
|
|
452
|
+
"NOTE: this async delegate ticket was spawned on a different branch of the " +
|
|
453
|
+
"session tree; the conversation has since navigated elsewhere (/tree). These " +
|
|
454
|
+
"results may not relate to the current line of work — verify relevance before " +
|
|
455
|
+
"acting on them.";
|
|
456
|
+
|
|
457
|
+
/** How a completed ticket was handed back. `deferred` means the result was
|
|
458
|
+
* queued without waking the agent because the session navigated away from
|
|
459
|
+
* the spawn leaf; callers surface that to the human (see status.ts). */
|
|
460
|
+
export type TicketDelivery = "none" | "waiters" | "steer" | "deferred";
|
|
461
|
+
|
|
374
462
|
/** Push results into parent session via sendMessage when background ticket completes.
|
|
375
463
|
* If there are active blocking waiters, resolve them directly and suppress the
|
|
376
|
-
* automatic follow-up so completion is delivered exactly once.
|
|
464
|
+
* automatic follow-up so completion is delivered exactly once.
|
|
465
|
+
*
|
|
466
|
+
* Delivery mode depends on leaf affinity (see leaf.ts). Same leaf: `steer` +
|
|
467
|
+
* `triggerTurn`, the agent picks the result up immediately. Different leaf:
|
|
468
|
+
* `nextTurn`, which explicitly "does not interrupt or trigger anything" — the
|
|
469
|
+
* result waits for the human's next prompt instead of waking the agent on a
|
|
470
|
+
* branch the task was never part of. The ticket stays pollable either way. */
|
|
377
471
|
export function deliverTicketResults(
|
|
378
472
|
pi: ExtensionAPI,
|
|
379
473
|
ticket: AsyncTicket,
|
|
380
|
-
):
|
|
381
|
-
if (!ticket.completedAt) return;
|
|
474
|
+
): TicketDelivery {
|
|
475
|
+
if (!ticket.completedAt) return "none";
|
|
382
476
|
|
|
383
477
|
// Resolve active blocking waiters directly. Stale/aborted waiters are
|
|
384
478
|
// cleaned but not resolved here (their abort handlers already returned).
|
|
385
|
-
|
|
479
|
+
// A waiter is a tool call on the live leaf by construction, so leaf
|
|
480
|
+
// affinity does not apply to it.
|
|
481
|
+
if (settleTicketWaiters(ticket)) return "waiters";
|
|
386
482
|
|
|
387
483
|
const formatted = formatCompletedTicket(ticket);
|
|
388
484
|
const text = formatted.content
|
|
@@ -393,10 +489,12 @@ export function deliverTicketResults(
|
|
|
393
489
|
.map((c) => c.text)
|
|
394
490
|
.join("\n");
|
|
395
491
|
|
|
492
|
+
const crossLeaf = isCrossLeafTicket(ticket);
|
|
493
|
+
|
|
396
494
|
pi.sendMessage(
|
|
397
495
|
{
|
|
398
496
|
customType: "async_delegate_result",
|
|
399
|
-
content: text,
|
|
497
|
+
content: crossLeaf ? `${CROSS_LEAF_NOTICE}\n\n${text}` : text,
|
|
400
498
|
display: true,
|
|
401
499
|
details: {
|
|
402
500
|
...formatted.details,
|
|
@@ -404,11 +502,11 @@ export function deliverTicketResults(
|
|
|
404
502
|
status: ticket.status,
|
|
405
503
|
},
|
|
406
504
|
},
|
|
407
|
-
|
|
408
|
-
deliverAs: "
|
|
409
|
-
triggerTurn: true,
|
|
410
|
-
},
|
|
505
|
+
crossLeaf
|
|
506
|
+
? { deliverAs: "nextTurn" }
|
|
507
|
+
: { deliverAs: "steer", triggerTurn: true },
|
|
411
508
|
);
|
|
509
|
+
return crossLeaf ? "deferred" : "steer";
|
|
412
510
|
}
|
|
413
511
|
|
|
414
512
|
/** Return a snapshot of one async ticket or the complete ticket roster. */
|
|
@@ -434,7 +532,7 @@ export function handlePoll(
|
|
|
434
532
|
"No async tickets.",
|
|
435
533
|
"",
|
|
436
534
|
"To spawn a subagent: delegate({ tasks: [{ agent, prompt }] }).",
|
|
437
|
-
"For the full manual and agent list, call delegate({ tasks: [] }) with no top-level `
|
|
535
|
+
"For the full manual and agent list, call delegate({ tasks: [] }) with no top-level `ticketAction`.",
|
|
438
536
|
].join("\n"),
|
|
439
537
|
},
|
|
440
538
|
],
|
|
@@ -467,9 +565,9 @@ export function handlePoll(
|
|
|
467
565
|
// Copy-pasteable controls for running/cancelling tickets — a human can grab these
|
|
468
566
|
// straight out of the TUI without retyping the ticket id.
|
|
469
567
|
if (t.status === "running" || t.status === "cancelling") {
|
|
470
|
-
line += `\n poll: delegate({
|
|
568
|
+
line += `\n poll: delegate({ ticketAction: "poll", ticket: "${t.id}" })`;
|
|
471
569
|
if (t.status === "running") {
|
|
472
|
-
line += `\n cancel: delegate({
|
|
570
|
+
line += `\n cancel: delegate({ ticketAction: "cancel", ticket: "${t.id}", force: true })`;
|
|
473
571
|
}
|
|
474
572
|
}
|
|
475
573
|
return line;
|
|
@@ -537,9 +635,9 @@ export function handlePoll(
|
|
|
537
635
|
if (r.touchedFiles.length > 0) {
|
|
538
636
|
const t = ticket.resolved[i]!;
|
|
539
637
|
const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
|
|
540
|
-
if (touched) meta.push(`touched: ${touched}`);
|
|
638
|
+
if (touched) meta.push(`touched (best-effort): ${touched}`);
|
|
541
639
|
}
|
|
542
|
-
lines.push(`✓ ${r.agent} · ${meta.join(" · ")}`);
|
|
640
|
+
lines.push(`✓ ${r.agent}${formatTaskId(r.id)} · ${meta.join(" · ")}`);
|
|
543
641
|
if (r.output && r.output !== "(no output)") {
|
|
544
642
|
lines.push(renderOutputForPoll(r.output));
|
|
545
643
|
}
|
|
@@ -549,10 +647,12 @@ export function handlePoll(
|
|
|
549
647
|
if (r.touchedFiles.length > 0) {
|
|
550
648
|
const t = ticket.resolved[i]!;
|
|
551
649
|
const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
|
|
552
|
-
if (touched) meta.push(`touched: ${touched}`);
|
|
650
|
+
if (touched) meta.push(`touched (best-effort): ${touched}`);
|
|
553
651
|
}
|
|
554
652
|
const errorText = r.error ?? "unknown error";
|
|
555
|
-
lines.push(
|
|
653
|
+
lines.push(
|
|
654
|
+
`✗ ${r.agent}${formatTaskId(r.id)} · ${errorText} · ${meta.join(" · ")}`,
|
|
655
|
+
);
|
|
556
656
|
if (r.sessionFile)
|
|
557
657
|
lines.push(` session: ${shortenPath(r.sessionFile)}`);
|
|
558
658
|
if (r.output && r.output !== "(no output)")
|
|
@@ -565,12 +665,19 @@ export function handlePoll(
|
|
|
565
665
|
if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
|
|
566
666
|
const age = getActivityAge(p.lastActivityAt);
|
|
567
667
|
if (age) parts.push(age);
|
|
568
|
-
lines.push(`⏳ ${p.agent} · ${parts.join(" · ")}`);
|
|
668
|
+
lines.push(`⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`);
|
|
569
669
|
} else {
|
|
570
|
-
lines.push(`○ ${p.agent} · waiting…`);
|
|
670
|
+
lines.push(`○ ${p.agent}${formatTaskId(p.id)} · waiting…`);
|
|
571
671
|
}
|
|
572
672
|
}
|
|
573
673
|
|
|
674
|
+
const completedForOverlap = completedResults.filter(
|
|
675
|
+
(r): r is TaskResult => r !== undefined,
|
|
676
|
+
);
|
|
677
|
+
const overlapWarning = formatTouchedOverlapWarning(
|
|
678
|
+
findTouchedOverlaps(completedForOverlap),
|
|
679
|
+
);
|
|
680
|
+
|
|
574
681
|
const headerStatus =
|
|
575
682
|
ticket.status === "cancelling" ? "CANCELLING" : "RUNNING";
|
|
576
683
|
const headerParts: string[] = [
|
|
@@ -597,13 +704,13 @@ export function handlePoll(
|
|
|
597
704
|
content: [
|
|
598
705
|
{
|
|
599
706
|
type: "text",
|
|
600
|
-
text: `${header}\n${lines.join("\n")}${guidance ? `\n\n${guidance}` : ""}`,
|
|
707
|
+
text: `${header}\n${lines.join("\n")}${guidance ? `\n\n${guidance}` : ""}${overlapWarning ? `\n\n${overlapWarning}` : ""}`,
|
|
601
708
|
},
|
|
602
709
|
],
|
|
603
710
|
details: {
|
|
604
711
|
tasks: ticket.tasks,
|
|
605
712
|
results: completedResults.map(
|
|
606
|
-
(r) => r ??
|
|
713
|
+
(r, i) => r ?? pendingResultPlaceholder(ticket.resolved[i]),
|
|
607
714
|
),
|
|
608
715
|
progress: [...ticket.progress],
|
|
609
716
|
parentModel: ticket.parentModelId,
|
|
@@ -611,6 +718,7 @@ export function handlePoll(
|
|
|
611
718
|
// (friction #2). The LLM-facing content still names the ticket id too.
|
|
612
719
|
ticketId: ticket.id,
|
|
613
720
|
status: ticket.status,
|
|
721
|
+
overlapWarning: overlapWarning || undefined,
|
|
614
722
|
},
|
|
615
723
|
};
|
|
616
724
|
}
|
|
@@ -633,9 +741,9 @@ function buildCancelPreview(ticket: AsyncTicket): string {
|
|
|
633
741
|
for (let i = 0; i < ticket.progress.length; i++) {
|
|
634
742
|
const p = ticket.progress[i]!;
|
|
635
743
|
if (p.status === "done") {
|
|
636
|
-
lines.push(`✓ ${p.agent} · completed`);
|
|
744
|
+
lines.push(`✓ ${p.agent}${formatTaskId(p.id)} · completed`);
|
|
637
745
|
} else if (p.status === "failed") {
|
|
638
|
-
lines.push(`✗ ${p.agent} · ${p.error ?? "failed"}`);
|
|
746
|
+
lines.push(`✗ ${p.agent}${formatTaskId(p.id)} · ${p.error ?? "failed"}`);
|
|
639
747
|
} else if (p.status === "running") {
|
|
640
748
|
const parts: string[] = [formatActivityLabel(p)];
|
|
641
749
|
if (p.toolUses > 0)
|
|
@@ -643,16 +751,16 @@ function buildCancelPreview(ticket: AsyncTicket): string {
|
|
|
643
751
|
if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
|
|
644
752
|
const age = getActivityAge(p.lastActivityAt);
|
|
645
753
|
if (age) parts.push(age);
|
|
646
|
-
lines.push(`⏳ ${p.agent} · ${parts.join(" · ")}`);
|
|
754
|
+
lines.push(`⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`);
|
|
647
755
|
} else {
|
|
648
|
-
lines.push(`○ ${p.agent} · waiting…`);
|
|
756
|
+
lines.push(`○ ${p.agent}${formatTaskId(p.id)} · waiting…`);
|
|
649
757
|
}
|
|
650
758
|
}
|
|
651
759
|
|
|
652
760
|
lines.push(
|
|
653
761
|
"",
|
|
654
762
|
"WARNING: Cancelling now will abort active subagents. Files already written or shell commands already executed are NOT rolled back.",
|
|
655
|
-
`To proceed, call delegate({
|
|
763
|
+
`To proceed, call delegate({ ticketAction: "cancel", ticket: "${ticket.id}", force: true }).`,
|
|
656
764
|
);
|
|
657
765
|
return lines.join("\n");
|
|
658
766
|
}
|
|
@@ -668,7 +776,7 @@ export function handleCancel(params: {
|
|
|
668
776
|
if (!ticketId) {
|
|
669
777
|
return {
|
|
670
778
|
content: [
|
|
671
|
-
{ type: "text", text: "
|
|
779
|
+
{ type: "text", text: "ticketAction='cancel' requires a ticket ID." },
|
|
672
780
|
],
|
|
673
781
|
details: { tasks: [], results: [], progress: [] },
|
|
674
782
|
};
|
|
@@ -692,29 +800,30 @@ export function handleCancel(params: {
|
|
|
692
800
|
};
|
|
693
801
|
}
|
|
694
802
|
if (!params.force) {
|
|
803
|
+
const details = buildWaitDetails(ticket);
|
|
804
|
+
const text =
|
|
805
|
+
buildCancelPreview(ticket) +
|
|
806
|
+
(details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
695
807
|
return {
|
|
696
|
-
content: [{ type: "text", text
|
|
697
|
-
details
|
|
808
|
+
content: [{ type: "text", text }],
|
|
809
|
+
details,
|
|
698
810
|
};
|
|
699
811
|
}
|
|
700
|
-
ticket
|
|
701
|
-
|
|
702
|
-
|
|
812
|
+
requestTicketCancel(ticket);
|
|
813
|
+
const details = buildWaitDetails(ticket);
|
|
814
|
+
const base = `Ticket '${ticketId}' is cancelling; workers are settling. Poll for final status.`;
|
|
815
|
+
const text =
|
|
816
|
+
base + (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
703
817
|
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),
|
|
818
|
+
content: [{ type: "text", text }],
|
|
819
|
+
details,
|
|
711
820
|
};
|
|
712
821
|
}
|
|
713
822
|
|
|
714
823
|
/** Block until a ticket reaches a terminal state or `timeoutMs` expires.
|
|
715
824
|
* Progress is streamed through `onUpdate` without consuming model turns.
|
|
716
825
|
* Parent-tool abort or timeout detaches the waiter and leaves the ticket
|
|
717
|
-
* running; cancellation remains explicit (`
|
|
826
|
+
* running; cancellation remains explicit (`ticketAction: "cancel"`). */
|
|
718
827
|
export function handleWait(
|
|
719
828
|
params: { ticket?: string; timeoutMs?: number },
|
|
720
829
|
signal: AbortSignal | undefined,
|
|
@@ -727,7 +836,9 @@ export function handleWait(
|
|
|
727
836
|
const ticketId = params.ticket;
|
|
728
837
|
if (!ticketId) {
|
|
729
838
|
return Promise.resolve({
|
|
730
|
-
content: [
|
|
839
|
+
content: [
|
|
840
|
+
{ type: "text", text: "ticketAction='wait' requires a ticket ID." },
|
|
841
|
+
],
|
|
731
842
|
details: {
|
|
732
843
|
tasks: [],
|
|
733
844
|
results: [],
|
package/tools.ts
CHANGED
|
@@ -12,20 +12,22 @@ import { DEFAULT_TOOLS, READONLY_TOOLS } from "./constants.ts";
|
|
|
12
12
|
|
|
13
13
|
/** Shorthand → concrete tool list. `*` = full agent (bash subsumes search);
|
|
14
14
|
* `ro` = read-only scout (search without shell). */
|
|
15
|
-
const TOOL_GROUPS: Record<string, string[]> =
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const TOOL_GROUPS: Record<string, string[]> = Object.create(null) as Record<
|
|
16
|
+
string,
|
|
17
|
+
string[]
|
|
18
|
+
>;
|
|
19
|
+
TOOL_GROUPS["*"] = DEFAULT_TOOLS;
|
|
20
|
+
TOOL_GROUPS.ro = READONLY_TOOLS;
|
|
19
21
|
|
|
20
|
-
export const TOOL_FACTORIES: Record<string, (cwd: string) => AgentTool<any>> =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
export const TOOL_FACTORIES: Record<string, (cwd: string) => AgentTool<any>> =
|
|
23
|
+
Object.create(null) as Record<string, (cwd: string) => AgentTool<any>>;
|
|
24
|
+
TOOL_FACTORIES.read = createReadTool;
|
|
25
|
+
TOOL_FACTORIES.write = createWriteTool;
|
|
26
|
+
TOOL_FACTORIES.edit = createEditTool;
|
|
27
|
+
TOOL_FACTORIES.bash = createBashTool;
|
|
28
|
+
TOOL_FACTORIES.grep = createGrepTool;
|
|
29
|
+
TOOL_FACTORIES.find = createFindTool;
|
|
30
|
+
TOOL_FACTORIES.ls = createLsTool;
|
|
29
31
|
|
|
30
32
|
/** Expand tool-group shorthands (`*`, `ro`) into concrete tool lists.
|
|
31
33
|
* Unknown names pass through unchanged for the caller to validate.
|
|
@@ -33,8 +35,7 @@ export const TOOL_FACTORIES: Record<string, (cwd: string) => AgentTool<any>> = {
|
|
|
33
35
|
export function resolveToolGroups(tools: string[]): string[] {
|
|
34
36
|
const resolved: string[] = [];
|
|
35
37
|
for (const t of tools) {
|
|
36
|
-
|
|
37
|
-
if (group) resolved.push(...group);
|
|
38
|
+
if (Object.hasOwn(TOOL_GROUPS, t)) resolved.push(...TOOL_GROUPS[t]);
|
|
38
39
|
else resolved.push(t);
|
|
39
40
|
}
|
|
40
41
|
return [...new Set(resolved)];
|
package/types.ts
CHANGED
|
@@ -25,18 +25,37 @@ export interface AgentConfig {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
// ── Tool parameter types — derived from the TypeBox schema ────────────────
|
|
28
|
-
// `delegateArgumentsSchema` in schema.ts is the
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// The import is type-only, so the schema.ts ↔ types.ts cycle is erased
|
|
32
|
-
// compile time.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
// `delegateArgumentsSchema` in schema.ts is the canonical provider-visible
|
|
29
|
+
// shape. The public types add deprecated `action` aliases so existing TypeScript
|
|
30
|
+
// callers remain source-compatible without advertising the overloaded fields to
|
|
31
|
+
// models. The import is type-only, so the schema.ts ↔ types.ts cycle is erased
|
|
32
|
+
// at compile time.
|
|
33
|
+
|
|
34
|
+
type CanonicalDelegateArguments = Static<typeof delegateArgumentsSchema>;
|
|
35
|
+
type CanonicalTaskDef = NonNullable<
|
|
36
|
+
CanonicalDelegateArguments["tasks"]
|
|
37
|
+
>[number];
|
|
38
|
+
|
|
36
39
|
/** Top-level async ticket action: "poll" | "cancel" | "wait". */
|
|
37
|
-
export type
|
|
40
|
+
export type TicketAction = NonNullable<
|
|
41
|
+
CanonicalDelegateArguments["ticketAction"]
|
|
42
|
+
>;
|
|
38
43
|
/** Per-task session action: "prompt" | "close" | "list". */
|
|
39
|
-
export type SessionAction = NonNullable<
|
|
44
|
+
export type SessionAction = NonNullable<CanonicalTaskDef["sessionAction"]>;
|
|
45
|
+
|
|
46
|
+
export type TaskDef = CanonicalTaskDef & {
|
|
47
|
+
/** @deprecated Use `sessionAction` instead. Runtime normalization still accepts this alias. */
|
|
48
|
+
action?: SessionAction;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type DelegateArguments = Omit<CanonicalDelegateArguments, "tasks"> & {
|
|
52
|
+
/** @deprecated Use `ticketAction` instead. Runtime normalization still accepts this alias. */
|
|
53
|
+
action?: TicketAction;
|
|
54
|
+
tasks?: TaskDef[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** @deprecated Use `TicketAction` instead. */
|
|
58
|
+
export type DelegateAction = TicketAction;
|
|
40
59
|
|
|
41
60
|
// ── Async Ticket Types ─────────────────────────────────────────────────────
|
|
42
61
|
|
|
@@ -61,10 +80,22 @@ export interface AsyncTicket {
|
|
|
61
80
|
controller: AbortController;
|
|
62
81
|
error?: string;
|
|
63
82
|
parentModelId?: string;
|
|
83
|
+
/** Session-tree leaf active when the ticket was spawned (see leaf.ts).
|
|
84
|
+
* `undefined` = the leaf the session opened on. Compared at delivery time
|
|
85
|
+
* so results are not used to wake the agent on a foreign branch. */
|
|
86
|
+
spawnLeafId?: string | null;
|
|
64
87
|
/** Active blocking waiters. Resolved by terminal delivery or timeout/abort. */
|
|
65
88
|
waiters?: TicketWaiter[];
|
|
66
89
|
}
|
|
67
90
|
|
|
91
|
+
/** Live parent settings captured when a delegate call starts. The built-in
|
|
92
|
+
* `default` profile mirrors these settings, limited to tools delegate can
|
|
93
|
+
* safely recreate without loading the parent's extensions. */
|
|
94
|
+
export interface ParentAgentDefaults {
|
|
95
|
+
thinking: ThinkingLevel;
|
|
96
|
+
tools: string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
68
99
|
export interface ReuseIntent {
|
|
69
100
|
/** Explicit model requested by this call/profile; omitted means use frozen. */
|
|
70
101
|
model?: Model<Api>;
|
|
@@ -73,6 +104,7 @@ export interface ReuseIntent {
|
|
|
73
104
|
}
|
|
74
105
|
|
|
75
106
|
export interface ResolvedTask {
|
|
107
|
+
id?: string;
|
|
76
108
|
prompt: string;
|
|
77
109
|
agent?: string;
|
|
78
110
|
model: Model<Api>;
|
|
@@ -82,8 +114,10 @@ export interface ResolvedTask {
|
|
|
82
114
|
cwd: string;
|
|
83
115
|
context?: "fresh" | "with-parent-transcript";
|
|
84
116
|
sessionId?: string;
|
|
85
|
-
|
|
117
|
+
sessionAction?: SessionAction;
|
|
86
118
|
resumeFrom?: string;
|
|
119
|
+
/** Hard wall-clock budget in milliseconds, measured from task start. */
|
|
120
|
+
deadlineMs?: number;
|
|
87
121
|
agentName: string;
|
|
88
122
|
warnings: string[];
|
|
89
123
|
/** Explicit settings that must match a live pooled session on reuse. */
|
|
@@ -109,10 +143,14 @@ export interface ToolActivity {
|
|
|
109
143
|
* - `model_error`: the failure is attributable to the resolved model/provider
|
|
110
144
|
* (account usage limit, quota exhausted, auth lost) — not transient for that
|
|
111
145
|
* model, so same-model retry is pointless. The parent should resume with a
|
|
112
|
-
* different `model` (see `resumeFrom` + `model`).
|
|
113
|
-
|
|
146
|
+
* different `model` (see `resumeFrom` + `model`).
|
|
147
|
+
* - `deadline_exceeded`: the task's `deadlineMs` wall-clock budget expired
|
|
148
|
+
* (measured from when the task left the concurrency queue). The prompt was
|
|
149
|
+
* cooperatively aborted; completed side effects are not rolled back. */
|
|
150
|
+
export type TaskFailureKind = "stalled" | "model_error" | "deadline_exceeded";
|
|
114
151
|
|
|
115
152
|
export interface TaskProgress {
|
|
153
|
+
id?: string;
|
|
116
154
|
index: number;
|
|
117
155
|
agent: string;
|
|
118
156
|
task: string;
|
|
@@ -138,9 +176,13 @@ export interface DelegateDetails {
|
|
|
138
176
|
ticketId?: string;
|
|
139
177
|
/** Terminal/live ticket status when this result comes from an async ticket. */
|
|
140
178
|
status?: AsyncTicket["status"];
|
|
179
|
+
/** Global overlap warning derived from result.attributedFiles, surfaced in both
|
|
180
|
+
* the textual content and the custom TUI. */
|
|
181
|
+
overlapWarning?: string;
|
|
141
182
|
}
|
|
142
183
|
|
|
143
184
|
export interface TaskResult {
|
|
185
|
+
id?: string;
|
|
144
186
|
agent: string;
|
|
145
187
|
output: string;
|
|
146
188
|
error?: string;
|
|
@@ -159,7 +201,14 @@ export interface TaskResult {
|
|
|
159
201
|
* Pi sums `cost.total` for nested usage anyway. */
|
|
160
202
|
usage: Usage;
|
|
161
203
|
sessionFile?: string;
|
|
204
|
+
/** All files the subagent is known to have touched, including bash mutations
|
|
205
|
+
* captured via git diff and other tasks' concurrent git changes. This is a
|
|
206
|
+
* best-effort, repository-wide list for display, not for attribution. */
|
|
162
207
|
touchedFiles: string[];
|
|
208
|
+
/** Files directly attributable to this task's edit/write tool calls. Used
|
|
209
|
+
* for overlap detection so concurrent tasks in the same repo do not
|
|
210
|
+
* fabricate false conflicts from shared git snapshots. */
|
|
211
|
+
attributedFiles?: string[];
|
|
163
212
|
}
|
|
164
213
|
|
|
165
214
|
/** Single source of truth for a subagent's runtime configuration.
|