@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.
@@ -0,0 +1,323 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import {
3
+ fmtDuration,
4
+ fmtTokens,
5
+ formatTaskId,
6
+ shortenPath,
7
+ getActivityAge,
8
+ formatActivityLabel,
9
+ taskMetaBase,
10
+ relativeTouchedSummary,
11
+ findTouchedOverlaps,
12
+ formatTouchedOverlapWarning,
13
+ } from "./format.ts";
14
+ import { renderOutputForPoll } from "./spill.ts";
15
+ import type {
16
+ AsyncTicket,
17
+ DelegateDetails,
18
+ ResolvedTask,
19
+ TaskProgress,
20
+ TaskResult,
21
+ } from "./types.ts";
22
+
23
+ /** Discovery hint when poll is called with an empty registry. */
24
+ export const EMPTY_TICKET_POLL_TEXT = [
25
+ "No async tickets.",
26
+ "",
27
+ "To spawn a subagent: delegate({ tasks: [{ agent, prompt }] }).",
28
+ "For the full manual and agent list, call delegate({ tasks: [] }) with no top-level `ticketAction`.",
29
+ ].join("\n");
30
+
31
+ /** Status glyph for a ticket roster line. */
32
+ export function ticketStatusIcon(status: AsyncTicket["status"]): string {
33
+ return status === "running" || status === "cancelling"
34
+ ? "⏳"
35
+ : status === "done"
36
+ ? "✓"
37
+ : "✗";
38
+ }
39
+
40
+ /** Compact, deduplicated agent roster so tickets are distinguishable at a glance. */
41
+ export function formatTicketAgentRoster(progress: TaskProgress[]): string {
42
+ const agentSet = [...new Set(progress.map((p) => p.agent).filter(Boolean))];
43
+ if (!agentSet.length) return "";
44
+ return ` · ${agentSet.slice(0, 3).join(", ")}${
45
+ agentSet.length > 3 ? ` +${agentSet.length - 3}` : ""
46
+ }`;
47
+ }
48
+
49
+ /**
50
+ * Copy-pasteable poll/cancel controls for a live ticket. Cancelling tickets
51
+ * keep poll (to watch unwind) but drop cancel (already requested).
52
+ */
53
+ export function formatTicketControlSnippets(ticket: AsyncTicket): string {
54
+ if (ticket.status !== "running" && ticket.status !== "cancelling") return "";
55
+ let snippets = `\n poll: delegate({ ticketAction: "poll", ticket: "${ticket.id}" })`;
56
+ if (ticket.status === "running") {
57
+ snippets += `\n cancel: delegate({ ticketAction: "cancel", ticket: "${ticket.id}", force: true })`;
58
+ }
59
+ return snippets;
60
+ }
61
+
62
+ /** One roster line for `delegate({ ticketAction: "poll" })` with no ticket id. */
63
+ export function formatTicketRosterLine(
64
+ ticket: AsyncTicket,
65
+ now = Date.now(),
66
+ ): string {
67
+ const icon = ticketStatusIcon(ticket.status);
68
+ const finalized = ticket.progress.filter(
69
+ (p) => p.status === "done" || p.status === "failed",
70
+ ).length;
71
+ const age = fmtDuration(now - ticket.created);
72
+ return `${icon} ${ticket.id}${formatTicketAgentRoster(ticket.progress)} · ${finalized}/${ticket.progress.length} finalized · ${ticket.status} · ${age}${formatTicketControlSnippets(ticket)}`;
73
+ }
74
+
75
+ /** Full roster listing when poll is called without a ticket id. */
76
+ export function formatTicketRoster(
77
+ tickets: AsyncTicket[],
78
+ now = Date.now(),
79
+ ): string {
80
+ return `Async tickets:\n${tickets.map((t) => formatTicketRosterLine(t, now)).join("\n")}`;
81
+ }
82
+
83
+ export function missingTicketPollText(ticketId: string): string {
84
+ return `Ticket '${ticketId}' not found. It may have expired or never existed.`;
85
+ }
86
+
87
+ /** Running-task line shared by poll snapshots and cancel previews. */
88
+ export function formatInFlightTaskLine(p: TaskProgress): string {
89
+ const parts: string[] = [formatActivityLabel(p)];
90
+ if (p.toolUses > 0)
91
+ parts.push(`${p.toolUses} tool${p.toolUses === 1 ? "" : "s"}`);
92
+ if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
93
+ const age = getActivityAge(p.lastActivityAt);
94
+ if (age) parts.push(age);
95
+ return `⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`;
96
+ }
97
+
98
+ /** Queued-task line shared by poll snapshots and cancel previews. */
99
+ export function formatQueuedTaskLine(p: TaskProgress): string {
100
+ return `○ ${p.agent}${formatTaskId(p.id)} · waiting…`;
101
+ }
102
+
103
+ function appendTouchedMeta(
104
+ meta: string[],
105
+ result: TaskResult,
106
+ task: ResolvedTask,
107
+ ): void {
108
+ if (result.touchedFiles.length === 0) return;
109
+ const touched = relativeTouchedSummary(result.touchedFiles, task.cwd);
110
+ if (touched) meta.push(`touched (best-effort): ${touched}`);
111
+ }
112
+
113
+ function formatSettledPollLines(
114
+ result: TaskResult,
115
+ task: ResolvedTask,
116
+ failed: boolean,
117
+ ): string[] {
118
+ const meta = taskMetaBase(result);
119
+ appendTouchedMeta(meta, result, task);
120
+ if (!failed) {
121
+ const lines = [
122
+ `✓ ${result.agent}${formatTaskId(result.id)} · ${meta.join(" · ")}`,
123
+ ];
124
+ if (result.output && result.output !== "(no output)") {
125
+ lines.push(renderOutputForPoll(result.output));
126
+ }
127
+ return lines;
128
+ }
129
+ const errorText = result.error ?? "unknown error";
130
+ const lines = [
131
+ `✗ ${result.agent}${formatTaskId(result.id)} · ${errorText} · ${meta.join(" · ")}`,
132
+ ];
133
+ if (result.sessionFile)
134
+ lines.push(` session: ${shortenPath(result.sessionFile)}`);
135
+ if (result.output && result.output !== "(no output)") {
136
+ lines.push(renderOutputForPoll(result.output));
137
+ }
138
+ return lines;
139
+ }
140
+
141
+ function formatPollTaskLines(
142
+ ticket: AsyncTicket,
143
+ index: number,
144
+ ): { lines: string[]; result?: TaskResult } {
145
+ const p = ticket.progress[index]!;
146
+ const r = ticket.results[index];
147
+ if (p.status === "done" && r) {
148
+ return {
149
+ lines: formatSettledPollLines(r, ticket.resolved[index]!, false),
150
+ result: r,
151
+ };
152
+ }
153
+ if (p.status === "failed" && r) {
154
+ return {
155
+ lines: formatSettledPollLines(r, ticket.resolved[index]!, true),
156
+ result: r,
157
+ };
158
+ }
159
+ if (p.status === "running") {
160
+ return { lines: [formatInFlightTaskLine(p)] };
161
+ }
162
+ return { lines: [formatQueuedTaskLine(p)] };
163
+ }
164
+
165
+ export function formatLiveTicketHeader(
166
+ ticket: AsyncTicket,
167
+ now = Date.now(),
168
+ ): string {
169
+ const failedCount = ticket.progress.filter(
170
+ (p) => p.status === "failed",
171
+ ).length;
172
+ const settledCount = ticket.progress.filter(
173
+ (p) => p.status === "done" || p.status === "failed",
174
+ ).length;
175
+ const totalCount = ticket.progress.length;
176
+ const runningCount = ticket.progress.filter(
177
+ (p) => p.status === "running",
178
+ ).length;
179
+ const pendingCount = ticket.progress.filter(
180
+ (p) => p.status === "pending",
181
+ ).length;
182
+ const totalTools = ticket.progress.reduce((sum, p) => sum + p.toolUses, 0);
183
+ const totalTokens = ticket.progress.reduce((sum, p) => sum + p.tokens, 0);
184
+ const headerStatus =
185
+ ticket.status === "cancelling" ? "CANCELLING" : "RUNNING";
186
+ const headerParts: string[] = [
187
+ `Ticket ${ticket.id}: ${headerStatus}`,
188
+ `${settledCount}/${totalCount} finalized`,
189
+ ];
190
+ if (runningCount > 0) headerParts.push(`${runningCount} active`);
191
+ if (pendingCount > 0) headerParts.push(`${pendingCount} queued`);
192
+ if (failedCount > 0) headerParts.push(`${failedCount} failed`);
193
+ headerParts.push(`${totalTools} tool${totalTools === 1 ? "" : "s"}`);
194
+ headerParts.push(`${fmtTokens(totalTokens)} tokens`);
195
+ headerParts.push(`(${fmtDuration(now - ticket.created)})`);
196
+ return headerParts.join(" · ");
197
+ }
198
+
199
+ export function liveTicketGuidance(ticket: AsyncTicket): string {
200
+ if (ticket.status === "cancelling") {
201
+ return "Cancellation requested. Active subagents are aborting and returning partial results. Wait without timeoutMs for final status; do not repeatedly poll.";
202
+ }
203
+ const settledCount = ticket.progress.filter(
204
+ (p) => p.status === "done" || p.status === "failed",
205
+ ).length;
206
+ return settledCount === ticket.progress.length
207
+ ? ""
208
+ : "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.";
209
+ }
210
+
211
+ export interface LiveTicketPollSnapshot {
212
+ text: string;
213
+ completedResults: (TaskResult | undefined)[];
214
+ overlapWarning: string | null;
215
+ }
216
+
217
+ /** LLM-facing live (running/cancelling) ticket snapshot. */
218
+ export function formatLiveTicketPoll(
219
+ ticket: AsyncTicket,
220
+ now = Date.now(),
221
+ ): LiveTicketPollSnapshot {
222
+ const lines: string[] = [];
223
+ const completedResults: (TaskResult | undefined)[] = new Array(
224
+ ticket.progress.length,
225
+ ).fill(undefined);
226
+
227
+ for (let i = 0; i < ticket.progress.length; i++) {
228
+ const formatted = formatPollTaskLines(ticket, i);
229
+ lines.push(...formatted.lines);
230
+ if (formatted.result) completedResults[i] = formatted.result;
231
+ }
232
+
233
+ const completedForOverlap = completedResults.filter(
234
+ (r): r is TaskResult => r !== undefined,
235
+ );
236
+ const overlapWarning = formatTouchedOverlapWarning(
237
+ findTouchedOverlaps(completedForOverlap),
238
+ );
239
+ const guidance = liveTicketGuidance(ticket);
240
+ return {
241
+ text: `${formatLiveTicketHeader(ticket, now)}\n${lines.join("\n")}${
242
+ guidance ? `\n\n${guidance}` : ""
243
+ }${overlapWarning ? `\n\n${overlapWarning}` : ""}`,
244
+ completedResults,
245
+ overlapWarning,
246
+ };
247
+ }
248
+
249
+ /** Cancel-preview body (without the wait-details overlap appendix). */
250
+ export function formatCancelPreview(ticket: AsyncTicket): string {
251
+ const finalized = ticket.progress.filter(
252
+ (p) => p.status === "done" || p.status === "failed",
253
+ ).length;
254
+ const running = ticket.progress.filter((p) => p.status === "running").length;
255
+ const pending = ticket.progress.filter((p) => p.status === "pending").length;
256
+ const lines: string[] = [
257
+ `Ticket ${ticket.id}: cancellation preview`,
258
+ `${finalized}/${ticket.progress.length} finalized · ${running} active · ${pending} queued`,
259
+ ];
260
+
261
+ for (const p of ticket.progress) {
262
+ if (p.status === "done") {
263
+ lines.push(`✓ ${p.agent}${formatTaskId(p.id)} · completed`);
264
+ } else if (p.status === "failed") {
265
+ lines.push(`✗ ${p.agent}${formatTaskId(p.id)} · ${p.error ?? "failed"}`);
266
+ } else if (p.status === "running") {
267
+ lines.push(formatInFlightTaskLine(p));
268
+ } else {
269
+ lines.push(formatQueuedTaskLine(p));
270
+ }
271
+ }
272
+
273
+ lines.push(
274
+ "",
275
+ "WARNING: Cancelling now will abort active subagents. Files already written or shell commands already executed are NOT rolled back.",
276
+ `To proceed, call delegate({ ticketAction: "cancel", ticket: "${ticket.id}", force: true }).`,
277
+ );
278
+ return lines.join("\n");
279
+ }
280
+
281
+ export function emptyTicketPollResult(
282
+ parentModelId: string | undefined,
283
+ ): AgentToolResult<DelegateDetails> {
284
+ return {
285
+ content: [{ type: "text", text: EMPTY_TICKET_POLL_TEXT }],
286
+ details: {
287
+ tasks: [],
288
+ results: [],
289
+ progress: [],
290
+ parentModel: parentModelId,
291
+ },
292
+ };
293
+ }
294
+
295
+ export function rosterTicketPollResult(
296
+ tickets: AsyncTicket[],
297
+ parentModelId: string | undefined,
298
+ ): AgentToolResult<DelegateDetails> {
299
+ return {
300
+ content: [{ type: "text", text: formatTicketRoster(tickets) }],
301
+ details: {
302
+ tasks: [],
303
+ results: [],
304
+ progress: [],
305
+ parentModel: parentModelId,
306
+ },
307
+ };
308
+ }
309
+
310
+ export function missingTicketPollResult(
311
+ ticketId: string,
312
+ parentModelId: string | undefined,
313
+ ): AgentToolResult<DelegateDetails> {
314
+ return {
315
+ content: [{ type: "text", text: missingTicketPollText(ticketId) }],
316
+ details: {
317
+ tasks: [],
318
+ results: [],
319
+ progress: [],
320
+ parentModel: parentModelId,
321
+ },
322
+ };
323
+ }