@bermudi/pi-delegate 0.1.9 → 0.1.11
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 +22 -7
- package/agents.ts +140 -14
- package/delegate.ts +3 -2
- package/dispatch.ts +26 -27
- package/extension.ts +2 -4
- package/format.ts +2 -2
- package/host-cache.ts +70 -0
- package/host.ts +164 -811
- package/lifecycle.ts +653 -508
- package/manual.ts +40 -10
- package/package.json +1 -1
- package/pi-package-source.ts +293 -0
- package/provider-extensions.ts +528 -0
- package/quiescence.ts +262 -0
- package/runner.ts +32 -140
- package/schema.ts +77 -153
- package/task-resolution.ts +60 -16
- package/ticket-format.ts +323 -0
- package/tickets.ts +79 -248
- package/tools.ts +12 -0
- package/trusted-paths.ts +71 -0
- package/types.ts +13 -17
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,17 +155,16 @@ 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
|
},
|
|
@@ -168,9 +200,9 @@ export function isSessionBusy(sessionId: string): string | null {
|
|
|
168
200
|
* is also "failed".
|
|
169
201
|
*
|
|
170
202
|
* Callers are expected to have already handled "cancelled" / "cancelling"
|
|
171
|
-
* (set by handleCancel) before invoking this;
|
|
172
|
-
*
|
|
173
|
-
*
|
|
203
|
+
* (set by handleCancel / cancelTicketForShutdown) before invoking this;
|
|
204
|
+
* dispatchAsync's completion path routes both through `settleTicket`, which
|
|
205
|
+
* only calls this for a still-"running" ticket.
|
|
174
206
|
*/
|
|
175
207
|
export function resolveFinalTicketStatus(
|
|
176
208
|
ticket: AsyncTicket,
|
|
@@ -517,21 +549,31 @@ export function deliverTicketResults(
|
|
|
517
549
|
|
|
518
550
|
const crossLeaf = isCrossLeafTicket(ticket);
|
|
519
551
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
552
|
+
try {
|
|
553
|
+
pi.sendMessage(
|
|
554
|
+
{
|
|
555
|
+
customType: "async_delegate_result",
|
|
556
|
+
content: crossLeaf ? `${CROSS_LEAF_NOTICE}\n\n${text}` : text,
|
|
557
|
+
display: true,
|
|
558
|
+
details: {
|
|
559
|
+
...formatted.details,
|
|
560
|
+
ticketId: ticket.id,
|
|
561
|
+
status: ticket.status,
|
|
562
|
+
},
|
|
529
563
|
},
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
)
|
|
564
|
+
crossLeaf
|
|
565
|
+
? { deliverAs: "nextTurn" }
|
|
566
|
+
: { deliverAs: "steer", triggerTurn: true },
|
|
567
|
+
);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
// Delivery is an observer boundary. The terminal ticket remains pollable
|
|
570
|
+
// even when the host cannot accept an unsolicited follow-up.
|
|
571
|
+
console.error(
|
|
572
|
+
`[delegate] failed to deliver results for ticket '${ticket.id}'; it remains pollable`,
|
|
573
|
+
error,
|
|
574
|
+
);
|
|
575
|
+
return "none";
|
|
576
|
+
}
|
|
535
577
|
return crossLeaf ? "deferred" : "steer";
|
|
536
578
|
}
|
|
537
579
|
|
|
@@ -545,195 +587,23 @@ export function handlePoll(
|
|
|
545
587
|
|
|
546
588
|
// Only use top-level ticket param — per-task prompt is NOT a ticket ID
|
|
547
589
|
const ticketId = params.ticket;
|
|
548
|
-
|
|
549
|
-
// No ticket specified — list all
|
|
550
590
|
if (!ticketId) {
|
|
551
591
|
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
|
-
};
|
|
592
|
+
return tickets.length
|
|
593
|
+
? rosterTicketPollResult(tickets, parentModelId)
|
|
594
|
+
: emptyTicketPollResult(parentModelId);
|
|
610
595
|
}
|
|
611
596
|
|
|
612
|
-
// Specific ticket
|
|
613
597
|
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
|
-
}
|
|
598
|
+
if (!ticket) return missingTicketPollResult(ticketId, parentModelId);
|
|
630
599
|
|
|
631
600
|
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
|
-
|
|
601
|
+
const snapshot = formatLiveTicketPoll(ticket);
|
|
727
602
|
return {
|
|
728
|
-
content: [
|
|
729
|
-
{
|
|
730
|
-
type: "text",
|
|
731
|
-
text: `${header}\n${lines.join("\n")}${guidance ? `\n\n${guidance}` : ""}${overlapWarning ? `\n\n${overlapWarning}` : ""}`,
|
|
732
|
-
},
|
|
733
|
-
],
|
|
603
|
+
content: [{ type: "text", text: snapshot.text }],
|
|
734
604
|
details: {
|
|
735
605
|
tasks: ticket.tasks,
|
|
736
|
-
results: completedResults.map(
|
|
606
|
+
results: snapshot.completedResults.map(
|
|
737
607
|
(r, i) => r ?? pendingResultPlaceholder(ticket.resolved[i]),
|
|
738
608
|
),
|
|
739
609
|
progress: [...ticket.progress],
|
|
@@ -742,53 +612,14 @@ export function handlePoll(
|
|
|
742
612
|
// (friction #2). The LLM-facing content still names the ticket id too.
|
|
743
613
|
ticketId: ticket.id,
|
|
744
614
|
status: ticket.status,
|
|
745
|
-
overlapWarning: overlapWarning || undefined,
|
|
615
|
+
overlapWarning: snapshot.overlapWarning || undefined,
|
|
746
616
|
},
|
|
747
617
|
};
|
|
748
618
|
}
|
|
749
619
|
|
|
750
|
-
// Done / Failed / Cancelled — full results
|
|
751
620
|
return formatCompletedTicket(ticket);
|
|
752
621
|
}
|
|
753
622
|
|
|
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
623
|
/** Preview or request cancellation of a running async ticket. */
|
|
793
624
|
export function handleCancel(params: {
|
|
794
625
|
ticket?: string;
|
|
@@ -826,7 +657,7 @@ export function handleCancel(params: {
|
|
|
826
657
|
if (!params.force) {
|
|
827
658
|
const details = buildWaitDetails(ticket);
|
|
828
659
|
const text =
|
|
829
|
-
|
|
660
|
+
formatCancelPreview(ticket) +
|
|
830
661
|
(details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
831
662
|
return {
|
|
832
663
|
content: [{ type: "text", text }],
|
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
|
+
}
|
package/types.ts
CHANGED
|
@@ -23,20 +23,26 @@ export interface AgentConfig {
|
|
|
23
23
|
thinking?: ThinkingLevel;
|
|
24
24
|
tools: string[];
|
|
25
25
|
systemPrompt: string;
|
|
26
|
-
/** Built-in profiles
|
|
26
|
+
/** Built-in profiles can be overridden by a same-named Markdown file. */
|
|
27
27
|
builtin?: boolean;
|
|
28
28
|
/** Default workspace for a built-in profile. Custom agents use shared. */
|
|
29
29
|
workspace?: WorkspaceMode;
|
|
30
30
|
/** Origin of the profile. `claude` denotes imported .claude/agents files. */
|
|
31
31
|
scope?: "project" | "global" | "claude";
|
|
32
|
+
/** Whether `tools` was explicitly set in the Markdown frontmatter (vs inherited default). */
|
|
33
|
+
explicitTools?: boolean;
|
|
34
|
+
/** Whether `model` was explicitly set in the Markdown frontmatter. */
|
|
35
|
+
explicitModel?: boolean;
|
|
36
|
+
/** Whether `thinking` was explicitly set in the Markdown frontmatter. */
|
|
37
|
+
explicitThinking?: boolean;
|
|
38
|
+
/** Denylist applied to a built-in `default` override with no explicit allowlist – materialized against parentNativeTools at resolution. */
|
|
39
|
+
deniedTools?: string[];
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
// ── Tool parameter types — derived from the TypeBox schema ────────────────
|
|
35
43
|
// `delegateArgumentsSchema` in schema.ts is the canonical provider-visible
|
|
36
|
-
// shape
|
|
37
|
-
//
|
|
38
|
-
// models. The import is type-only, so the schema.ts ↔ types.ts cycle is erased
|
|
39
|
-
// at compile time.
|
|
44
|
+
// shape; these types are its `Static<>` projections. The import is type-only,
|
|
45
|
+
// so the schema.ts ↔ types.ts cycle is erased at compile time.
|
|
40
46
|
|
|
41
47
|
type CanonicalDelegateArguments = Static<typeof delegateArgumentsSchema>;
|
|
42
48
|
type CanonicalTaskDef = NonNullable<
|
|
@@ -52,19 +58,9 @@ export type SessionAction = NonNullable<CanonicalTaskDef["sessionAction"]>;
|
|
|
52
58
|
/** Filesystem mode: shared source tree or an ephemeral CoW scratch copy. */
|
|
53
59
|
export type WorkspaceMode = NonNullable<CanonicalTaskDef["workspace"]>;
|
|
54
60
|
|
|
55
|
-
export type TaskDef = CanonicalTaskDef
|
|
56
|
-
/** @deprecated Use `sessionAction` instead. Runtime normalization still accepts this alias. */
|
|
57
|
-
action?: SessionAction;
|
|
58
|
-
};
|
|
61
|
+
export type TaskDef = CanonicalTaskDef;
|
|
59
62
|
|
|
60
|
-
export type DelegateArguments =
|
|
61
|
-
/** @deprecated Use `ticketAction` instead. Runtime normalization still accepts this alias. */
|
|
62
|
-
action?: TicketAction;
|
|
63
|
-
tasks?: TaskDef[];
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/** @deprecated Use `TicketAction` instead. */
|
|
67
|
-
export type DelegateAction = TicketAction;
|
|
63
|
+
export type DelegateArguments = CanonicalDelegateArguments;
|
|
68
64
|
|
|
69
65
|
// ── Async Ticket Types ─────────────────────────────────────────────────────
|
|
70
66
|
|