@bermudi/pi-delegate 0.1.2 → 0.1.4

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/format.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  TaskProgress,
8
8
  TaskResult,
9
9
  ToolActivity,
10
+ WorkspaceMode,
10
11
  } from "./types.ts";
11
12
 
12
13
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -517,7 +518,10 @@ export function relativeTouchedSummary(
517
518
  * in the same repository do not fabricate false conflicts from shared
518
519
  * repository-wide git snapshots. */
519
520
  export function findTouchedOverlaps(
520
- results: readonly { attributedFiles?: string[] }[],
521
+ results: readonly {
522
+ attributedFiles?: string[];
523
+ workspace?: WorkspaceMode;
524
+ }[],
521
525
  ): string[] {
522
526
  const counts = new Map<string, number>();
523
527
  for (const r of results) {
package/host-compat.ts CHANGED
@@ -23,6 +23,7 @@ const REQUIRED_EXPORTS: ExportCheck[] = [
23
23
  { name: "SettingsManager", requiredMember: "create" },
24
24
  { name: "SessionManager", requiredMember: "create" },
25
25
  { name: "SessionManager", requiredMember: "open" },
26
+ { name: "SessionManager", requiredMember: "inMemory" },
26
27
  { name: "DefaultResourceLoader" },
27
28
  { name: "DefaultPackageManager" },
28
29
  { name: "createAgentSession" },