@bermudi/pi-delegate 0.1.2 → 0.1.3

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/extension.ts CHANGED
@@ -30,10 +30,87 @@ import {
30
30
  notifyActiveTicketsOnSettled,
31
31
  syncDelegateStatus,
32
32
  } from "./status.ts";
33
+ import {
34
+ beginCall,
35
+ closeTelemetry,
36
+ getTelemetryGeneration,
37
+ prepareTelemetryForSession,
38
+ sealTelemetryWrites,
39
+ } from "./telemetry.ts";
33
40
  import type { DelegateArguments } from "./types.ts";
34
41
 
42
+ const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS = 10_000;
43
+ let shutdownDrainTimeoutMs = DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
44
+
45
+ type ShutdownDrainResult =
46
+ { drained: true; failures: unknown[] } | { drained: false; failures: [] };
47
+
48
+ /** Wait for shutdown workers without allowing a stuck provider/tool to hold
49
+ * Pi's reload or exit hostage. The allSettled promise is intentionally left
50
+ * attached after timeout so a late rejection cannot become unhandled. */
51
+ export async function drainAsyncTickets(
52
+ completions: readonly Promise<void>[],
53
+ timeoutMs = shutdownDrainTimeoutMs,
54
+ ): Promise<ShutdownDrainResult> {
55
+ if (!completions.length) return { drained: true, failures: [] };
56
+
57
+ const drain = Promise.allSettled(completions).then((results) => ({
58
+ drained: true as const,
59
+ failures: results
60
+ .filter(
61
+ (result): result is PromiseRejectedResult =>
62
+ result.status === "rejected",
63
+ )
64
+ .map((result) => result.reason),
65
+ }));
66
+
67
+ let timer: ReturnType<typeof setTimeout> | undefined;
68
+ const timeout = new Promise<ShutdownDrainResult>((resolve) => {
69
+ timer = setTimeout(
70
+ () => resolve({ drained: false, failures: [] }),
71
+ timeoutMs,
72
+ );
73
+ });
74
+ const result = await Promise.race([drain, timeout]);
75
+ if (timer !== undefined) clearTimeout(timer);
76
+ return result;
77
+ }
78
+
79
+ /** Wait for another shutdown cleanup operation without allowing a stuck
80
+ * session lock to hold reload/quit forever. Rejection is consumed here; the
81
+ * caller reports it when it wins the race, and a late rejection stays handled.
82
+ */
83
+ async function boundedShutdownCleanup(
84
+ cleanup: Promise<void>,
85
+ timeoutMs = shutdownDrainTimeoutMs,
86
+ ): Promise<"settled" | "timed-out" | "failed"> {
87
+ let timer: ReturnType<typeof setTimeout> | undefined;
88
+ const outcome = cleanup.then(
89
+ () => "settled" as const,
90
+ () => "failed" as const,
91
+ );
92
+ const timeout = new Promise<"timed-out">((resolve) => {
93
+ timer = setTimeout(() => resolve("timed-out"), timeoutMs);
94
+ });
95
+ const result = await Promise.race([outcome, timeout]);
96
+ if (timer !== undefined) clearTimeout(timer);
97
+ return result;
98
+ }
99
+
100
+ /** @internal Test-only timeout override. */
101
+ export function _setShutdownDrainTimeoutForTesting(
102
+ timeoutMs: number | undefined,
103
+ ): void {
104
+ shutdownDrainTimeoutMs = timeoutMs ?? DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
105
+ }
106
+
35
107
  /** Register the delegate tool and clean up its parent-session resources. */
36
108
  export default function delegateExtension(pi: ExtensionAPI): void {
109
+ // A /reload can reuse this module instance after the previous runtime closed
110
+ // its SQLite handle. Permit the new runtime to open a fresh backend; stale
111
+ // workers from the old runtime remain blocked from reopening it.
112
+ prepareTelemetryForSession();
113
+
37
114
  pi.registerTool({
38
115
  name: "delegate",
39
116
  label: "Delegate to Subagents",
@@ -45,23 +122,70 @@ export default function delegateExtension(pi: ExtensionAPI): void {
45
122
  prepareArguments: normalizeDelegateArguments,
46
123
 
47
124
  async execute(_id, params: DelegateArguments, signal, onUpdate, ctx) {
125
+ const parentModelId = ctx.model?.id;
126
+ const tasks = params.tasks ?? [];
127
+ const parentSessionFile = (
128
+ ctx as { sessionManager?: { getSessionFile?(): string | undefined } }
129
+ ).sessionManager?.getSessionFile?.();
130
+
131
+ let mode: string;
132
+ if (params.ticketAction) {
133
+ mode = params.ticketAction;
134
+ } else if (tasks.length === 0) {
135
+ mode = "manual";
136
+ } else if (params.async) {
137
+ mode = "async";
138
+ } else {
139
+ mode = "sync";
140
+ }
141
+ const taskCount = params.ticketAction ? 0 : tasks.length;
142
+ const callSpan = beginCall({
143
+ parentModel: parentModelId,
144
+ mode,
145
+ taskCount,
146
+ parentSessionFile,
147
+ });
148
+
149
+ function failCall(): void {
150
+ callSpan.finish({
151
+ status: "failed",
152
+ totalTokens: 0,
153
+ totalCost: 0,
154
+ wallMs: Date.now() - callSpan.startedAt,
155
+ });
156
+ }
157
+
158
+ function succeedCall(): void {
159
+ callSpan.finish({
160
+ status: "success",
161
+ totalTokens: 0,
162
+ totalCost: 0,
163
+ wallMs: Date.now() - callSpan.startedAt,
164
+ });
165
+ }
166
+
48
167
  // Guard against pi dropping/renaming a symbol this extension imports
49
168
  // before any operation-specific validation or early return.
50
169
  const compatError = hostCompatError();
51
- if (compatError) return compatError;
52
-
53
- const parentModelId = ctx.model?.id;
54
- const tasks = params.tasks ?? [];
170
+ if (compatError) {
171
+ failCall();
172
+ return compatError;
173
+ }
55
174
 
56
175
  const operationResult = validateDelegateOperationResult(
57
176
  params,
58
177
  parentModelId,
59
178
  );
60
- if (operationResult) return operationResult;
179
+ if (operationResult) {
180
+ failCall();
181
+ return operationResult;
182
+ }
61
183
 
62
184
  // ── Poll action ───────────────────────────────────────────────────
63
185
  if (params.ticketAction === "poll") {
64
- return handlePoll(params, ctx);
186
+ const result = handlePoll(params, ctx);
187
+ succeedCall();
188
+ return result;
65
189
  }
66
190
 
67
191
  // ── Cancel action ─────────────────────────────────────────────────
@@ -70,12 +194,20 @@ export default function delegateExtension(pi: ExtensionAPI): void {
70
194
  // A forced cancel flips the ticket to "cancelling" — keep the
71
195
  // footer status in step (deduped; the preview path is a no-op).
72
196
  syncDelegateStatus(ctx);
197
+ succeedCall();
73
198
  return result;
74
199
  }
75
200
 
76
201
  // ── Wait action ────────────────────────────────────────────────────
77
202
  if (params.ticketAction === "wait") {
78
- return handleWait(params, signal, onUpdate, ctx);
203
+ try {
204
+ const result = await handleWait(params, signal, onUpdate, ctx);
205
+ succeedCall();
206
+ return result;
207
+ } catch (err) {
208
+ failCall();
209
+ throw err;
210
+ }
79
211
  }
80
212
 
81
213
  // Agent discovery is intentionally parent-cwd-scoped: agent profiles are a
@@ -87,6 +219,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
87
219
 
88
220
  // ── Help mode ─────────────────────────────────────────────────
89
221
  if (!tasks.length) {
222
+ succeedCall();
90
223
  return {
91
224
  content: [{ type: "text", text: getSubagentManualMarkdown(agents) }],
92
225
  details: {
@@ -108,25 +241,38 @@ export default function delegateExtension(pi: ExtensionAPI): void {
108
241
  // across dispatches: edits to auth/models/settings/context files must be
109
242
  // visible without restarting Pi.
110
243
  invalidateHostDepsCache();
111
- return dispatchDelegate({
112
- pi,
113
- params,
114
- ctx,
115
- agents,
116
- parentModelId,
117
- parentDefaults: {
118
- thinking: pi.getThinkingLevel(),
119
- tools: pi.getActiveTools(),
120
- },
121
- signal,
122
- onUpdate,
123
- });
244
+ try {
245
+ return await dispatchDelegate({
246
+ pi,
247
+ params,
248
+ ctx,
249
+ agents,
250
+ parentModelId,
251
+ parentDefaults: {
252
+ thinking: pi.getThinkingLevel(),
253
+ tools: pi.getActiveTools(),
254
+ },
255
+ signal,
256
+ onUpdate,
257
+ callSpan,
258
+ });
259
+ } catch (err) {
260
+ failCall();
261
+ throw err;
262
+ }
124
263
  },
125
264
 
126
265
  renderCall: renderDelegateCall,
127
266
  renderResult: renderDelegateResult,
128
267
  });
129
268
 
269
+ // A reload may rebuild the extension runtime without re-invoking this
270
+ // module's default export. Re-open telemetry only after the prior shutdown
271
+ // handler has drained old workers and closed its connection.
272
+ pi.on("session_start", () => {
273
+ prepareTelemetryForSession();
274
+ });
275
+
130
276
  // ── Background-work visibility (see status.ts) ──────────────────────────
131
277
  // The turn settling with live tickets is the "looks idle but isn't" moment:
132
278
  // warn once per ticket. The footer status carries it from there.
@@ -155,52 +301,137 @@ export default function delegateExtension(pi: ExtensionAPI): void {
155
301
 
156
302
  // ── Session shutdown: abort tickets and dispose live pooled sessions ──
157
303
  pi.on("session_shutdown", async (event, ctx) => {
158
- // Quit and /reload kill background work with no cancellable hook, so
159
- // leave a trace. For quit the TUI is already stopped — stderr lands in
160
- // the scrollback. For reload the TUI survives — warn in place. Switch
161
- // and fork already passed the confirm guard above.
162
- const active = activeTicketSummary();
163
- if (active.tickets.length) {
164
- if (event.reason === "quit") {
304
+ const shutdownGeneration = getTelemetryGeneration();
305
+ const shutdownDeadline = Date.now() + shutdownDrainTimeoutMs;
306
+ const ticketCompletions: Promise<void>[] = [];
307
+ let drainAttempted = false;
308
+ let telemetrySealed = false;
309
+
310
+ try {
311
+ // Quit and /reload kill background work with no cancellable hook, so
312
+ // leave a trace. For quit the TUI is already stopped — stderr lands in
313
+ // the scrollback. For reload the TUI survives — warn in place. Switch
314
+ // and fork already passed the confirm guard above.
315
+ const active = activeTicketSummary();
316
+ if (active.tickets.length) {
317
+ if (event.reason === "quit") {
318
+ console.error(
319
+ `[delegate] pi exited with ${describeActiveTickets(active)} — aborted.`,
320
+ );
321
+ } else if (event.reason === "reload") {
322
+ try {
323
+ ctx.ui.notify(
324
+ `[delegate] reload aborted ${describeActiveTickets(active)}`,
325
+ "warning",
326
+ );
327
+ } catch (error) {
328
+ console.error(
329
+ "[delegate] reload shutdown notification failed",
330
+ error,
331
+ );
332
+ }
333
+ }
334
+ }
335
+
336
+ for (const ticket of ticketRegistry.values()) {
337
+ if (ticket.status === "running" || ticket.status === "cancelling") {
338
+ cancelTicketForShutdown(ticket);
339
+ }
340
+ // Include already-cancelled tickets too: a repeated shutdown event can
341
+ // race the first handler while its workers are still unwinding.
342
+ if (ticket.completion) ticketCompletions.push(ticket.completion);
343
+ }
344
+ syncDelegateStatus(ctx);
345
+ // The runtime is invalidated right after this handler returns; aborted
346
+ // tickets keep unwinding asynchronously and must find no cached ctx (or
347
+ // captured pi) to touch. The cancelled completion path still writes one
348
+ // final aggregate after late task results arrive, but never delivers UI.
349
+ clearDelegateStatusContext();
350
+ // A replacement session starts on its own leaf; stale tracking would make
351
+ // every ticket look cross-leaf (or, worse, look same-leaf by accident).
352
+ resetLeafTracking();
353
+ // Do NOT clear the ticket registry here — completed tickets are retained
354
+ // until their TTL cleanup. Pooled AgentSessions, however, own listeners
355
+ // and must be disposed before the parent session exits.
356
+ //
357
+ // Start pooled cleanup before waiting for ticket completion. Its immediate
358
+ // abort requests can help a worker blocked on a pooled session unwind;
359
+ // SQLite still stays open until both cleanup paths finish.
360
+ let poolCleanup: Promise<void>;
361
+ try {
362
+ poolCleanup = closeAllPooledAgents();
363
+ } catch (error) {
364
+ console.error("[delegate] pooled-session shutdown start failed", error);
365
+ poolCleanup = Promise.resolve();
366
+ }
367
+ // Drain cooperatively, but never let a provider/tool hold reload or
368
+ // quit forever. A timed-out old runtime is sealed before a new one can
369
+ // reopen telemetry; its eventual task completion remains harmless.
370
+ drainAttempted = true;
371
+ const drain = await drainAsyncTickets(
372
+ ticketCompletions,
373
+ Math.max(0, shutdownDeadline - Date.now()),
374
+ );
375
+ if (!drain.drained) {
165
376
  console.error(
166
- `[delegate] pi exited with ${describeActiveTickets(active)} aborted.`,
377
+ `[delegate] async-ticket shutdown drain exceeded ${shutdownDrainTimeoutMs}ms; continuing without late results`,
167
378
  );
168
- } else if (event.reason === "reload") {
169
- ctx.ui.notify(
170
- `[delegate] reload aborted ${describeActiveTickets(active)}`,
171
- "warning",
379
+ // Seal immediately, before waiting on pooled-session cleanup. A worker
380
+ // that finishes during that second phase belongs to the old runtime
381
+ // and must not write into a freshly reopened backend.
382
+ telemetrySealed = sealTelemetryWrites(shutdownGeneration);
383
+ } else if (drain.failures.length) {
384
+ console.error(
385
+ "[delegate] async-ticket cleanup failed during shutdown:",
386
+ drain.failures,
172
387
  );
173
388
  }
174
- }
175
- for (const ticket of ticketRegistry.values()) {
176
- cancelTicketForShutdown(ticket);
177
- }
178
- syncDelegateStatus(ctx);
179
- // The runtime is invalidated right after this handler returns; aborted
180
- // tickets keep unwinding asynchronously and must find no cached ctx (or
181
- // captured pi) to touch. See the "cancelled"-at-entry guard in dispatch.
182
- clearDelegateStatusContext();
183
- // A replacement session starts on its own leaf; stale tracking would make
184
- // every ticket look cross-leaf (or, worse, look same-leaf by accident).
185
- resetLeafTracking();
186
- // Do NOT clear the ticket registry here — completed tickets are retained
187
- // until their TTL cleanup. Pooled AgentSessions, however, own listeners
188
- // and must be disposed before the parent session exits.
189
- //
190
- // closeAllPooledAgents attempts every session (Promise.allSettled) before
191
- // aggregating failures into an AggregateError, so swallowing here does not
192
- // abandon remaining cleanup. Catch and log so a wedged session's failure
193
- // stays observable instead of becoming an unhandled rejection — pi.on is
194
- // EventEmitter-style and does not surface handler rejections — and so
195
- // shutdown completes even when one pooled session failed to abort/dispose.
196
- try {
197
- await closeAllPooledAgents();
198
- } catch (error) {
199
- const failures = error instanceof AggregateError ? error.errors : [error];
200
- console.error(
201
- "[delegate] pooled-session cleanup failed during shutdown:",
202
- failures,
389
+
390
+ // closeAllPooledAgents attempts every session (Promise.allSettled) before
391
+ // aggregating failures into an AggregateError, so swallowing here does
392
+ // not abandon remaining cleanup.
393
+ const poolResult = await boundedShutdownCleanup(
394
+ poolCleanup,
395
+ Math.max(0, shutdownDeadline - Date.now()),
203
396
  );
397
+ if (poolResult === "timed-out") {
398
+ console.error(
399
+ `[delegate] pooled-session shutdown cleanup exceeded ${shutdownDrainTimeoutMs}ms; continuing`,
400
+ );
401
+ } else if (poolResult === "failed") {
402
+ // closeAllPooledAgents aggregates every session failure before
403
+ // rejecting, so the rejection is already a complete diagnostic.
404
+ try {
405
+ await poolCleanup;
406
+ } catch (error) {
407
+ const failures =
408
+ error instanceof AggregateError ? error.errors : [error];
409
+ console.error(
410
+ "[delegate] pooled-session cleanup failed during shutdown:",
411
+ failures,
412
+ );
413
+ }
414
+ }
415
+ } catch (error) {
416
+ // pi.on is EventEmitter-style and does not surface handler rejections.
417
+ // Keep shutdown moving, but leave the failure visible.
418
+ console.error("[delegate] shutdown cleanup failed", error);
419
+ } finally {
420
+ if (!drainAttempted) {
421
+ // An unexpected earlier cleanup failure must not leave a worker with an
422
+ // unhandled rejection, but this fallback is bounded too.
423
+ await drainAsyncTickets(
424
+ ticketCompletions,
425
+ Math.max(0, shutdownDeadline - Date.now()),
426
+ );
427
+ }
428
+ // Once the bounded drain expires, old workers may still unwind. Seal
429
+ // their generation before closing SQLite so a later runtime cannot
430
+ // receive stale task/call writes.
431
+ if (!telemetrySealed) {
432
+ telemetrySealed = sealTelemetryWrites(shutdownGeneration);
433
+ }
434
+ closeTelemetry(shutdownGeneration);
204
435
  }
205
436
  });
206
437
  }
package/lifecycle.ts CHANGED
@@ -27,6 +27,7 @@ import { resolveCwd, validateResumeFromPath } from "./utils.ts";
27
27
  import { getWholeTaskMaxRetries, getWholeTaskBaseDelayMs } from "./config.ts";
28
28
  import { addUsage, emptyUsage } from "./usage.ts";
29
29
  import { scheduleDeadline } from "./timer.ts";
30
+ import { recordTask } from "./telemetry.ts";
30
31
 
31
32
  /** Internal seam for lifecycle-level tests without replacing session ownership. */
32
33
  type RunAgentSession = typeof runAgentSession;
@@ -187,8 +188,22 @@ function finishTask(
187
188
  env: TaskRunEnv,
188
189
  p: TaskProgress,
189
190
  r: TaskResult,
191
+ task: ResolvedTask,
192
+ retries = 0,
190
193
  ): TaskResult {
191
194
  updateProgressFromResult(p, r);
195
+ if (env.telemetryCallId) {
196
+ recordTask({
197
+ callId: env.telemetryCallId,
198
+ generation: env.telemetryGeneration,
199
+ async: env.async ?? false,
200
+ taskIndex: p.index,
201
+ task,
202
+ progress: p,
203
+ result: r,
204
+ retries,
205
+ });
206
+ }
192
207
  env.onStatusChange?.();
193
208
  return r;
194
209
  }
@@ -553,7 +568,7 @@ async function runResolvedTaskUnlocked(
553
568
  try {
554
569
  // ── Aborted before we started? ───────────────────────────────────
555
570
  if (env.signal?.aborted) {
556
- return finishTask(env, p, failTask(task, "Aborted"));
571
+ return finishTask(env, p, failTask(task, "Aborted"), task);
557
572
  }
558
573
 
559
574
  // ── Session busy guard (defense-in-depth) ────────────────────────
@@ -563,7 +578,7 @@ async function runResolvedTaskUnlocked(
563
578
  const busyTicketId = isSessionBusy(task.sessionId);
564
579
  if (busyTicketId && busyTicketId !== env.ticketId) {
565
580
  const msg = `Session '${task.sessionId}' is already in use by ticket ${busyTicketId}. Each session can only handle one task at a time.`;
566
- return finishTask(env, p, failTask(task, msg));
581
+ return finishTask(env, p, failTask(task, msg), task);
567
582
  }
568
583
  }
569
584
 
@@ -577,6 +592,7 @@ async function runResolvedTaskUnlocked(
577
592
  env,
578
593
  p,
579
594
  failTask(task, "sessionAction='close' requires sessionId."),
595
+ task,
580
596
  );
581
597
  }
582
598
  // The per-session lock for action-based operations is already held by the
@@ -593,6 +609,7 @@ async function runResolvedTaskUnlocked(
593
609
  : `Session '${task.sessionId}' not found.`,
594
610
  Date.now() - env.delegateStartedAt,
595
611
  ),
612
+ task,
596
613
  );
597
614
  }
598
615
 
@@ -605,6 +622,7 @@ async function runResolvedTaskUnlocked(
605
622
  `Active sessions:\n${pool.listPooledAgents().join("\n")}`,
606
623
  Date.now() - env.delegateStartedAt,
607
624
  ),
625
+ task,
608
626
  );
609
627
  }
610
628
 
@@ -821,16 +839,22 @@ async function runResolvedTaskUnlocked(
821
839
  task,
822
840
  err instanceof Error ? err.message : String(err),
823
841
  );
824
- return finishTask(env, p, {
825
- ...failure,
826
- durationMs: Math.max(failure.durationMs, Date.now() - taskStartedAt),
827
- tokens: accumulatedUsage.totalTokens,
828
- usage: accumulatedUsage,
829
- });
842
+ return finishTask(
843
+ env,
844
+ p,
845
+ {
846
+ ...failure,
847
+ durationMs: Math.max(failure.durationMs, Date.now() - taskStartedAt),
848
+ tokens: accumulatedUsage.totalTokens,
849
+ usage: accumulatedUsage,
850
+ },
851
+ task,
852
+ );
830
853
  }
831
854
 
832
855
  const maxRetries = resolvedWholeTaskMaxRetries();
833
856
  const baseDelayMs = resolvedWholeTaskBaseDelayMs();
857
+ let retriesExecuted = 0;
834
858
  for (
835
859
  let retry = 0;
836
860
  retry < maxRetries && canRetryWholeTask(task, result, hasBashExecution);
@@ -864,6 +888,7 @@ async function runResolvedTaskUnlocked(
864
888
  p.error = undefined;
865
889
  p.failureKind = undefined;
866
890
  env.onStatusChange?.();
891
+ retriesExecuted++;
867
892
  try {
868
893
  result = await runAttempt();
869
894
  } catch (err) {
@@ -881,12 +906,18 @@ async function runResolvedTaskUnlocked(
881
906
  }
882
907
  }
883
908
 
884
- return finishTask(env, p, {
885
- ...result,
886
- durationMs: Math.max(result.durationMs, Date.now() - taskStartedAt),
887
- tokens: Math.max(cumulativeTokens, accumulatedUsage.totalTokens),
888
- usage: accumulatedUsage,
889
- });
909
+ return finishTask(
910
+ env,
911
+ p,
912
+ {
913
+ ...result,
914
+ durationMs: Math.max(result.durationMs, Date.now() - taskStartedAt),
915
+ tokens: Math.max(cumulativeTokens, accumulatedUsage.totalTokens),
916
+ usage: accumulatedUsage,
917
+ },
918
+ task,
919
+ retriesExecuted,
920
+ );
890
921
  } catch (err) {
891
922
  // Any acquired session is released by runAttempt's finally before an
892
923
  // exception reaches this boundary. This outer catch handles unexpected
@@ -896,6 +927,7 @@ async function runResolvedTaskUnlocked(
896
927
  env,
897
928
  p,
898
929
  failTask(task, err instanceof Error ? err.message : String(err)),
930
+ task,
899
931
  );
900
932
  }
901
933
  }
package/manual.ts CHANGED
@@ -185,8 +185,9 @@ export function getSubagentManualMarkdown(
185
185
  "```",
186
186
  "",
187
187
  '- `delegate({ ticketAction: "poll" })` \u2014 list all tickets',
188
- '- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014 check one ticket',
189
- '- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 block until finished or timeout',
188
+ '- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014 take one progress snapshot',
189
+ '- `delegate({ ticketAction: "wait", ticket: "abc123" })` \u2014 block until finished; omit `timeoutMs` when the result is needed this turn',
190
+ '- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 bounded wait; timeout includes the latest snapshot, so do not poll afterward',
190
191
  '- `delegate({ ticketAction: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
191
192
  '- `delegate({ ticketAction: "cancel", ticket: "abc123", force: true })` \u2014 abort after review',
192
193
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
package/schema.ts CHANGED
@@ -139,7 +139,7 @@ export const delegateArgumentsSchema = Type.Object({
139
139
  Type.Number({
140
140
  minimum: 0,
141
141
  description:
142
- "Bounds wait (ms); omit to block until settled. Timeout never cancels the ticket.",
142
+ "Bounds wait (ms); omit to block until settled. Timeout returns a snapshot; do not poll afterward.",
143
143
  }),
144
144
  ),
145
145
  tasks: Type.Optional(