@bermudi/pi-delegate 0.1.0

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/runner.ts ADDED
@@ -0,0 +1,686 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type {
3
+ AgentSession,
4
+ AgentSessionEvent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ getGitChangedFiles,
8
+ extractTouchedFromActivities,
9
+ } from "./file-tracking.ts";
10
+ import { extractTextFromPartialResult, extractOutput } from "./utils.ts";
11
+ import { snapshotSessionUsage, usageDelta, emptyUsage } from "./usage.ts";
12
+ import { getStallTimeoutMs } from "./config.ts";
13
+ import { fmtDuration } from "./format.ts";
14
+ import { scheduleDeadline } from "./timer.ts";
15
+ import type { Usage } from "@earendil-works/pi-ai";
16
+ import type {
17
+ AgentProgressUpdate,
18
+ TaskFailureKind,
19
+ ToolActivity,
20
+ } from "./types.ts";
21
+
22
+ /**
23
+ * Run a single prompt against a live `AgentSession` and report progress.
24
+ *
25
+ * This replaces the old `createAgent` / `runAgentOnce` / `runAgent` stack.
26
+ * The `AgentSession` owns the model-call loop, per-message auto-retry
27
+ * (strip-bad-message + `agent.continue()`), compaction, overflow recovery, and
28
+ * session persistence — so this function only:
29
+ * 1. subscribes to the session's event stream and maps it to the renderer's
30
+ * `AgentProgressUpdate` / `ToolActivity` shapes,
31
+ * 2. wires the parent abort signal to `session.abort()`,
32
+ * 3. waits for extension-started post-run compaction/continuations to become
33
+ * quiescent before returning ownership to lifecycle,
34
+ * 4. snapshots usage before/after the prompt for token delta accounting, and
35
+ * 5. computes touched files from activity + git diff.
36
+ *
37
+ * Output is captured from AgentSession events so compaction cannot erase it
38
+ * before collection; `session.messages` is only a guarded fallback for
39
+ * providers that fail before emitting message_end. AgentSession's internal
40
+ * retry means a transient mid-loop error no longer hard-fails a task whose
41
+ * work is already done.
42
+ */
43
+ export async function runAgentSession(
44
+ session: AgentSession,
45
+ prompt: string,
46
+ config: { cwd: string },
47
+ signal: AbortSignal | undefined,
48
+ onProgress: ((update: AgentProgressUpdate) => void) | undefined,
49
+ gitBaseline: Set<string>,
50
+ start: number,
51
+ ): Promise<{
52
+ output: string;
53
+ error?: string;
54
+ durationMs: number;
55
+ tokens: number;
56
+ usage: Usage;
57
+ touchedFiles: string[];
58
+ failureKind?: TaskFailureKind;
59
+ }> {
60
+ const startTime = start ?? Date.now();
61
+ const stallTimeoutMs = getStallTimeoutMs();
62
+ let toolUses = 0;
63
+ let lastActivityAt: number | undefined = startTime;
64
+ let phase = "starting agent";
65
+ let stalled = false;
66
+ let stalledPhase: string | undefined;
67
+ let clearStallDeadline: (() => void) | undefined;
68
+ const activities: ToolActivity[] = [];
69
+ const pendingById = new Map<string, ToolActivity>();
70
+ let sessionEventGeneration = 0;
71
+ let wakeSessionEvent: (() => void) | undefined;
72
+
73
+ // AgentSession.prompt() can return while an agent_settled extension callback
74
+ // is still running fire-and-forget work through ctx.compact(). Keep a small
75
+ // internal event seam so the runner can wait without polling throughout a
76
+ // potentially long remote compaction. The generation closes the race between
77
+ // checking session state and installing the next waiter.
78
+ let abortRequestedGeneration = -1;
79
+ const noteSessionEvent = () => {
80
+ sessionEventGeneration++;
81
+ const wake = wakeSessionEvent;
82
+ wakeSessionEvent = undefined;
83
+ wake?.();
84
+ };
85
+ const waitForSessionEventAfter = async (
86
+ generation: number,
87
+ ): Promise<void> => {
88
+ if (sessionEventGeneration !== generation) return;
89
+ await new Promise<void>((resolve) => {
90
+ let settled = false;
91
+ const probe = setTimeout(() => finish(), 250);
92
+ const finish = () => {
93
+ if (settled) return;
94
+ settled = true;
95
+ clearTimeout(probe);
96
+ if (wakeSessionEvent === finish) wakeSessionEvent = undefined;
97
+ resolve();
98
+ };
99
+ if (sessionEventGeneration !== generation) {
100
+ finish();
101
+ return;
102
+ }
103
+ // AgentSession normally emits every transition we care about. The slow
104
+ // probe is a liveness fallback for host versions that clear an internal
105
+ // busy flag without a corresponding public event.
106
+ wakeSessionEvent = finish;
107
+ });
108
+ };
109
+ const nextEventLoopTurn = () =>
110
+ new Promise<void>((resolve) => setImmediate(resolve));
111
+
112
+ const requestSessionCancellation = (
113
+ source: "parent-aborted" | "stalled",
114
+ ): void => {
115
+ const logFailure = (operation: string, error: unknown) => {
116
+ console.error(`[delegate] ${source} subagent ${operation} failed`, error);
117
+ };
118
+ try {
119
+ session.abortCompaction();
120
+ } catch (error) {
121
+ logFailure("compaction cancellation", error);
122
+ }
123
+ try {
124
+ session.abortBranchSummary();
125
+ } catch (error) {
126
+ logFailure("branch-summary cancellation", error);
127
+ }
128
+ noteSessionEvent();
129
+ // Fire the abort before recording the generation. session.abort() may
130
+ // synchronously emit events (e.g. a final message_update as the stream
131
+ // unwinds) that increment the generation. Recording after the call
132
+ // ensures those abort-caused events are accounted for, so the quiescence
133
+ // barrier's re-abort check doesn't loop on the abort's own events.
134
+ void session.abort().catch((error: unknown) => {
135
+ logFailure("agent cancellation", error);
136
+ });
137
+ // Record the generation after abort is dispatched. If new session events
138
+ // fire after this point (e.g. a continuation prompt started by an
139
+ // extension's onComplete callback delayed by async auth), the quiescence
140
+ // barrier re-aborts to cancel that continuation rather than letting it run
141
+ // — and potentially mutate files — after the task is considered cancelled.
142
+ abortRequestedGeneration = sessionEventGeneration;
143
+ };
144
+
145
+ /**
146
+ * Wait until the session is idle and non-compacting for two unchanged event
147
+ * loop turns. The quiet turns are significant: AgentSession emits
148
+ * compaction_end before ctx.compact's onComplete/onError callback runs, and a
149
+ * successful callback may immediately start a continuation prompt.
150
+ *
151
+ * Cancellation re-abort: if the task has been cancelled (parent abort or
152
+ * stall), any session event after the last cancellation request means new
153
+ * work started — typically a continuation from an extension's onComplete
154
+ * callback, possibly delayed by async auth. The barrier re-aborts to cancel
155
+ * it. This check runs *before* the idle check so a fast continuation that
156
+ * already completed between samples is still caught: the generation changed
157
+ * even though the session is idle again, and re-abort resets the tracker to
158
+ * catch any further continuations.
159
+ *
160
+ * Cancelled grace period: when cancelled and idle, a single 50 ms wait is
161
+ * required before quiet turns can accumulate. Without it, a continuation
162
+ * delayed by async auth or another extension handler can start after the
163
+ * runner returns — the two event-loop turns pass in microseconds, far faster
164
+ * than any realistic async-auth gap. This is a **mitigation, not a
165
+ * deterministic fix**: a deterministic solution would require the host to
166
+ * expose pending extension work (e.g. `AgentSession.hasPendingExtensionWork()`)
167
+ * so the barrier could wait on it explicitly. The grace period adds at most
168
+ * one 50 ms wait per cancellation/re-abort cycle — re-aborts reset the
169
+ * `graceWaited` flag, so a sequence of delayed continuations can cause
170
+ * multiple grace waits.
171
+ */
172
+ const waitForSessionQuiescence = async (): Promise<void> => {
173
+ const cancelledGraceMs = 50;
174
+ const cancellationRequested = () => signal?.aborted || stalled;
175
+ const cancellationSource = (): "parent-aborted" | "stalled" =>
176
+ signal?.aborted ? "parent-aborted" : "stalled";
177
+ let quietTurns = 0;
178
+ let graceWaited = false;
179
+ while (quietTurns < 2) {
180
+ const generation = sessionEventGeneration;
181
+ await nextEventLoopTurn();
182
+
183
+ // Re-abort if new activity started after the last cancellation request.
184
+ // This runs before the idle check so a fast continuation that completed
185
+ // between samples (generation changed, session idle again) is still
186
+ // caught. After re-aborting, restart the loop to recompute isIdle/
187
+ // isCompacting rather than falling through to the 250 ms event probe.
188
+ if (
189
+ cancellationRequested() &&
190
+ abortRequestedGeneration >= 0 &&
191
+ sessionEventGeneration !== abortRequestedGeneration
192
+ ) {
193
+ requestSessionCancellation(cancellationSource());
194
+ quietTurns = 0;
195
+ graceWaited = false;
196
+ continue;
197
+ }
198
+
199
+ const idle = session.isIdle && !session.isCompacting;
200
+ if (idle && sessionEventGeneration === generation) {
201
+ if (cancellationRequested() && !graceWaited) {
202
+ // Wait once for a grace period before accepting quiet turns. A
203
+ // continuation delayed by async auth can start after the immediate
204
+ // microtask batch settles. This is a single setTimeout, not a
205
+ // busy-spin — the event loop is free to process the continuation's
206
+ // events during the wait.
207
+ graceWaited = true;
208
+ await new Promise<void>((resolve) =>
209
+ setTimeout(resolve, cancelledGraceMs),
210
+ );
211
+ continue;
212
+ }
213
+ quietTurns++;
214
+ continue;
215
+ }
216
+
217
+ quietTurns = 0;
218
+ graceWaited = false;
219
+ if (!idle) {
220
+ await waitForSessionEventAfter(sessionEventGeneration);
221
+ }
222
+ }
223
+ };
224
+
225
+ // Snapshot cumulative usage before the prompt so we can report only the
226
+ // tokens consumed by this call (not cumulative history on a pooled/resumed
227
+ // session). Cumulative session stats (including compacted-away history) are the only
228
+ // stable accounting boundary for pooled/resumed sessions. The transcript can
229
+ // be replaced during compaction, so a message-array length/usage delta is not
230
+ // a valid per-call counter.
231
+ const statsBefore = snapshotSessionUsage(session);
232
+ const currentUsage = () =>
233
+ usageDelta(statsBefore, snapshotSessionUsage(session));
234
+
235
+ const fireProgress = () => {
236
+ if (!onProgress) return;
237
+ const delta = currentUsage().totalTokens;
238
+ onProgress({
239
+ tokens: delta,
240
+ toolUses,
241
+ durationMs: Date.now() - startTime,
242
+ lastActivityAt,
243
+ activities: [...activities],
244
+ failureKind: stalled ? "stalled" : undefined,
245
+ });
246
+ };
247
+
248
+ const stallError = () =>
249
+ `Stalled: no AgentSession activity for ${fmtDuration(stallTimeoutMs)} while ${stalledPhase ?? phase}; task aborted.`;
250
+ const clearStallWatchdog = () => {
251
+ clearStallDeadline?.();
252
+ clearStallDeadline = undefined;
253
+ };
254
+ const abortForStall = () => {
255
+ if (stalled || signal?.aborted) return;
256
+ stalled = true;
257
+ stalledPhase = phase;
258
+ clearStallWatchdog();
259
+ console.warn(
260
+ `[delegate] stalled subagent detected after ${fmtDuration(stallTimeoutMs)} while ${phase}; requesting cooperative cancellation`,
261
+ );
262
+ // AgentSession.abort() only covers the agent loop. A post-settle manual
263
+ // compaction is idle by that definition, so cancel the other session work
264
+ // explicitly before waiting for the prompt/quiescence barrier to settle.
265
+ requestSessionCancellation("stalled");
266
+ // Surface the transition immediately. Cancellation is cooperative, so the
267
+ // final TaskResult may not arrive until the provider/tool becomes idle.
268
+ fireProgress();
269
+ };
270
+ const armStallWatchdog = (graceMs = 0) => {
271
+ clearStallWatchdog();
272
+ if (!stallTimeoutMs || stalled || signal?.aborted) return;
273
+
274
+ const grace = Number.isFinite(graceMs) && graceMs > 0 ? graceMs : 0;
275
+ const deadline = Date.now() + stallTimeoutMs + grace;
276
+ clearStallDeadline = scheduleDeadline(deadline, abortForStall);
277
+ };
278
+ const noteActivity = (nextPhase: string, graceMs = 0) => {
279
+ lastActivityAt = Date.now();
280
+ phase = nextPhase;
281
+ armStallWatchdog(graceMs);
282
+ fireProgress();
283
+ };
284
+
285
+ // AgentSession can replace `session.messages` during compaction. Capture
286
+ // finalized assistant messages from the event stream instead of slicing the
287
+ // mutable transcript after prompt() returns. Keep each low-level attempt as
288
+ // structured data: overflow compaction reports its retry disposition only
289
+ // *after* agent_end, so output cannot be settled permanently at agent_end.
290
+ //
291
+ // The initial array and its prefix are also retained for the narrow fallback
292
+ // below. A Set of initial message objects is not enough: compaction can
293
+ // rebuild historical messages as fresh objects, making them look like new
294
+ // output. New pre-message_end output is still recoverable when the host has
295
+ // appended it to the unchanged transcript.
296
+ const initialMessages = session.messages;
297
+ const initialMessageCount = initialMessages.length;
298
+ const initialMessageSnapshot = initialMessages.slice();
299
+ let transcriptMayHaveBeenReplaced = false;
300
+ const assistantMessagesForAttempt: AgentMessage[] = [];
301
+ let partialAssistantMessage: AgentMessage | undefined;
302
+ type AttemptCapture = {
303
+ eventMessages: AgentMessage[];
304
+ capturedAssistants: AgentMessage[];
305
+ partialAssistant?: AgentMessage;
306
+ /** Set by a retrying agent_end or a later overflow compaction_end. */
307
+ omitFinalAssistant: boolean;
308
+ };
309
+ const settledAttempts: AttemptCapture[] = [];
310
+ let pendingCompactionAttempt: AttemptCapture | undefined;
311
+
312
+ const countAssistants = (messages: readonly AgentMessage[]): number =>
313
+ messages.reduce(
314
+ (count, message) => count + (message.role === "assistant" ? 1 : 0),
315
+ 0,
316
+ );
317
+ const removeAssistantAt = (
318
+ messages: readonly AgentMessage[],
319
+ assistantOrdinal: number,
320
+ ): AgentMessage[] => {
321
+ if (assistantOrdinal < 0) return [...messages];
322
+ let currentOrdinal = 0;
323
+ for (let index = 0; index < messages.length; index++) {
324
+ if (messages[index]?.role !== "assistant") continue;
325
+ if (currentOrdinal === assistantOrdinal) {
326
+ return messages.filter((_, candidate) => candidate !== index);
327
+ }
328
+ currentOrdinal++;
329
+ }
330
+ return [...messages];
331
+ };
332
+ const renderAttempt = (attempt: AttemptCapture): string => {
333
+ // `agent_end.messages` is authoritative for the low-level attempt. When a
334
+ // retry is requested, remove the final assistant by position. The
335
+ // message_end collection is filtered by the same assistant ordinal rather
336
+ // than object identity: hosts/extensions may clone event payloads.
337
+ const eventAssistantCount = countAssistants(attempt.eventMessages);
338
+ const finalAssistantOrdinal = eventAssistantCount - 1;
339
+ const eventMessages = attempt.omitFinalAssistant
340
+ ? removeAssistantAt(attempt.eventMessages, finalAssistantOrdinal)
341
+ : [...attempt.eventMessages];
342
+ const capturedAssistantCount = countAssistants(attempt.capturedAssistants);
343
+ const capturedOrdinal = attempt.omitFinalAssistant
344
+ ? finalAssistantOrdinal >= 0
345
+ ? finalAssistantOrdinal
346
+ : capturedAssistantCount - 1
347
+ : -1;
348
+ const capturedAssistants = attempt.omitFinalAssistant
349
+ ? removeAssistantAt(attempt.capturedAssistants, capturedOrdinal)
350
+ : [...attempt.capturedAssistants];
351
+ const eventText = extractOutput(eventMessages);
352
+ const capturedText = extractOutput(capturedAssistants);
353
+ const attemptTextWithoutPartial = eventText || capturedText;
354
+ const attemptParts = attemptTextWithoutPartial
355
+ ? [attemptTextWithoutPartial]
356
+ : [];
357
+
358
+ // A failed stream can contain useful text that never receives a
359
+ // message_end. If this agent run also contains an earlier successful turn,
360
+ // that turn must not win the `||` chain and hide the newer partial output.
361
+ // Do not retain partial text for a response that will be retried, including
362
+ // the overflow case whose retry disposition arrives at compaction_end.
363
+ const partialText =
364
+ attempt.omitFinalAssistant || !attempt.partialAssistant
365
+ ? ""
366
+ : extractOutput([attempt.partialAssistant]);
367
+ // Avoid duplicating partial output already represented by the authoritative
368
+ // event selection (including when the host copied the message object).
369
+ if (
370
+ partialText &&
371
+ !attemptParts.some(
372
+ (text) => text === partialText || text.includes(partialText),
373
+ )
374
+ ) {
375
+ attemptParts.push(partialText);
376
+ }
377
+ return attemptParts.join("\n\n");
378
+ };
379
+ const rememberPartialAssistant = (message: AgentMessage): void => {
380
+ // AgentSession emits an empty synthetic failure message after a provider
381
+ // throws mid-stream. Do not let that empty message erase useful text from
382
+ // the real, pre-message_end partial response.
383
+ const existingText = partialAssistantMessage
384
+ ? extractOutput([partialAssistantMessage])
385
+ : "";
386
+ const nextText = extractOutput([message]);
387
+ if (nextText || !existingText) partialAssistantMessage = message;
388
+ };
389
+ const finishAttempt = (
390
+ messages: readonly AgentMessage[],
391
+ willRetry: boolean,
392
+ ): void => {
393
+ const attempt: AttemptCapture = {
394
+ eventMessages: [...messages],
395
+ capturedAssistants: [...assistantMessagesForAttempt],
396
+ partialAssistant: partialAssistantMessage,
397
+ omitFinalAssistant: willRetry,
398
+ };
399
+ settledAttempts.push(attempt);
400
+ pendingCompactionAttempt = attempt;
401
+ assistantMessagesForAttempt.length = 0;
402
+ partialAssistantMessage = undefined;
403
+ };
404
+ const capturedOutput = (): string => {
405
+ const parts = settledAttempts.map(renderAttempt).filter(Boolean);
406
+ const currentAttempt = renderAttempt({
407
+ eventMessages: [],
408
+ capturedAssistants: assistantMessagesForAttempt,
409
+ partialAssistant: partialAssistantMessage,
410
+ omitFinalAssistant: false,
411
+ });
412
+ if (currentAttempt) parts.push(currentAttempt);
413
+ const eventOutput = parts.join("\n\n");
414
+ if (eventOutput) return eventOutput;
415
+ // Once an event boundary has been observed, an empty rendered result is
416
+ // meaningful (for example, a sole retrying response was intentionally
417
+ // removed). Do not let the mutable transcript re-introduce that response.
418
+ if (
419
+ settledAttempts.length > 0 ||
420
+ assistantMessagesForAttempt.length > 0 ||
421
+ partialAssistantMessage
422
+ ) {
423
+ return "";
424
+ }
425
+
426
+ // Keep an append-only fallback for providers/fakes that reject before
427
+ // emitting message_end. It is deliberately used only when event capture
428
+ // is empty; event capture is authoritative across compaction and retries.
429
+ // Requiring the original array and unchanged historical prefix prevents a
430
+ // replacement/compaction transcript from leaking old assistant output.
431
+ const currentMessages = session.messages;
432
+ if (
433
+ transcriptMayHaveBeenReplaced ||
434
+ currentMessages !== initialMessages ||
435
+ currentMessages.length < initialMessageCount
436
+ ) {
437
+ return "";
438
+ }
439
+ for (let i = 0; i < initialMessageCount; i++) {
440
+ if (currentMessages[i] !== initialMessageSnapshot[i]) return "";
441
+ }
442
+ return extractOutput(currentMessages.slice(initialMessageCount));
443
+ };
444
+
445
+ // Map the AgentSession event union to the renderer's ToolActivity model.
446
+ // Field names line up 1:1 (toolCallId, toolName, args, partialResult,
447
+ // result, isError) — AgentSession forwards the underlying agent events
448
+ // verbatim. Retry and compaction events are handled below; queue/bookkeeping
449
+ // events and thinking changes are intentionally ignored.
450
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
451
+ noteSessionEvent();
452
+ switch (event.type) {
453
+ case "tool_execution_start": {
454
+ const now = Date.now();
455
+ const activity: ToolActivity = {
456
+ id: event.toolCallId,
457
+ name: event.toolName,
458
+ args: event.args,
459
+ startTime: now,
460
+ };
461
+ pendingById.set(event.toolCallId, activity);
462
+ activities.push(activity);
463
+ noteActivity(`executing tool '${event.toolName}'`);
464
+ break;
465
+ }
466
+ case "tool_execution_update": {
467
+ const activity = pendingById.get(event.toolCallId);
468
+ if (activity) {
469
+ const text = extractTextFromPartialResult(event.partialResult);
470
+ if (text !== undefined) activity.liveOutput = text;
471
+ }
472
+ noteActivity(`executing tool '${event.toolName}'`);
473
+ break;
474
+ }
475
+ case "tool_execution_end": {
476
+ const now = Date.now();
477
+ const activity = pendingById.get(event.toolCallId);
478
+ if (activity) {
479
+ activity.result = {
480
+ content: event.result?.content ?? [],
481
+ isError: event.isError,
482
+ };
483
+ activity.endTime = now;
484
+ pendingById.delete(event.toolCallId);
485
+ }
486
+ toolUses++;
487
+ noteActivity("waiting for the next agent turn");
488
+ break;
489
+ }
490
+ case "message_start":
491
+ case "message_update":
492
+ if (event.message?.role === "assistant") {
493
+ rememberPartialAssistant(event.message);
494
+ }
495
+ noteActivity("streaming model output");
496
+ break;
497
+ case "message_end":
498
+ if (event.message.role === "assistant") {
499
+ assistantMessagesForAttempt.push(event.message);
500
+ // Keep a text-bearing partial if the host follows a provider
501
+ // exception with an empty synthetic failure message. A real
502
+ // finalized text response supersedes the partial.
503
+ if (extractOutput([event.message])) {
504
+ partialAssistantMessage = undefined;
505
+ }
506
+ }
507
+ noteActivity("waiting for the next agent turn");
508
+ break;
509
+ case "turn_end":
510
+ noteActivity("waiting for the next agent turn");
511
+ break;
512
+ case "agent_start":
513
+ case "turn_start":
514
+ noteActivity("waiting for model output");
515
+ break;
516
+ case "agent_end":
517
+ finishAttempt(event.messages, event.willRetry);
518
+ noteActivity("finishing agent run");
519
+ break;
520
+ case "agent_settled":
521
+ noteActivity("finishing agent run");
522
+ break;
523
+ case "auto_retry_start":
524
+ // A declared retry delay is intentional silence, not a wedge. Add the
525
+ // delay before ordinary inactivity detection resumes.
526
+ noteActivity("waiting to retry", event.delayMs);
527
+ break;
528
+ case "auto_retry_end":
529
+ noteActivity("waiting for model output");
530
+ break;
531
+ case "compaction_start":
532
+ // Even if a host mutates the transcript in place rather than replacing
533
+ // its array, historical messages are no longer a safe fallback source.
534
+ transcriptMayHaveBeenReplaced = true;
535
+ noteActivity("compacting context");
536
+ break;
537
+ case "compaction_end":
538
+ transcriptMayHaveBeenReplaced = true;
539
+ // Context-overflow agent_end is intentionally emitted with
540
+ // willRetry=false because Pi's retry decision belongs to compaction.
541
+ // Only an overflow compaction that actually retries may retract that
542
+ // preceding response. Failed compaction retains its error evidence;
543
+ // threshold compaction never discards a successful answer.
544
+ if (
545
+ event.reason === "overflow" &&
546
+ event.willRetry &&
547
+ pendingCompactionAttempt
548
+ ) {
549
+ pendingCompactionAttempt.omitFinalAssistant = true;
550
+ }
551
+ pendingCompactionAttempt = undefined;
552
+ noteActivity("waiting for model output");
553
+ break;
554
+ default: {
555
+ // Pi 0.83 added summarization-retry and direct-bash progress events.
556
+ // The extension still typechecks against its oldest supported Pi, so
557
+ // recognize this forward-compatible event subset at runtime.
558
+ const compatEvent = event as unknown as {
559
+ type: string;
560
+ delayMs?: unknown;
561
+ };
562
+ if (compatEvent.type === "summarization_retry_scheduled") {
563
+ const delayMs =
564
+ typeof compatEvent.delayMs === "number" ? compatEvent.delayMs : 0;
565
+ noteActivity("waiting to retry summarization", delayMs);
566
+ } else if (
567
+ compatEvent.type === "summarization_retry_attempt_start" ||
568
+ compatEvent.type === "summarization_retry_finished"
569
+ ) {
570
+ noteActivity("summarizing context");
571
+ } else if (compatEvent.type === "bash_execution_update") {
572
+ noteActivity("running bash command");
573
+ }
574
+ // queue_update, entry_appended, session_info_changed, and thinking
575
+ // changes are local bookkeeping, not evidence a blocked operation lives.
576
+ break;
577
+ }
578
+ }
579
+ });
580
+
581
+ // Wire the parent abort signal to the session. AgentSession.abort() cancels
582
+ // the in-flight model call, any retry backoff, and waits for idle.
583
+ let abortHandler: (() => void) | undefined;
584
+ if (signal) {
585
+ abortHandler = () => {
586
+ clearStallWatchdog();
587
+ // Fire-and-forget: prompt()/the quiescence barrier observe cancellation
588
+ // and then return the partial evidence that this runner reports.
589
+ requestSessionCancellation("parent-aborted");
590
+ };
591
+ signal.addEventListener("abort", abortHandler, { once: true });
592
+ }
593
+
594
+ // The signal may have fired *between* lifecycle's pre-acquire abort check
595
+ // and this listener registration (e.g. during getHostDeps/createAgentSession/
596
+ // git baseline). addEventListener("abort", …, { once }) does NOT fire for an
597
+ // already-aborted signal, so without this re-check a cancelled async ticket
598
+ // can still start a subagent that writes files and gets pooled.
599
+ if (signal?.aborted) {
600
+ abortHandler?.();
601
+ // The early return skips the try/finally below, so clean up the
602
+ // subscription and abort listener here — otherwise they leak on every
603
+ // already-aborted call, which is especially harmful for pooled sessions
604
+ // whose subscription would outlive the task.
605
+ if (signal && abortHandler)
606
+ signal.removeEventListener("abort", abortHandler);
607
+ unsubscribe();
608
+ return {
609
+ output: "",
610
+ error: "Aborted",
611
+ durationMs: Date.now() - startTime,
612
+ tokens: 0,
613
+ usage: emptyUsage(),
614
+ touchedFiles: [],
615
+ };
616
+ }
617
+
618
+ // Start detection only once the session is ready to receive its prompt;
619
+ // queued delegate tasks never enter this runner and therefore never time out.
620
+ armStallWatchdog();
621
+ fireProgress();
622
+
623
+ try {
624
+ await session.prompt(prompt);
625
+ await waitForSessionQuiescence();
626
+ clearStallWatchdog();
627
+
628
+ const state = session.state as { errorMessage?: string };
629
+ const output = capturedOutput();
630
+ const usage = currentUsage();
631
+
632
+ // Compute touched files: union of activity-based (edit/write) and git diff
633
+ // against the pre-prompt baseline. Independent of the runner's event model.
634
+ const fromActivities = extractTouchedFromActivities(activities, config.cwd);
635
+ const gitAfter = await getGitChangedFiles(config.cwd);
636
+ const fromGit = [...gitAfter].filter((f) => !gitBaseline.has(f));
637
+ const touchedFiles = [...new Set([...fromActivities, ...fromGit])];
638
+ const errorMessage = stalled
639
+ ? stallError()
640
+ : signal?.aborted
641
+ ? "Aborted"
642
+ : state.errorMessage;
643
+
644
+ return {
645
+ output: output || "(no output)",
646
+ error: errorMessage,
647
+ durationMs: Date.now() - startTime,
648
+ tokens: usage.totalTokens,
649
+ usage,
650
+ touchedFiles,
651
+ failureKind: stalled ? "stalled" : undefined,
652
+ };
653
+ } catch (err) {
654
+ // Preserve partial-work evidence: whatever assistant output, token spend,
655
+ // and touched files accumulated before the failure/abort.
656
+ const partialOutput = capturedOutput();
657
+ const usage = currentUsage();
658
+
659
+ const fromActivities = extractTouchedFromActivities(activities, config.cwd);
660
+ const gitAfter = await getGitChangedFiles(config.cwd);
661
+ const fromGit = [...gitAfter].filter((f) => !gitBaseline.has(f));
662
+ const touchedFiles = [...new Set([...fromActivities, ...fromGit])];
663
+
664
+ const msg = stalled
665
+ ? stallError()
666
+ : signal?.aborted
667
+ ? "Aborted"
668
+ : err instanceof Error
669
+ ? err.message
670
+ : String(err);
671
+ return {
672
+ output: partialOutput || "(no output)",
673
+ error: msg,
674
+ durationMs: Date.now() - startTime,
675
+ tokens: usage.totalTokens,
676
+ usage,
677
+ touchedFiles,
678
+ failureKind: stalled ? "stalled" : undefined,
679
+ };
680
+ } finally {
681
+ clearStallWatchdog();
682
+ if (signal && abortHandler)
683
+ signal.removeEventListener("abort", abortHandler);
684
+ unsubscribe();
685
+ }
686
+ }