@bermudi/pi-delegate 0.1.10 → 0.1.12
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 +57 -4
- package/concurrency.ts +55 -16
- package/config.ts +584 -50
- package/delegate.ts +11 -3
- package/dispatch.ts +436 -65
- package/extension.ts +16 -4
- package/format.ts +52 -6
- package/host-cache.ts +70 -0
- package/host.ts +169 -811
- package/isolated-workspace.ts +857 -0
- package/lifecycle.ts +699 -520
- package/manual.ts +4 -7
- package/package.json +1 -1
- package/pi-package-source.ts +293 -0
- package/pool.ts +23 -1
- package/provider-extensions.ts +537 -0
- package/quiescence.ts +262 -0
- package/render-branches.ts +30 -0
- package/render-result.ts +8 -5
- package/runner.ts +35 -141
- package/schema.ts +133 -202
- package/settings.ts +202 -84
- package/shared-write-safety.ts +273 -0
- package/task-resolution.ts +121 -32
- package/telemetry.ts +135 -68
- package/ticket-format.ts +331 -0
- package/tickets.ts +101 -258
- package/tools.ts +12 -0
- package/trusted-paths.ts +71 -0
- package/types.ts +83 -16
package/tickets.ts
CHANGED
|
@@ -9,21 +9,21 @@ import type {
|
|
|
9
9
|
import { ASYNC_TICKET_TTL_MS } from "./constants.ts";
|
|
10
10
|
import {
|
|
11
11
|
fmtDuration,
|
|
12
|
-
fmtTokens,
|
|
13
12
|
formatCompletedTask,
|
|
14
13
|
formatTaskId,
|
|
15
|
-
shortenPath,
|
|
16
14
|
trunc,
|
|
17
|
-
getActivityAge,
|
|
18
|
-
formatActivityLabel,
|
|
19
|
-
taskMetaBase,
|
|
20
|
-
relativeTouchedSummary,
|
|
21
15
|
findTouchedOverlaps,
|
|
22
16
|
formatTouchedOverlapWarning,
|
|
23
17
|
} from "./format.ts";
|
|
24
18
|
import { isCrossLeafTicket } from "./leaf.ts";
|
|
25
|
-
import { renderOutputForPoll } from "./spill.ts";
|
|
26
19
|
import { scheduleDeadline } from "./timer.ts";
|
|
20
|
+
import {
|
|
21
|
+
emptyTicketPollResult,
|
|
22
|
+
formatCancelPreview,
|
|
23
|
+
formatLiveTicketPoll,
|
|
24
|
+
missingTicketPollResult,
|
|
25
|
+
rosterTicketPollResult,
|
|
26
|
+
} from "./ticket-format.ts";
|
|
27
27
|
import { aggregateTaskResults, emptyUsage } from "./usage.ts";
|
|
28
28
|
import { recordCall } from "./telemetry.ts";
|
|
29
29
|
import type {
|
|
@@ -115,6 +115,39 @@ export function sweepTickets(): void {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** Options for `settleTicket`. */
|
|
119
|
+
export interface SettleTicketOptions {
|
|
120
|
+
status: "done" | "failed" | "cancelled";
|
|
121
|
+
/** Recorded on the ticket (unexpected worker failure); omitted leaves any
|
|
122
|
+
* existing error untouched. */
|
|
123
|
+
error?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Transition an active ticket to a terminal state. Single owner of the
|
|
128
|
+
* terminal transition — status, completion timestamp, error, and the
|
|
129
|
+
* busy-session index — so the normal-completion, unexpected-error, and
|
|
130
|
+
* shutdown paths cannot drift apart. Result delivery is deliberately outside
|
|
131
|
+
* this transition: a host `sendMessage()` failure must not undo or re-enter
|
|
132
|
+
* worker settlement. Settling is idempotent: `completedAt` is the settle
|
|
133
|
+
* marker, and a second settle attempt is a loud no-op.
|
|
134
|
+
*/
|
|
135
|
+
export function settleTicket(
|
|
136
|
+
ticket: AsyncTicket,
|
|
137
|
+
opts: SettleTicketOptions,
|
|
138
|
+
): void {
|
|
139
|
+
if (ticket.completedAt) {
|
|
140
|
+
console.error(
|
|
141
|
+
`[delegate] ticket '${ticket.id}' already settled as '${ticket.status}'; ignoring settle as '${opts.status}'`,
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
ticket.status = opts.status;
|
|
146
|
+
if (opts.error !== undefined) ticket.error = opts.error;
|
|
147
|
+
ticket.completedAt = Date.now();
|
|
148
|
+
syncTicketBusyIndex(ticket);
|
|
149
|
+
}
|
|
150
|
+
|
|
118
151
|
/**
|
|
119
152
|
* Finalize an active ticket during host shutdown and resolve any blocking
|
|
120
153
|
* waiters. This intentionally does not send a follow-up: the host is exiting.
|
|
@@ -122,21 +155,21 @@ export function sweepTickets(): void {
|
|
|
122
155
|
export function cancelTicketForShutdown(ticket: AsyncTicket): void {
|
|
123
156
|
if (ticket.status !== "running" && ticket.status !== "cancelling") return;
|
|
124
157
|
ticket.controller.abort();
|
|
125
|
-
ticket
|
|
126
|
-
ticket.completedAt = Date.now();
|
|
127
|
-
syncTicketBusyIndex(ticket);
|
|
158
|
+
settleTicket(ticket, { status: "cancelled" });
|
|
128
159
|
settleTicketWaiters(ticket);
|
|
129
160
|
if (ticket.callRecord) {
|
|
161
|
+
const completedAt = ticket.completedAt ?? Date.now();
|
|
130
162
|
const { totalTokens, totalCost } = aggregateTaskResults(ticket.results);
|
|
131
163
|
recordCall(
|
|
132
164
|
{
|
|
133
165
|
...ticket.callRecord,
|
|
134
166
|
status: "cancelled",
|
|
135
|
-
wall_ms:
|
|
167
|
+
wall_ms: completedAt - (ticket.callStartedAt ?? ticket.created),
|
|
136
168
|
total_tokens: totalTokens,
|
|
137
169
|
total_cost: totalCost,
|
|
138
170
|
},
|
|
139
171
|
ticket.telemetryGeneration,
|
|
172
|
+
ticket.telemetryConfig,
|
|
140
173
|
);
|
|
141
174
|
}
|
|
142
175
|
}
|
|
@@ -168,9 +201,9 @@ export function isSessionBusy(sessionId: string): string | null {
|
|
|
168
201
|
* is also "failed".
|
|
169
202
|
*
|
|
170
203
|
* Callers are expected to have already handled "cancelled" / "cancelling"
|
|
171
|
-
* (set by handleCancel) before invoking this;
|
|
172
|
-
*
|
|
173
|
-
*
|
|
204
|
+
* (set by handleCancel / cancelTicketForShutdown) before invoking this;
|
|
205
|
+
* dispatchAsync's completion path routes both through `settleTicket`, which
|
|
206
|
+
* only calls this for a still-"running" ticket.
|
|
174
207
|
*/
|
|
175
208
|
export function resolveFinalTicketStatus(
|
|
176
209
|
ticket: AsyncTicket,
|
|
@@ -206,6 +239,9 @@ export function formatCompletedTicket(
|
|
|
206
239
|
parts.push(
|
|
207
240
|
`${statusTag}${succeeded}/${ticket.results.length} ${completionLabel} · ${fmtDuration(elapsedTotal)} wall time\n`,
|
|
208
241
|
);
|
|
242
|
+
if (ticket.dispatchWarning) {
|
|
243
|
+
parts.push(`WARNING: ${ticket.dispatchWarning}`);
|
|
244
|
+
}
|
|
209
245
|
|
|
210
246
|
const pendingLabelFor = (index: number): string => {
|
|
211
247
|
if (ticket.status !== "cancelled") {
|
|
@@ -226,7 +262,7 @@ export function formatCompletedTicket(
|
|
|
226
262
|
parts.push(`[${pendingLabelFor(i)}]`);
|
|
227
263
|
continue;
|
|
228
264
|
}
|
|
229
|
-
parts.push(...formatCompletedTask(t, r));
|
|
265
|
+
parts.push(...formatCompletedTask(t, r, ticket.config));
|
|
230
266
|
}
|
|
231
267
|
|
|
232
268
|
const completedResults = ticket.results.filter(
|
|
@@ -262,6 +298,7 @@ export function formatCompletedTicket(
|
|
|
262
298
|
ticketId: ticket.id,
|
|
263
299
|
status: ticket.status,
|
|
264
300
|
overlapWarning: overlapWarning || undefined,
|
|
301
|
+
dispatchWarning: ticket.dispatchWarning,
|
|
265
302
|
},
|
|
266
303
|
};
|
|
267
304
|
}
|
|
@@ -304,9 +341,21 @@ function buildWaitDetails(ticket: AsyncTicket): DelegateDetails {
|
|
|
304
341
|
ticketId: ticket.id,
|
|
305
342
|
status: ticket.status,
|
|
306
343
|
overlapWarning: overlapWarning || undefined,
|
|
344
|
+
dispatchWarning: ticket.dispatchWarning,
|
|
307
345
|
};
|
|
308
346
|
}
|
|
309
347
|
|
|
348
|
+
function appendDispatchWarnings(
|
|
349
|
+
base: string,
|
|
350
|
+
details: DelegateDetails,
|
|
351
|
+
): string {
|
|
352
|
+
const warnings = [
|
|
353
|
+
details.dispatchWarning ? `WARNING: ${details.dispatchWarning}` : undefined,
|
|
354
|
+
details.overlapWarning,
|
|
355
|
+
].filter((warning): warning is string => warning !== undefined);
|
|
356
|
+
return warnings.length ? `${base}\n\n${warnings.join("\n\n")}` : base;
|
|
357
|
+
}
|
|
358
|
+
|
|
310
359
|
function buildWaitRunningUpdate(
|
|
311
360
|
ticket: AsyncTicket,
|
|
312
361
|
): AgentToolResult<DelegateDetails> {
|
|
@@ -326,9 +375,7 @@ function buildWaitRunningUpdate(
|
|
|
326
375
|
if (pending > 0) parts.push(`${pending} queued`);
|
|
327
376
|
|
|
328
377
|
const details = buildWaitDetails(ticket);
|
|
329
|
-
const text =
|
|
330
|
-
parts.join(" · ") +
|
|
331
|
-
(details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
378
|
+
const text = appendDispatchWarnings(parts.join(" · "), details);
|
|
332
379
|
return {
|
|
333
380
|
content: [{ type: "text", text }],
|
|
334
381
|
details,
|
|
@@ -366,8 +413,7 @@ function buildWaitAbortResult(
|
|
|
366
413
|
): AgentToolResult<DelegateDetails> {
|
|
367
414
|
const details = buildWaitDetails(ticket);
|
|
368
415
|
const base = `Wait for ticket ${ticket.id} aborted · ticket continues ${ticket.status} in the background`;
|
|
369
|
-
const text =
|
|
370
|
-
base + (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
416
|
+
const text = appendDispatchWarnings(base, details);
|
|
371
417
|
return {
|
|
372
418
|
content: [{ type: "text", text }],
|
|
373
419
|
details,
|
|
@@ -517,21 +563,31 @@ export function deliverTicketResults(
|
|
|
517
563
|
|
|
518
564
|
const crossLeaf = isCrossLeafTicket(ticket);
|
|
519
565
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
566
|
+
try {
|
|
567
|
+
pi.sendMessage(
|
|
568
|
+
{
|
|
569
|
+
customType: "async_delegate_result",
|
|
570
|
+
content: crossLeaf ? `${CROSS_LEAF_NOTICE}\n\n${text}` : text,
|
|
571
|
+
display: true,
|
|
572
|
+
details: {
|
|
573
|
+
...formatted.details,
|
|
574
|
+
ticketId: ticket.id,
|
|
575
|
+
status: ticket.status,
|
|
576
|
+
},
|
|
529
577
|
},
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
)
|
|
578
|
+
crossLeaf
|
|
579
|
+
? { deliverAs: "nextTurn" }
|
|
580
|
+
: { deliverAs: "steer", triggerTurn: true },
|
|
581
|
+
);
|
|
582
|
+
} catch (error) {
|
|
583
|
+
// Delivery is an observer boundary. The terminal ticket remains pollable
|
|
584
|
+
// even when the host cannot accept an unsolicited follow-up.
|
|
585
|
+
console.error(
|
|
586
|
+
`[delegate] failed to deliver results for ticket '${ticket.id}'; it remains pollable`,
|
|
587
|
+
error,
|
|
588
|
+
);
|
|
589
|
+
return "none";
|
|
590
|
+
}
|
|
535
591
|
return crossLeaf ? "deferred" : "steer";
|
|
536
592
|
}
|
|
537
593
|
|
|
@@ -545,195 +601,23 @@ export function handlePoll(
|
|
|
545
601
|
|
|
546
602
|
// Only use top-level ticket param — per-task prompt is NOT a ticket ID
|
|
547
603
|
const ticketId = params.ticket;
|
|
548
|
-
|
|
549
|
-
// No ticket specified — list all
|
|
550
604
|
if (!ticketId) {
|
|
551
605
|
const tickets = [...ticketRegistry.values()];
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
{
|
|
556
|
-
type: "text",
|
|
557
|
-
text: [
|
|
558
|
-
"No async tickets.",
|
|
559
|
-
"",
|
|
560
|
-
"To spawn a subagent: delegate({ tasks: [{ agent, prompt }] }).",
|
|
561
|
-
"For the full manual and agent list, call delegate({ tasks: [] }) with no top-level `ticketAction`.",
|
|
562
|
-
].join("\n"),
|
|
563
|
-
},
|
|
564
|
-
],
|
|
565
|
-
details: {
|
|
566
|
-
tasks: [],
|
|
567
|
-
results: [],
|
|
568
|
-
progress: [],
|
|
569
|
-
parentModel: parentModelId,
|
|
570
|
-
},
|
|
571
|
-
};
|
|
572
|
-
}
|
|
573
|
-
const lines = tickets.map((t) => {
|
|
574
|
-
const icon =
|
|
575
|
-
t.status === "running" || t.status === "cancelling"
|
|
576
|
-
? "⏳"
|
|
577
|
-
: t.status === "done"
|
|
578
|
-
? "✓"
|
|
579
|
-
: "✗";
|
|
580
|
-
const done = t.progress.filter((p) => p.status === "done").length;
|
|
581
|
-
const age = fmtDuration(Date.now() - t.created);
|
|
582
|
-
// Agent roster — compact, deduplicated (a ticket may run the same agent
|
|
583
|
-
// several times). Helps a human tell tickets apart at a glance.
|
|
584
|
-
const agentSet = [
|
|
585
|
-
...new Set(t.progress.map((p) => p.agent).filter(Boolean)),
|
|
586
|
-
];
|
|
587
|
-
const agents = agentSet.length
|
|
588
|
-
? ` · ${agentSet.slice(0, 3).join(", ")}${agentSet.length > 3 ? ` +${agentSet.length - 3}` : ""}`
|
|
589
|
-
: "";
|
|
590
|
-
let line = `${icon} ${t.id}${agents} · ${done}/${t.progress.length} tasks · ${t.status} · ${age}`;
|
|
591
|
-
// Copy-pasteable controls for running/cancelling tickets — a human can grab these
|
|
592
|
-
// straight out of the TUI without retyping the ticket id.
|
|
593
|
-
if (t.status === "running" || t.status === "cancelling") {
|
|
594
|
-
line += `\n poll: delegate({ ticketAction: "poll", ticket: "${t.id}" })`;
|
|
595
|
-
if (t.status === "running") {
|
|
596
|
-
line += `\n cancel: delegate({ ticketAction: "cancel", ticket: "${t.id}", force: true })`;
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
return line;
|
|
600
|
-
});
|
|
601
|
-
return {
|
|
602
|
-
content: [{ type: "text", text: `Async tickets:\n${lines.join("\n")}` }],
|
|
603
|
-
details: {
|
|
604
|
-
tasks: [],
|
|
605
|
-
results: [],
|
|
606
|
-
progress: [],
|
|
607
|
-
parentModel: parentModelId,
|
|
608
|
-
},
|
|
609
|
-
};
|
|
606
|
+
return tickets.length
|
|
607
|
+
? rosterTicketPollResult(tickets, parentModelId)
|
|
608
|
+
: emptyTicketPollResult(parentModelId);
|
|
610
609
|
}
|
|
611
610
|
|
|
612
|
-
// Specific ticket
|
|
613
611
|
const ticket = ticketRegistry.get(ticketId);
|
|
614
|
-
if (!ticket)
|
|
615
|
-
return {
|
|
616
|
-
content: [
|
|
617
|
-
{
|
|
618
|
-
type: "text",
|
|
619
|
-
text: `Ticket '${ticketId}' not found. It may have expired or never existed.`,
|
|
620
|
-
},
|
|
621
|
-
],
|
|
622
|
-
details: {
|
|
623
|
-
tasks: [],
|
|
624
|
-
results: [],
|
|
625
|
-
progress: [],
|
|
626
|
-
parentModel: parentModelId,
|
|
627
|
-
},
|
|
628
|
-
};
|
|
629
|
-
}
|
|
612
|
+
if (!ticket) return missingTicketPollResult(ticketId, parentModelId);
|
|
630
613
|
|
|
631
614
|
if (ticket.status === "running" || ticket.status === "cancelling") {
|
|
632
|
-
const
|
|
633
|
-
(p) => p.status === "failed",
|
|
634
|
-
).length;
|
|
635
|
-
// Settled = done + failed. Used for the "all finished" guidance check.
|
|
636
|
-
const settledCount = ticket.progress.filter(
|
|
637
|
-
(p) => p.status === "done" || p.status === "failed",
|
|
638
|
-
).length;
|
|
639
|
-
const totalCount = ticket.progress.length;
|
|
640
|
-
const runningCount = ticket.progress.filter(
|
|
641
|
-
(p) => p.status === "running",
|
|
642
|
-
).length;
|
|
643
|
-
const pendingCount = ticket.progress.filter(
|
|
644
|
-
(p) => p.status === "pending",
|
|
645
|
-
).length;
|
|
646
|
-
const totalTools = ticket.progress.reduce((sum, p) => sum + p.toolUses, 0);
|
|
647
|
-
const totalTokens = ticket.progress.reduce((sum, p) => sum + p.tokens, 0);
|
|
648
|
-
const lines: string[] = [];
|
|
649
|
-
// Index-aligned sparse array — same shape as ticket.results, so consumers
|
|
650
|
-
// can correlate results[i] with progress[i] and tasks[i].
|
|
651
|
-
const completedResults: (TaskResult | undefined)[] = new Array(
|
|
652
|
-
ticket.progress.length,
|
|
653
|
-
).fill(undefined);
|
|
654
|
-
|
|
655
|
-
for (let i = 0; i < ticket.progress.length; i++) {
|
|
656
|
-
const p = ticket.progress[i]!;
|
|
657
|
-
const r = ticket.results[i];
|
|
658
|
-
|
|
659
|
-
if (p.status === "done" && r) {
|
|
660
|
-
const meta = taskMetaBase(r);
|
|
661
|
-
if (r.touchedFiles.length > 0) {
|
|
662
|
-
const t = ticket.resolved[i]!;
|
|
663
|
-
const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
|
|
664
|
-
if (touched) meta.push(`touched (best-effort): ${touched}`);
|
|
665
|
-
}
|
|
666
|
-
lines.push(`✓ ${r.agent}${formatTaskId(r.id)} · ${meta.join(" · ")}`);
|
|
667
|
-
if (r.output && r.output !== "(no output)") {
|
|
668
|
-
lines.push(renderOutputForPoll(r.output));
|
|
669
|
-
}
|
|
670
|
-
completedResults[i] = r;
|
|
671
|
-
} else if (p.status === "failed" && r) {
|
|
672
|
-
const meta = taskMetaBase(r);
|
|
673
|
-
if (r.touchedFiles.length > 0) {
|
|
674
|
-
const t = ticket.resolved[i]!;
|
|
675
|
-
const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
|
|
676
|
-
if (touched) meta.push(`touched (best-effort): ${touched}`);
|
|
677
|
-
}
|
|
678
|
-
const errorText = r.error ?? "unknown error";
|
|
679
|
-
lines.push(
|
|
680
|
-
`✗ ${r.agent}${formatTaskId(r.id)} · ${errorText} · ${meta.join(" · ")}`,
|
|
681
|
-
);
|
|
682
|
-
if (r.sessionFile)
|
|
683
|
-
lines.push(` session: ${shortenPath(r.sessionFile)}`);
|
|
684
|
-
if (r.output && r.output !== "(no output)")
|
|
685
|
-
lines.push(renderOutputForPoll(r.output));
|
|
686
|
-
completedResults[i] = r;
|
|
687
|
-
} else if (p.status === "running") {
|
|
688
|
-
const parts: string[] = [formatActivityLabel(p)];
|
|
689
|
-
if (p.toolUses > 0)
|
|
690
|
-
parts.push(`${p.toolUses} tool${p.toolUses === 1 ? "" : "s"}`);
|
|
691
|
-
if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
|
|
692
|
-
const age = getActivityAge(p.lastActivityAt);
|
|
693
|
-
if (age) parts.push(age);
|
|
694
|
-
lines.push(`⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`);
|
|
695
|
-
} else {
|
|
696
|
-
lines.push(`○ ${p.agent}${formatTaskId(p.id)} · waiting…`);
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
const completedForOverlap = completedResults.filter(
|
|
701
|
-
(r): r is TaskResult => r !== undefined,
|
|
702
|
-
);
|
|
703
|
-
const overlapWarning = formatTouchedOverlapWarning(
|
|
704
|
-
findTouchedOverlaps(completedForOverlap),
|
|
705
|
-
);
|
|
706
|
-
|
|
707
|
-
const headerStatus =
|
|
708
|
-
ticket.status === "cancelling" ? "CANCELLING" : "RUNNING";
|
|
709
|
-
const headerParts: string[] = [
|
|
710
|
-
`Ticket ${ticket.id}: ${headerStatus}`,
|
|
711
|
-
`${settledCount}/${totalCount} finalized`,
|
|
712
|
-
];
|
|
713
|
-
if (runningCount > 0) headerParts.push(`${runningCount} active`);
|
|
714
|
-
if (pendingCount > 0) headerParts.push(`${pendingCount} queued`);
|
|
715
|
-
if (failedCount > 0) headerParts.push(`${failedCount} failed`);
|
|
716
|
-
headerParts.push(`${totalTools} tool${totalTools === 1 ? "" : "s"}`);
|
|
717
|
-
headerParts.push(`${fmtTokens(totalTokens)} tokens`);
|
|
718
|
-
headerParts.push(`(${fmtDuration(Date.now() - ticket.created)})`);
|
|
719
|
-
const header = headerParts.join(" · ");
|
|
720
|
-
const guidance =
|
|
721
|
-
ticket.status === "cancelling"
|
|
722
|
-
? "Cancellation requested. Active subagents are aborting and returning partial results. Wait without timeoutMs for final status; do not repeatedly poll."
|
|
723
|
-
: settledCount === totalCount
|
|
724
|
-
? ""
|
|
725
|
-
: "If you need the final result in this turn, call wait once with timeoutMs omitted. Otherwise stop calling ticket controls and let the final result auto-deliver after this turn; repeated polling will not speed it up.";
|
|
726
|
-
|
|
615
|
+
const snapshot = formatLiveTicketPoll(ticket);
|
|
727
616
|
return {
|
|
728
|
-
content: [
|
|
729
|
-
{
|
|
730
|
-
type: "text",
|
|
731
|
-
text: `${header}\n${lines.join("\n")}${guidance ? `\n\n${guidance}` : ""}${overlapWarning ? `\n\n${overlapWarning}` : ""}`,
|
|
732
|
-
},
|
|
733
|
-
],
|
|
617
|
+
content: [{ type: "text", text: snapshot.text }],
|
|
734
618
|
details: {
|
|
735
619
|
tasks: ticket.tasks,
|
|
736
|
-
results: completedResults.map(
|
|
620
|
+
results: snapshot.completedResults.map(
|
|
737
621
|
(r, i) => r ?? pendingResultPlaceholder(ticket.resolved[i]),
|
|
738
622
|
),
|
|
739
623
|
progress: [...ticket.progress],
|
|
@@ -742,53 +626,15 @@ export function handlePoll(
|
|
|
742
626
|
// (friction #2). The LLM-facing content still names the ticket id too.
|
|
743
627
|
ticketId: ticket.id,
|
|
744
628
|
status: ticket.status,
|
|
745
|
-
overlapWarning: overlapWarning || undefined,
|
|
629
|
+
overlapWarning: snapshot.overlapWarning || undefined,
|
|
630
|
+
dispatchWarning: ticket.dispatchWarning,
|
|
746
631
|
},
|
|
747
632
|
};
|
|
748
633
|
}
|
|
749
634
|
|
|
750
|
-
// Done / Failed / Cancelled — full results
|
|
751
635
|
return formatCompletedTicket(ticket);
|
|
752
636
|
}
|
|
753
637
|
|
|
754
|
-
function buildCancelPreview(ticket: AsyncTicket): string {
|
|
755
|
-
const finalized = ticket.progress.filter(
|
|
756
|
-
(p) => p.status === "done" || p.status === "failed",
|
|
757
|
-
).length;
|
|
758
|
-
const running = ticket.progress.filter((p) => p.status === "running").length;
|
|
759
|
-
const pending = ticket.progress.filter((p) => p.status === "pending").length;
|
|
760
|
-
const lines: string[] = [
|
|
761
|
-
`Ticket ${ticket.id}: cancellation preview`,
|
|
762
|
-
`${finalized}/${ticket.progress.length} finalized · ${running} active · ${pending} queued`,
|
|
763
|
-
];
|
|
764
|
-
|
|
765
|
-
for (let i = 0; i < ticket.progress.length; i++) {
|
|
766
|
-
const p = ticket.progress[i]!;
|
|
767
|
-
if (p.status === "done") {
|
|
768
|
-
lines.push(`✓ ${p.agent}${formatTaskId(p.id)} · completed`);
|
|
769
|
-
} else if (p.status === "failed") {
|
|
770
|
-
lines.push(`✗ ${p.agent}${formatTaskId(p.id)} · ${p.error ?? "failed"}`);
|
|
771
|
-
} else if (p.status === "running") {
|
|
772
|
-
const parts: string[] = [formatActivityLabel(p)];
|
|
773
|
-
if (p.toolUses > 0)
|
|
774
|
-
parts.push(`${p.toolUses} tool${p.toolUses === 1 ? "" : "s"}`);
|
|
775
|
-
if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
|
|
776
|
-
const age = getActivityAge(p.lastActivityAt);
|
|
777
|
-
if (age) parts.push(age);
|
|
778
|
-
lines.push(`⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`);
|
|
779
|
-
} else {
|
|
780
|
-
lines.push(`○ ${p.agent}${formatTaskId(p.id)} · waiting…`);
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
lines.push(
|
|
785
|
-
"",
|
|
786
|
-
"WARNING: Cancelling now will abort active subagents. Files already written or shell commands already executed are NOT rolled back.",
|
|
787
|
-
`To proceed, call delegate({ ticketAction: "cancel", ticket: "${ticket.id}", force: true }).`,
|
|
788
|
-
);
|
|
789
|
-
return lines.join("\n");
|
|
790
|
-
}
|
|
791
|
-
|
|
792
638
|
/** Preview or request cancellation of a running async ticket. */
|
|
793
639
|
export function handleCancel(params: {
|
|
794
640
|
ticket?: string;
|
|
@@ -825,9 +671,7 @@ export function handleCancel(params: {
|
|
|
825
671
|
}
|
|
826
672
|
if (!params.force) {
|
|
827
673
|
const details = buildWaitDetails(ticket);
|
|
828
|
-
const text =
|
|
829
|
-
buildCancelPreview(ticket) +
|
|
830
|
-
(details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
674
|
+
const text = appendDispatchWarnings(formatCancelPreview(ticket), details);
|
|
831
675
|
return {
|
|
832
676
|
content: [{ type: "text", text }],
|
|
833
677
|
details,
|
|
@@ -836,8 +680,7 @@ export function handleCancel(params: {
|
|
|
836
680
|
requestTicketCancel(ticket);
|
|
837
681
|
const details = buildWaitDetails(ticket);
|
|
838
682
|
const base = `Ticket '${ticketId}' is cancelling; workers are settling. Poll for final status.`;
|
|
839
|
-
const text =
|
|
840
|
-
base + (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
683
|
+
const text = appendDispatchWarnings(base, details);
|
|
841
684
|
return {
|
|
842
685
|
content: [{ type: "text", text }],
|
|
843
686
|
details,
|
package/tools.ts
CHANGED
|
@@ -29,6 +29,18 @@ TOOL_FACTORIES.grep = createGrepTool;
|
|
|
29
29
|
TOOL_FACTORIES.find = createFindTool;
|
|
30
30
|
TOOL_FACTORIES.ls = createLsTool;
|
|
31
31
|
|
|
32
|
+
/** Tools supplied by the allowlisted provider extension rather than Pi core. */
|
|
33
|
+
const PROVIDER_TOOLS: Readonly<Record<string, readonly string[]>> = {
|
|
34
|
+
"openai-codex": ["web_search"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function availableToolNames(modelProvider?: string): string[] {
|
|
38
|
+
return [
|
|
39
|
+
...Object.keys(TOOL_FACTORIES),
|
|
40
|
+
...(modelProvider ? (PROVIDER_TOOLS[modelProvider] ?? []) : []),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
/** Expand tool-group shorthands (`*`, `ro`) into concrete tool lists.
|
|
33
45
|
* Unknown names pass through unchanged for the caller to validate.
|
|
34
46
|
* Returns a deduped list. */
|
package/trusted-paths.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path containment primitives for the subagent trust boundary.
|
|
3
|
+
*
|
|
4
|
+
* These decide whether a resolved package directory is allowed to become
|
|
5
|
+
* executable subagent code, so they compare **canonical** paths: a symlink
|
|
6
|
+
* whose target escapes an install root must not be able to launder itself
|
|
7
|
+
* through a lexically-innocent path.
|
|
8
|
+
*/
|
|
9
|
+
import { realpathSync } from "node:fs";
|
|
10
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a path through symlinks, or `undefined` if the path cannot be
|
|
14
|
+
* canonicalized.
|
|
15
|
+
*
|
|
16
|
+
* A path that cannot be resolved to a real filesystem target is never accepted
|
|
17
|
+
* as trusted: returning `undefined` lets callers fail closed rather than
|
|
18
|
+
* falling back to lexical resolution, which a symlink whose target escapes an
|
|
19
|
+
* install root could launder itself through.
|
|
20
|
+
*/
|
|
21
|
+
export function canonicalPath(candidate: string): string | undefined {
|
|
22
|
+
try {
|
|
23
|
+
return realpathSync(candidate);
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether `candidate` is `directory` itself or lies beneath it, after symlink
|
|
30
|
+
* resolution. Returns `false` when either path cannot be canonicalized. */
|
|
31
|
+
export function isPathWithinDirectory(
|
|
32
|
+
directory: string | undefined,
|
|
33
|
+
candidate: string | undefined,
|
|
34
|
+
): boolean {
|
|
35
|
+
if (directory === undefined || candidate === undefined) return false;
|
|
36
|
+
const canonicalDirectory = canonicalPath(directory);
|
|
37
|
+
const canonicalCandidate = canonicalPath(candidate);
|
|
38
|
+
if (canonicalDirectory === undefined || canonicalCandidate === undefined) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const relativePath = relative(canonicalDirectory, canonicalCandidate);
|
|
42
|
+
return (
|
|
43
|
+
relativePath === "" ||
|
|
44
|
+
(relativePath !== ".." &&
|
|
45
|
+
!relativePath.startsWith(`..${sep}`) &&
|
|
46
|
+
!isAbsolute(relativePath))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether `candidate` is `directory` itself or lies beneath it, using lexical
|
|
52
|
+
* resolution only (no symlink resolution).
|
|
53
|
+
*
|
|
54
|
+
* Use this for containment checks on paths that are already trusted or that
|
|
55
|
+
* need not exist on disk (e.g. classifying load failures by root). For the
|
|
56
|
+
* subagent trust boundary, use `isPathWithinDirectory` instead — a symlink
|
|
57
|
+
* whose target escapes an install root must not launder itself through a
|
|
58
|
+
* lexical check.
|
|
59
|
+
*/
|
|
60
|
+
export function isPathWithinDirectoryLexical(
|
|
61
|
+
directory: string,
|
|
62
|
+
candidate: string,
|
|
63
|
+
): boolean {
|
|
64
|
+
const relativePath = relative(resolve(directory), resolve(candidate));
|
|
65
|
+
return (
|
|
66
|
+
relativePath === "" ||
|
|
67
|
+
(relativePath !== ".." &&
|
|
68
|
+
!relativePath.startsWith(`..${sep}`) &&
|
|
69
|
+
!isAbsolute(relativePath))
|
|
70
|
+
);
|
|
71
|
+
}
|