@bermudi/pi-delegate 0.1.19 → 0.1.20
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 +50 -2
- package/assistant-preview.ts +31 -0
- package/browser-state.ts +250 -0
- package/browser.ts +334 -0
- package/delegate.ts +2 -0
- package/dispatch.ts +73 -21
- package/extension.ts +30 -2
- package/format.ts +23 -8
- package/lifecycle.ts +3 -0
- package/manual.ts +10 -1
- package/package.json +1 -1
- package/pause.ts +81 -0
- package/render-branches.ts +17 -4
- package/render-result.ts +1 -0
- package/runner.ts +55 -1
- package/schema.ts +7 -5
- package/status.ts +14 -0
- package/ticket-format.ts +25 -4
- package/tickets.ts +54 -2
- package/types.ts +14 -0
package/schema.ts
CHANGED
|
@@ -113,9 +113,9 @@ export const delegateTaskSchema = Type.Object({
|
|
|
113
113
|
export const delegateArgumentsSchema = Type.Object(
|
|
114
114
|
{
|
|
115
115
|
ticketAction: Type.Optional(
|
|
116
|
-
StringEnum(["poll", "cancel", "wait"], {
|
|
116
|
+
StringEnum(["poll", "cancel", "wait", "pause", "resume"], {
|
|
117
117
|
description:
|
|
118
|
-
"
|
|
118
|
+
"poll=snapshot; wait=await results; pause=stop between turns; resume=continue; cancel=abort. Prefer wait over polling.",
|
|
119
119
|
}),
|
|
120
120
|
),
|
|
121
121
|
sessionAction: Type.Optional(
|
|
@@ -213,7 +213,7 @@ export function validateDelegateOperation(
|
|
|
213
213
|
const rawParams = params as Record<string, unknown>;
|
|
214
214
|
if ("action" in rawParams) {
|
|
215
215
|
return (
|
|
216
|
-
"unsupported field 'action'; use 'ticketAction' for poll/cancel/wait " +
|
|
216
|
+
"unsupported field 'action'; use 'ticketAction' for poll/cancel/wait/pause/resume " +
|
|
217
217
|
"or 'sessionAction' for close/list."
|
|
218
218
|
);
|
|
219
219
|
}
|
|
@@ -327,7 +327,7 @@ function validateDispatchOrHelpMode(
|
|
|
327
327
|
const tasks = params.tasks ?? [];
|
|
328
328
|
|
|
329
329
|
if (params.ticket !== undefined) {
|
|
330
|
-
return "ticket requires ticketAction 'poll', 'cancel', or '
|
|
330
|
+
return "ticket requires ticketAction 'poll', 'cancel', 'wait', 'pause', or 'resume'.";
|
|
331
331
|
}
|
|
332
332
|
if (params.force === true)
|
|
333
333
|
return "force is valid only with ticketAction 'cancel'.";
|
|
@@ -415,12 +415,14 @@ function normalizeToolsField(value: string): unknown {
|
|
|
415
415
|
|
|
416
416
|
/** True when `record` carries a top-level ticket-control intent that makes a
|
|
417
417
|
* flat task-field wrap illegitimate: an explicit `ticketAction`, or a bare
|
|
418
|
-
* `ticket` id (which only makes sense with
|
|
418
|
+
* `ticket` id (which only makes sense with ticket controls). */
|
|
419
419
|
function hasTicketControlIntent(record: Record<string, unknown>): boolean {
|
|
420
420
|
return (
|
|
421
421
|
record.ticketAction === "poll" ||
|
|
422
422
|
record.ticketAction === "cancel" ||
|
|
423
423
|
record.ticketAction === "wait" ||
|
|
424
|
+
record.ticketAction === "pause" ||
|
|
425
|
+
record.ticketAction === "resume" ||
|
|
424
426
|
record.ticket !== undefined
|
|
425
427
|
);
|
|
426
428
|
}
|
package/status.ts
CHANGED
|
@@ -62,8 +62,22 @@ function plural(n: number, noun: string): string {
|
|
|
62
62
|
export function buildStatusText(
|
|
63
63
|
summary: ActiveTicketSummary,
|
|
64
64
|
): string | undefined {
|
|
65
|
+
const text = buildStatusSummary(summary);
|
|
66
|
+
return text === undefined ? undefined : `${text} · /subagents`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildStatusSummary(summary: ActiveTicketSummary): string | undefined {
|
|
65
70
|
const { tickets, activeSubagents } = summary;
|
|
66
71
|
if (tickets.length === 0) return undefined;
|
|
72
|
+
const held = tickets.filter(
|
|
73
|
+
(ticket) =>
|
|
74
|
+
ticket.status === "running" &&
|
|
75
|
+
ticket.pause &&
|
|
76
|
+
ticket.pause.state !== "running",
|
|
77
|
+
);
|
|
78
|
+
if (held.length) {
|
|
79
|
+
return `Ⅱ ${held.map((ticket) => `${ticket.id} ${ticket.pause!.state}`).join(" · ")}${tickets.length > held.length ? ` · ${tickets.length - held.length} other ticket(s)` : ""}`;
|
|
80
|
+
}
|
|
67
81
|
// Wind-down window: tasks have settled but the ticket has not flipped to a
|
|
68
82
|
// terminal status yet. "settling" is more honest than "0 subagents".
|
|
69
83
|
if (activeSubagents === 0) {
|
package/ticket-format.ts
CHANGED
|
@@ -50,12 +50,18 @@ export function formatTicketAgentRoster(progress: TaskProgress[]): string {
|
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* Copy-pasteable poll/cancel controls for a live ticket. Cancelling tickets
|
|
53
|
-
* keep poll (to watch unwind) but drop cancel (already requested).
|
|
53
|
+
* keep poll (to watch unwind) but drop cancel (already requested). Pause/
|
|
54
|
+
* resume needs the ticket's pause controller: without one, handlePause
|
|
55
|
+
* rejects the call, so no snippet is advertised.
|
|
54
56
|
*/
|
|
55
57
|
export function formatTicketControlSnippets(ticket: AsyncTicket): string {
|
|
56
58
|
if (ticket.status !== "running" && ticket.status !== "cancelling") return "";
|
|
57
59
|
let snippets = `\n poll: delegate({ ticketAction: "poll", ticket: "${ticket.id}" })`;
|
|
58
60
|
if (ticket.status === "running") {
|
|
61
|
+
if (ticket.pause) {
|
|
62
|
+
const action = ticket.pause.state !== "running" ? "resume" : "pause";
|
|
63
|
+
snippets += `\n ${action}: delegate({ ticketAction: "${action}", ticket: "${ticket.id}" })`;
|
|
64
|
+
}
|
|
59
65
|
snippets += `\n cancel: delegate({ ticketAction: "cancel", ticket: "${ticket.id}", force: true })`;
|
|
60
66
|
}
|
|
61
67
|
return snippets;
|
|
@@ -71,7 +77,11 @@ export function formatTicketRosterLine(
|
|
|
71
77
|
(p) => p.status === "done" || p.status === "failed",
|
|
72
78
|
).length;
|
|
73
79
|
const age = fmtDuration(now - ticket.created);
|
|
74
|
-
|
|
80
|
+
const state =
|
|
81
|
+
ticket.status === "running"
|
|
82
|
+
? (ticket.pause?.state ?? ticket.status)
|
|
83
|
+
: ticket.status;
|
|
84
|
+
return `${icon} ${ticket.id}${formatTicketAgentRoster(ticket.progress)} · ${finalized}/${ticket.progress.length} finalized · ${state} · ${age}${formatTicketControlSnippets(ticket)}`;
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
/** Full roster listing when poll is called without a ticket id. */
|
|
@@ -88,6 +98,8 @@ export function missingTicketPollText(ticketId: string): string {
|
|
|
88
98
|
|
|
89
99
|
/** Running-task line shared by poll snapshots and cancel previews. */
|
|
90
100
|
export function formatInFlightTaskLine(p: TaskProgress): string {
|
|
101
|
+
if (p.paused)
|
|
102
|
+
return `Ⅱ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · paused between turns`;
|
|
91
103
|
const parts: string[] = [formatActivityLabel(p)];
|
|
92
104
|
if (p.toolUses > 0)
|
|
93
105
|
parts.push(`${p.toolUses} tool${p.toolUses === 1 ? "" : "s"}`);
|
|
@@ -183,7 +195,10 @@ export function formatLiveTicketHeader(
|
|
|
183
195
|
).length;
|
|
184
196
|
const totalCount = ticket.progress.length;
|
|
185
197
|
const runningCount = ticket.progress.filter(
|
|
186
|
-
(p) => p.status === "running",
|
|
198
|
+
(p) => p.status === "running" && !p.paused,
|
|
199
|
+
).length;
|
|
200
|
+
const pausedCount = ticket.progress.filter(
|
|
201
|
+
(p) => p.status === "running" && p.paused,
|
|
187
202
|
).length;
|
|
188
203
|
const pendingCount = ticket.progress.filter(
|
|
189
204
|
(p) => p.status === "pending",
|
|
@@ -191,12 +206,15 @@ export function formatLiveTicketHeader(
|
|
|
191
206
|
const totalTools = ticket.progress.reduce((sum, p) => sum + p.toolUses, 0);
|
|
192
207
|
const totalTokens = ticket.progress.reduce((sum, p) => sum + p.tokens, 0);
|
|
193
208
|
const headerStatus =
|
|
194
|
-
ticket.status === "cancelling"
|
|
209
|
+
ticket.status === "cancelling"
|
|
210
|
+
? "CANCELLING"
|
|
211
|
+
: (ticket.pause?.state ?? "running").toUpperCase();
|
|
195
212
|
const headerParts: string[] = [
|
|
196
213
|
`Ticket ${ticket.id}: ${headerStatus}`,
|
|
197
214
|
`${settledCount}/${totalCount} finalized`,
|
|
198
215
|
];
|
|
199
216
|
if (runningCount > 0) headerParts.push(`${runningCount} active`);
|
|
217
|
+
if (pausedCount > 0) headerParts.push(`${pausedCount} paused`);
|
|
200
218
|
if (pendingCount > 0) headerParts.push(`${pendingCount} queued`);
|
|
201
219
|
if (failedCount > 0) headerParts.push(`${failedCount} failed`);
|
|
202
220
|
headerParts.push(`${totalTools} tool${totalTools === 1 ? "" : "s"}`);
|
|
@@ -209,6 +227,9 @@ export function liveTicketGuidance(ticket: AsyncTicket): string {
|
|
|
209
227
|
if (ticket.status === "cancelling") {
|
|
210
228
|
return "Cancellation requested. Active subagents are aborting and returning partial results. Wait without timeoutMs for final status; do not repeatedly poll.";
|
|
211
229
|
}
|
|
230
|
+
if (ticket.pause && ticket.pause.state !== "running") {
|
|
231
|
+
return `Pause requested: current turns may finish, then further turns and queued tasks stay blocked. Resume with delegate({ ticketAction: "resume", ticket: "${ticket.id}" }). Wait does not resume a paused ticket.`;
|
|
232
|
+
}
|
|
212
233
|
const settledCount = ticket.progress.filter(
|
|
213
234
|
(p) => p.status === "done" || p.status === "failed",
|
|
214
235
|
).length;
|
package/tickets.ts
CHANGED
|
@@ -100,6 +100,7 @@ function buildWaitDetails(ticket: AsyncTicket): DelegateDetails {
|
|
|
100
100
|
parentModel: ticket.parentModelId,
|
|
101
101
|
ticketId: ticket.id,
|
|
102
102
|
status: ticket.status,
|
|
103
|
+
pauseState: ticket.status === "running" ? ticket.pause?.state : undefined,
|
|
103
104
|
elapsedMs: (ticket.completedAt ?? Date.now()) - ticket.created,
|
|
104
105
|
overlapWarning: overlapWarning || undefined,
|
|
105
106
|
dispatchWarning: ticket.dispatchWarning,
|
|
@@ -112,6 +113,7 @@ function appendDispatchWarnings(
|
|
|
112
113
|
details: DelegateDetails,
|
|
113
114
|
): string {
|
|
114
115
|
const warnings = [
|
|
116
|
+
details.serializedNotice,
|
|
115
117
|
details.dispatchWarning ? `WARNING: ${details.dispatchWarning}` : undefined,
|
|
116
118
|
details.overlapWarning,
|
|
117
119
|
].filter((warning): warning is string => warning !== undefined);
|
|
@@ -124,15 +126,21 @@ function buildWaitRunningUpdate(
|
|
|
124
126
|
const total = ticket.progress.length;
|
|
125
127
|
const done = ticket.progress.filter((p) => p.status === "done").length;
|
|
126
128
|
const failed = ticket.progress.filter((p) => p.status === "failed").length;
|
|
127
|
-
const running = ticket.progress.filter(
|
|
129
|
+
const running = ticket.progress.filter(
|
|
130
|
+
(p) => p.status === "running" && !p.paused,
|
|
131
|
+
).length;
|
|
132
|
+
const paused = ticket.progress.filter(
|
|
133
|
+
(p) => p.status === "running" && p.paused,
|
|
134
|
+
).length;
|
|
128
135
|
const pending = ticket.progress.filter((p) => p.status === "pending").length;
|
|
129
136
|
const finalized = done + failed;
|
|
130
137
|
|
|
131
138
|
const parts: string[] = [
|
|
132
|
-
`Waiting for ticket ${ticket.id}: ${ticket.status.toUpperCase()}`,
|
|
139
|
+
`Waiting for ticket ${ticket.id}: ${(ticket.status === "running" ? (ticket.pause?.state ?? ticket.status) : ticket.status).toUpperCase()}`,
|
|
133
140
|
];
|
|
134
141
|
parts.push(`${finalized}/${total} finalized`);
|
|
135
142
|
if (running > 0) parts.push(`${running} active`);
|
|
143
|
+
if (paused > 0) parts.push(`${paused} paused`);
|
|
136
144
|
if (failed > 0) parts.push(`${failed} failed`);
|
|
137
145
|
if (pending > 0) parts.push(`${pending} queued`);
|
|
138
146
|
|
|
@@ -603,6 +611,8 @@ export class TicketRegistry extends Map<string, AsyncTicket> {
|
|
|
603
611
|
// (friction #2). The LLM-facing content still names the ticket id too.
|
|
604
612
|
ticketId: ticket.id,
|
|
605
613
|
status: ticket.status,
|
|
614
|
+
pauseState:
|
|
615
|
+
ticket.status === "running" ? ticket.pause?.state : undefined,
|
|
606
616
|
elapsedMs: Date.now() - ticket.created,
|
|
607
617
|
overlapWarning: snapshot.overlapWarning || undefined,
|
|
608
618
|
dispatchWarning: ticket.dispatchWarning,
|
|
@@ -614,6 +624,41 @@ export class TicketRegistry extends Map<string, AsyncTicket> {
|
|
|
614
624
|
return this.formatCompletedTicket(ticket);
|
|
615
625
|
}
|
|
616
626
|
|
|
627
|
+
/** Preview or request cancellation of a running async ticket. */
|
|
628
|
+
handlePause(params: {
|
|
629
|
+
ticket?: string;
|
|
630
|
+
ticketAction: "pause" | "resume";
|
|
631
|
+
}): AgentToolResult<DelegateDetails> {
|
|
632
|
+
const ticket = params.ticket ? this.get(params.ticket) : undefined;
|
|
633
|
+
if (!ticket || ticket.status !== "running" || !ticket.pause) {
|
|
634
|
+
return {
|
|
635
|
+
content: [
|
|
636
|
+
{
|
|
637
|
+
type: "text",
|
|
638
|
+
text: !ticket
|
|
639
|
+
? `Ticket '${params.ticket ?? ""}' not found.`
|
|
640
|
+
: `Cannot ${params.ticketAction} ticket '${ticket.id}': ${ticket.status === "running" ? "pause control unavailable" : `already ${ticket.status}`}.`,
|
|
641
|
+
},
|
|
642
|
+
],
|
|
643
|
+
details: { tasks: [], results: [], progress: [] },
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
ticket.pause[params.ticketAction]();
|
|
647
|
+
console.info(
|
|
648
|
+
`[delegate] ticket '${ticket.id}' ${params.ticketAction} requested: ${ticket.pause.state}`,
|
|
649
|
+
);
|
|
650
|
+
const details = buildWaitDetails(ticket);
|
|
651
|
+
const text = `Ticket ${ticket.id}: ${ticket.pause.state.toUpperCase()}. ${
|
|
652
|
+
params.ticketAction === "pause"
|
|
653
|
+
? "Current turns may finish; no subsequent turn or queued task starts while paused. Resume this ticket to continue. Existing background processes are not frozen; wall-clock deadlines still apply."
|
|
654
|
+
: "Continuing the same live sessions."
|
|
655
|
+
}`;
|
|
656
|
+
return {
|
|
657
|
+
content: [{ type: "text", text: appendDispatchWarnings(text, details) }],
|
|
658
|
+
details,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
617
662
|
/** Preview or request cancellation of a running async ticket. */
|
|
618
663
|
handleCancel(params: {
|
|
619
664
|
ticket?: string;
|
|
@@ -881,6 +926,13 @@ export function handleCancel(params: {
|
|
|
881
926
|
return ticketRegistry.handleCancel(params);
|
|
882
927
|
}
|
|
883
928
|
|
|
929
|
+
export function handlePause(params: {
|
|
930
|
+
ticket?: string;
|
|
931
|
+
ticketAction: "pause" | "resume";
|
|
932
|
+
}): AgentToolResult<DelegateDetails> {
|
|
933
|
+
return ticketRegistry.handlePause(params);
|
|
934
|
+
}
|
|
935
|
+
|
|
884
936
|
export function handleWait(
|
|
885
937
|
params: { ticket?: string; timeoutMs?: number },
|
|
886
938
|
signal: AbortSignal | undefined,
|
package/types.ts
CHANGED
|
@@ -84,6 +84,8 @@ export interface TicketWaiter {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
export interface AsyncTicket {
|
|
87
|
+
/** Orthogonal to lifecycle status: paused tickets remain live reservations. */
|
|
88
|
+
pause?: import("./pause.ts").PauseController;
|
|
87
89
|
id: string;
|
|
88
90
|
created: number;
|
|
89
91
|
completedAt?: number;
|
|
@@ -253,6 +255,12 @@ export type TaskFailureKind =
|
|
|
253
255
|
"cancelled" | "stalled" | "model_error" | "deadline_exceeded";
|
|
254
256
|
|
|
255
257
|
export interface TaskProgress {
|
|
258
|
+
/** Bounded assistant-text tail for the live browser; no private thinking. */
|
|
259
|
+
assistantPreview?: string;
|
|
260
|
+
activity?: string;
|
|
261
|
+
/** Index of the same-call writer this task must follow. */
|
|
262
|
+
waitingFor?: number;
|
|
263
|
+
paused?: boolean;
|
|
256
264
|
id?: string;
|
|
257
265
|
index: number;
|
|
258
266
|
agent: string;
|
|
@@ -278,6 +286,7 @@ export interface TaskProgress {
|
|
|
278
286
|
}
|
|
279
287
|
|
|
280
288
|
export interface DelegateDetails {
|
|
289
|
+
pauseState?: import("./pause.ts").PauseState;
|
|
281
290
|
tasks: TaskDef[];
|
|
282
291
|
results: (TaskResult | { error: string })[];
|
|
283
292
|
progress: TaskProgress[];
|
|
@@ -300,6 +309,8 @@ export interface DelegateDetails {
|
|
|
300
309
|
}
|
|
301
310
|
|
|
302
311
|
export interface TaskResult {
|
|
312
|
+
/** Batch-local ordered-writer group; never suppress incomplete evidence. */
|
|
313
|
+
serializedGroup?: number;
|
|
303
314
|
id?: string;
|
|
304
315
|
agent: string;
|
|
305
316
|
/** Short tag (via `formatResumeTag`) of the transcript this task continued
|
|
@@ -426,6 +437,8 @@ export interface AgentRunConfig {
|
|
|
426
437
|
}
|
|
427
438
|
|
|
428
439
|
export interface AgentProgressUpdate {
|
|
440
|
+
assistantPreview?: string;
|
|
441
|
+
activity?: string;
|
|
429
442
|
tokens: number;
|
|
430
443
|
toolUses: number;
|
|
431
444
|
durationMs: number;
|
|
@@ -436,6 +449,7 @@ export interface AgentProgressUpdate {
|
|
|
436
449
|
}
|
|
437
450
|
|
|
438
451
|
export interface TaskRunEnv {
|
|
452
|
+
pause?: import("./pause.ts").PauseController;
|
|
439
453
|
/** Abort signal — parent's for sync, ticket's for async. May be undefined when no parent signal is available. */
|
|
440
454
|
signal: AbortSignal | undefined;
|
|
441
455
|
modelRegistry: ModelRegistry;
|