@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/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
- "Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
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 'wait'.";
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 poll/cancel/wait). */
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
- return `${icon} ${ticket.id}${formatTicketAgentRoster(ticket.progress)} · ${finalized}/${ticket.progress.length} finalized · ${ticket.status} · ${age}${formatTicketControlSnippets(ticket)}`;
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" ? "CANCELLING" : "RUNNING";
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((p) => p.status === "running").length;
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;