@ferris1225/pi-subagents 4.1.7 → 4.1.9

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/src/runtime.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { rmSync } from "node:fs";
13
- import { BackgroundTaskQueue } from "./background.ts";
13
+ import { BackgroundTaskQueue, MAX_CONCURRENT_SUBAGENTS } from "./background.ts";
14
14
  import {
15
15
  completionGroupTriggersTurn,
16
16
  createCompletionBatcher,
@@ -19,29 +19,25 @@ import {
19
19
  type CompletionBatcher,
20
20
  type CompletionMessageItem,
21
21
  } from "./completion.ts";
22
- import { loadConfigSync, type ThinkingLevel } from "./config.ts";
22
+ import { type ThinkingLevel } from "./config.ts";
23
+ import { threadRecordFromThread, upsertThreadRecord, type ThreadRecord } from "./durable.ts";
23
24
  import { isRunActiveStatus, monitor } from "./monitor.ts";
24
- import {
25
- persistRecoveryRecords,
26
- recoveryRecordFromFinalization,
27
- type RecoveryRecord,
28
- } from "./recovery.ts";
29
25
  import type { RpcRunControl } from "./rpc-run.ts";
30
- import type { SingleResult } from "./spawn.ts";
26
+ import type { StartBackgroundInternal } from "./thread-lifecycle.ts";
27
+ import { isFailedResult, type SingleResult } from "./spawn.ts";
31
28
  import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "./worktree.ts";
32
29
 
33
30
  export type ThreadState =
34
31
  | "queued"
35
32
  | "resuming"
36
33
  | "running"
37
- | "steering"
38
34
  | "interrupting"
39
35
  | "parked"
40
36
  | "completed"
41
37
  | "failed"
42
38
  | "stopped";
43
39
 
44
- export type ThreadLifecycleOperation = "park" | "resume" | "fork" | "stop" | "settle";
40
+ export type ThreadLifecycleOperation = "park" | "resume" | "stop" | "settle";
45
41
 
46
42
  export interface SubagentThread {
47
43
  id: number;
@@ -54,6 +50,8 @@ export interface SubagentThread {
54
50
  executionCwd: string;
55
51
  thinkingLevel?: ThinkingLevel;
56
52
  isolation: IsolationMode;
53
+ /** Report-only reviewer dispatch: verdicts never chain into auto-fix. */
54
+ advisoryReview: boolean;
57
55
  worktree?: WorktreeIsolation;
58
56
  state: ThreadState;
59
57
  control: RpcRunControl;
@@ -74,15 +72,8 @@ export interface SubagentThread {
74
72
  /** A destructive stop retires context even if the active child settles later. */
75
73
  retireOnSettle?: boolean;
76
74
  retired?: boolean;
77
- /** Abort the active generation to a stable checkpoint and wait until its
78
- * queue work has published that checkpoint and released its slot. */
79
- park: () => Promise<"queued" | "active">;
80
75
  /** Installed by dispatch so the control tool can restart the same logical id. */
81
76
  resume: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
82
- /** Create a new logical thread from this thread's retained Pi session branch. */
83
- fork: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
84
- forkedFromRunId?: number;
85
- forkChildRunIds: number[];
86
77
  /** Dispatch-owned, generation-guarded worktree settlement hook. Its apply
87
78
  * runs under the canonical original-repository lane. */
88
79
  finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
@@ -98,6 +89,13 @@ export interface SubagentRuntime {
98
89
  getActiveTools: () => string[];
99
90
  /** False after session_shutdown; guards delivery and queue work. */
100
91
  sessionActive: boolean;
92
+ /** The process-wide background dispatcher. Set at tool registration so
93
+ * threads restored from the durable manifest can resume before any dispatch. */
94
+ dispatcher?: StartBackgroundInternal;
95
+ /** Run ids restored from the durable manifest at load; consumed by the
96
+ * one-time session-start notice. */
97
+ restoredRunIds: number[];
98
+ restoredNotified: boolean;
101
99
  /** Deliver a batch of completion messages to the main window, waking it only
102
100
  * when the batch needs a turn. */
103
101
  sendCompletionGroup: (items: CompletionMessageItem[]) => void;
@@ -111,7 +109,7 @@ export interface SubagentRuntime {
111
109
  registerRunResult: (runId: number, result: SingleResult) => void;
112
110
  /** Logical threads outlive process attempts and completed generations. */
113
111
  threads: Map<number, SubagentThread>;
114
- /** Resume/fork setup that has claimed a thread but has not yet enqueued its
112
+ /** Resume setup that has claimed a thread but has not yet enqueued its
115
113
  * next generation. Shutdown invalidates these claims and waits for cleanup. */
116
114
  preflightOperations: Set<Promise<void>>;
117
115
  /** Every session directory retained for this parent session, including
@@ -124,16 +122,15 @@ export interface SubagentRuntime {
124
122
  }
125
123
 
126
124
  export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
127
- // Init-time decisions need the config synchronously; the full (migrating)
128
- // async load runs per tool call.
129
- const initialConfig = loadConfigSync(configPath);
130
- const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
125
+ const backgroundQueue = new BackgroundTaskQueue(MAX_CONCURRENT_SUBAGENTS);
131
126
 
132
127
  const runtime: SubagentRuntime = {
133
128
  configPath,
134
129
  backgroundQueue,
135
130
  getActiveTools: () => pi.getActiveTools(),
136
131
  sessionActive: true,
132
+ restoredRunIds: [],
133
+ restoredNotified: false,
137
134
  sendCompletionGroup: (items) => {
138
135
  if (!runtime.sessionActive || items.length === 0) return;
139
136
  // A result arriving for one run does not mean sibling runs are done.
@@ -206,61 +203,86 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
206
203
  if (!runtime.sessionActive) return;
207
204
  runtime.sessionActive = false;
208
205
  const shutdownThreads = [...runtime.threads.values()];
206
+ const liveStates = new Set(["queued", "resuming", "running", "interrupting"]);
207
+ const previousStates = new Map(shutdownThreads.map((thread) => [thread.id, thread.state] as const));
209
208
  // Invalidate every lifecycle claim synchronously before the first await.
210
- // Resume/fork preflight checks both this version and sessionActive, then
209
+ // Resume preflight checks both this version and sessionActive, then
211
210
  // cleans any worktree/session it created before resolving its tracker.
211
+ // A generation already inside its settlement keeps its own claim: it
212
+ // finalizes its worktree and persists its terminal record itself.
213
+ const interrupting = shutdownThreads.filter((thread) =>
214
+ !thread.retired &&
215
+ thread.lifecycleOperation !== "settle" &&
216
+ liveStates.has(thread.state),
217
+ );
212
218
  for (const thread of shutdownThreads) {
213
219
  thread.lifecycleVersion++;
220
+ if (thread.retired) {
221
+ thread.lifecycleOperation = "stop";
222
+ continue;
223
+ }
224
+ if (thread.lifecycleOperation === "settle") continue;
214
225
  thread.lifecycleOperation = "stop";
215
- thread.retired = true;
216
- thread.retireOnSettle = true;
217
- thread.state = "stopped";
226
+ // Deliberately NOT retireOnSettle: shutdown interrupts to the last
227
+ // checkpoint but keeps the session/worktree resumable across reload.
228
+ thread.retireOnSettle = false;
229
+ if (liveStates.has(thread.state)) thread.state = "stopped";
218
230
  }
219
231
  const preflights = [...runtime.preflightOperations];
220
232
  runtime.completionBatcher.dispose();
221
233
  runtime.backgroundQueue.cancelAll();
222
234
  // Await live RPC process-tree cleanup and continuation preflight rollback
223
- // before removing sessions/worktrees or clearing ownership maps.
235
+ // before persisting records or releasing ownership maps.
224
236
  await Promise.all([
225
237
  Promise.all(
226
- shutdownThreads.map((thread) =>
238
+ interrupting.map((thread) =>
227
239
  thread.control.stop("Parent session shut down").catch(() => undefined),
228
240
  ),
229
241
  ),
230
242
  Promise.allSettled(preflights),
231
243
  runtime.backgroundQueue.waitForIdle(),
232
244
  ]);
233
- // Parked work owns no queue task, so shutdown is its final settlement.
234
- // Active/stopped tasks may already have finalized; the handle and callback
235
- // are idempotent and generation-guarded.
236
- const recoveryRecords: RecoveryRecord[] = [];
245
+ // Persist one record per non-retired thread, then keep exactly the
246
+ // artifacts those records reference. A thread whose settlement finished
247
+ // during the wait above already wrote its own terminal record; the
248
+ // lastResult-derived state below matches it.
249
+ const records: ThreadRecord[] = [];
237
250
  for (const thread of runtime.threads.values()) {
238
- const finalization = await thread.finalizeIsolation(thread.generation).catch(() => undefined);
239
- if (finalization?.status === "retained") {
240
- recoveryRecords.push(recoveryRecordFromFinalization(thread.id, finalization));
241
- if (!thread.isolationFailureNotified) {
242
- thread.isolationFailureNotified = true;
243
- try {
244
- thread.notifyIsolationFailure?.(finalization);
245
- } catch {
246
- /* the parent UI may already be shutting down */
247
- }
248
- }
251
+ if (thread.retired) continue;
252
+ const previous = previousStates.get(thread.id) ?? thread.state;
253
+ let state: "parked" | "completed" | "failed";
254
+ if (previous === "completed" || previous === "failed") {
255
+ state = previous;
256
+ } else if (thread.lifecycleOperation === "settle" && thread.lastResult) {
257
+ state = isFailedResult(thread.lastResult) ? "failed" : "completed";
258
+ } else {
259
+ state = "parked";
249
260
  }
261
+ records.push(threadRecordFromThread(thread, state));
250
262
  }
251
- // Persist before tearing down the old runtime. A /new, /resume, or quit
252
- // must not make the only recovery paths unreachable.
253
- await persistRecoveryRecords(runtime.configPath, recoveryRecords).catch(() => undefined);
254
- runtime.settledRuns.clear();
255
- runtime.settledListeners.clear();
256
- runtime.runControllers.clear();
263
+ await Promise.all(
264
+ records.map((record) => upsertThreadRecord(runtime.configPath, record).catch(() => undefined)),
265
+ );
266
+ // Retained-failure recovery records are persisted by the finalization
267
+ // itself; shutdown only drops sessions no record claims anymore.
268
+ const referenced = new Set(
269
+ records.flatMap((record) =>
270
+ [record.sessionDir, record.worktree?.tempDir].filter(Boolean) as string[],
271
+ ),
272
+ );
257
273
  for (const sessionDir of runtime.sessionDirs) {
274
+ if (referenced.has(sessionDir)) continue;
258
275
  try {
259
276
  rmSync(sessionDir, { recursive: true, force: true });
260
277
  } catch {
261
- /* best-effort */
278
+ /* best-effort; the state-root sweep catches leftovers later */
262
279
  }
263
280
  }
281
+ runtime.settledRuns.clear();
282
+ runtime.settledListeners.clear();
283
+ runtime.runControllers.clear();
284
+ // sessionDirs entries still referenced by records stay owned by the
285
+ // manifest; the next process re-registers them at restore.
264
286
  runtime.sessionDirs.clear();
265
287
  runtime.preflightOperations.clear();
266
288
  runtime.threads.clear();
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { SessionManager } from "@earendil-works/pi-coding-agent";
4
4
  import { existsSync } from "node:fs";
5
- import { mkdtemp, rm } from "node:fs/promises";
5
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
8
 
@@ -43,12 +43,17 @@ export async function forkRetainedSession(options: {
43
43
  targetCwd?: string;
44
44
  sessionDir: string;
45
45
  sessionId: string;
46
+ /** Parent directory for the cloned branch. Defaults to the OS temp dir;
47
+ * dispatch passes the durable state root. */
48
+ targetRoot?: string;
46
49
  }): Promise<ForkedSession> {
47
50
  const sourceSessionFile = await findRetainedSessionFile(
48
51
  options.sessionDir,
49
52
  options.sessionId,
50
53
  );
51
- const sessionDir = await mkdtemp(join(tmpdir(), "pi-subagent-session-fork-"));
54
+ const root = options.targetRoot ?? tmpdir();
55
+ await mkdir(root, { recursive: true });
56
+ const sessionDir = await mkdtemp(join(root, "pi-subagent-session-fork-"));
52
57
  try {
53
58
  // Supplying the new directory makes createBranchedSession write there.
54
59
  // cwdOverride rewrites the cloned header so a settled isolated session can
package/src/setup.ts CHANGED
@@ -13,13 +13,9 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
13
  import {
14
14
  AGENT_SCOPE_VALUES,
15
15
  BUILTIN_AGENT_NAMES,
16
- CLEANER_DEFAULTED_FEATURE,
17
- DOCUMENTER_DEFAULTED_FEATURE,
18
16
  DEFAULT_CONFIG,
19
17
  DEFAULT_ENABLED_AGENTS,
20
18
  DEFAULT_IDLE_TIMEOUT_SEC,
21
- DEFAULT_MAX_CONCURRENCY,
22
- DEFAULT_MAX_FIX_ROUNDS,
23
19
  DEFAULT_THINKING_LEVEL,
24
20
  type AgentScope,
25
21
  type SubagentsConfig,
@@ -243,8 +239,6 @@ async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Pr
243
239
  return choice.startsWith("On");
244
240
  }
245
241
 
246
- const CONCURRENCY_STEPS = [1, 2, 3, 4, 6, 8, 12, 16];
247
- const FIX_ROUNDS_STEPS = [0, 1, 2, 3, 5];
248
242
  const IDLE_TIMEOUT_STEPS = [0, 30, 60, 90, 120, 180, 300, 600];
249
243
 
250
244
  async function pickCount(
@@ -299,22 +293,6 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
299
293
  if (injection === undefined) return false;
300
294
  const scope = await pickScope(ctx, base.agentScope);
301
295
  if (scope === undefined) return false;
302
- const maxConcurrency = await pickCount(
303
- ctx,
304
- "Max sub-agents running at once?",
305
- CONCURRENCY_STEPS,
306
- base.maxConcurrency,
307
- DEFAULT_MAX_CONCURRENCY,
308
- );
309
- if (maxConcurrency === undefined) return false;
310
- const maxFixRounds = await pickCount(
311
- ctx,
312
- "Reviewer worker-fix rounds? (0 = no automatic fixes)",
313
- FIX_ROUNDS_STEPS,
314
- base.maxFixRounds,
315
- DEFAULT_MAX_FIX_ROUNDS,
316
- );
317
- if (maxFixRounds === undefined) return false;
318
296
  const idleTimeoutSec = await pickCount(
319
297
  ctx,
320
298
  "Idle timeout in seconds? (0 = disabled)",
@@ -333,16 +311,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
333
311
  maxResultLines: base.maxResultLines,
334
312
  proactiveInjection: injection,
335
313
  agentScope: scope,
336
- maxConcurrency,
337
- maxFixRounds,
338
314
  idleTimeoutSec,
339
- // Full setup is an explicit decision point: mark role-enable migrations as
340
- // processed so the user's saved selection is kept as-is.
341
- announcedFeatures: [...new Set([
342
- ...base.announcedFeatures,
343
- CLEANER_DEFAULTED_FEATURE,
344
- DOCUMENTER_DEFAULTED_FEATURE,
345
- ])],
346
315
  };
347
316
  await saveConfig(next, configPath);
348
317
  ctx.ui.notify(`pi-subagents configured with Auto thinking. Saved to ${configPath}`, "info");
@@ -357,8 +326,6 @@ async function updateRuntimeSetting(
357
326
  const choice = await ctx.ui.select("Runtime setting", [
358
327
  "Proactive injection",
359
328
  "Agent scope",
360
- "Max concurrency",
361
- "Reviewer worker-fix rounds",
362
329
  "Idle timeout",
363
330
  ]);
364
331
  if (choice === undefined) return undefined;
@@ -371,14 +338,6 @@ async function updateRuntimeSetting(
371
338
  const value = await pickScope(ctx, config.agentScope);
372
339
  if (value === undefined) continue;
373
340
  next.agentScope = value;
374
- } else if (choice.startsWith("Max concurrency")) {
375
- const value = await pickCount(ctx, "Max sub-agents running at once?", CONCURRENCY_STEPS, config.maxConcurrency, DEFAULT_MAX_CONCURRENCY);
376
- if (value === undefined) continue;
377
- next.maxConcurrency = value;
378
- } else if (choice.startsWith("Reviewer")) {
379
- const value = await pickCount(ctx, "Reviewer worker-fix rounds?", FIX_ROUNDS_STEPS, config.maxFixRounds, DEFAULT_MAX_FIX_ROUNDS);
380
- if (value === undefined) continue;
381
- next.maxFixRounds = value;
382
341
  } else {
383
342
  const value = await pickCount(ctx, "Idle timeout in seconds?", IDLE_TIMEOUT_STEPS, config.idleTimeoutSec, DEFAULT_IDLE_TIMEOUT_SEC);
384
343
  if (value === undefined) continue;
package/src/spawn.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { createHash, randomUUID } from "node:crypto";
12
12
  import { type Dirent, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
13
- import { mkdtemp, rm } from "node:fs/promises";
13
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
14
14
  import { tmpdir } from "node:os";
15
15
  import { basename, join, resolve } from "node:path";
16
16
  import type { Message } from "@earendil-works/pi-ai";
@@ -202,7 +202,7 @@ export function writeResultArtifact(output: string, agentName: string, cwd?: str
202
202
  }
203
203
 
204
204
  export function isFailedResult(result: SingleResult): boolean {
205
- if (result.parked) return false;
205
+
206
206
  return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
207
207
  }
208
208
 
@@ -272,7 +272,7 @@ export function isRetryableStartupFailure(result: SingleResult, durationMs: numb
272
272
  }
273
273
 
274
274
  export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
275
- return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or temporarily lower maxConcurrency in /subagents-setup.`;
275
+ return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or dispatch fewer sub-agents at once.`;
276
276
  }
277
277
 
278
278
  export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
@@ -308,12 +308,12 @@ async function waitForControlledRetry(
308
308
  ): Promise<boolean> {
309
309
  let remaining = normalizeStartupRetryDelay(delayMs);
310
310
  while (remaining > 0) {
311
- if (control?.isParkRequested() || control?.isStopRequested()) return false;
311
+ if (control?.isStopRequested()) return false;
312
312
  const slice = Math.min(remaining, 50);
313
313
  if (!(await waitForStartupRetry(slice, signal))) return false;
314
314
  remaining -= slice;
315
315
  }
316
- return !signal?.aborted && !control?.isParkRequested() && !control?.isStopRequested();
316
+ return !signal?.aborted && !control?.isStopRequested();
317
317
  }
318
318
 
319
319
  export function getResultOutput(result: SingleResult): string {
@@ -330,6 +330,12 @@ export function buildResumePrompt(task: string, reason: string): string {
330
330
  return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Current objective: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
331
331
  }
332
332
 
333
+ /** Create a fresh private session directory under the given root. */
334
+ export async function createSessionDir(root: string = tmpdir()): Promise<string> {
335
+ await mkdir(root, { recursive: true });
336
+ return mkdtemp(join(root, "pi-subagent-session-"));
337
+ }
338
+
333
339
  export function buildFallbackResumeReason(fromModel?: string): string {
334
340
  return fromModel
335
341
  ? `the selected model (${fromModel}) failed at the model/provider level, so the current main model is continuing`
@@ -349,6 +355,10 @@ export interface RunSingleOptions {
349
355
  startupRetryDelaysMs?: readonly number[];
350
356
  sessionDir?: string;
351
357
  sessionId?: string;
358
+ /** Parent directory for a fresh session directory. Defaults to the OS temp
359
+ * dir; dispatch passes the durable state root so retained sessions survive
360
+ * reloads and restarts. */
361
+ sessionRoot?: string;
352
362
  /** Initial RPC prompt. Kept under the old name to limit caller churn. */
353
363
  stdinText?: string;
354
364
  /** Refresh parent-derived tools immediately before every startup retry and
@@ -366,7 +376,7 @@ export interface RunSingleOptions {
366
376
 
367
377
  function controlledDisposition(options: RunSingleOptions, base?: SingleResult): SingleResult | undefined {
368
378
  const control = options.control;
369
- if (!control?.isParkRequested() && !control?.isStopRequested()) return undefined;
379
+ if (!control?.isStopRequested()) return undefined;
370
380
  const result: SingleResult = base ?? {
371
381
  agent: options.agentName,
372
382
  task: control.getObjective(),
@@ -380,23 +390,14 @@ function controlledDisposition(options: RunSingleOptions, base?: SingleResult):
380
390
  sessionDir: options.sessionDir,
381
391
  };
382
392
  result.task = control.getObjective();
383
- if (control.isParkRequested()) {
384
- result.parked = true;
385
- result.exitCode = 0;
386
- result.stopReason = undefined;
387
- result.errorMessage = undefined;
388
- } else {
389
- result.parked = undefined;
390
- result.exitCode = 1;
391
- result.stopReason = "aborted";
392
- result.errorMessage = control.getStopMessage();
393
- }
393
+ result.exitCode = 1;
394
+ result.stopReason = "aborted";
395
+ result.errorMessage = control.getStopMessage();
394
396
  return result;
395
397
  }
396
398
 
397
399
  function signalAbortDisposition(options: RunSingleOptions, base: SingleResult): SingleResult | undefined {
398
400
  if (!options.signal?.aborted) return undefined;
399
- base.parked = undefined;
400
401
  base.exitCode = 1;
401
402
  base.stopReason = "aborted";
402
403
  base.errorMessage = "Subagent was aborted";
@@ -459,8 +460,17 @@ export async function runSingleAgentWithMainFallback(
459
460
  const startupDelays = customStartupDelays ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
460
461
 
461
462
  const sessionId = options.sessionId ?? randomUUID();
462
- const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
463
+ const sessionDir = options.sessionDir ?? (await createSessionDir(options.sessionRoot));
463
464
  const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
465
+ if (!options.sessionDir) {
466
+ // Surface the fresh session immediately so the dispatching thread can
467
+ // persist a durable checkpoint before the child settles.
468
+ try {
469
+ options.onLive?.({ kind: "session", sessionId, sessionDir });
470
+ } catch {
471
+ /* never throw from event handling */
472
+ }
473
+ }
464
474
 
465
475
  const dispatchFailure = async (error: unknown): Promise<SingleResult> => {
466
476
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -489,14 +499,7 @@ export async function runSingleAgentWithMainFallback(
489
499
  let retries = 0;
490
500
  for (let attempt = 0; ; attempt++) {
491
501
  const immediate = controlledDisposition(opts);
492
- if (immediate) {
493
- if (immediate.parked && !options.sessionDir && !sessionExists(sessionDir, sessionId)) {
494
- await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
495
- immediate.sessionId = undefined;
496
- immediate.sessionDir = undefined;
497
- }
498
- return immediate;
499
- }
502
+ if (immediate) return immediate;
500
503
  const start = Date.now();
501
504
  try {
502
505
  const attemptOptions = opts.resolveAgentForAttempt
@@ -510,7 +513,7 @@ export async function runSingleAgentWithMainFallback(
510
513
  const durationMs = Date.now() - start;
511
514
  const controlled = controlledDisposition(opts, lastResult);
512
515
  if (controlled) return controlled;
513
- if (lastResult.parked || lastResult.stopReason === "aborted") return lastResult;
516
+ if (lastResult.stopReason === "aborted") return lastResult;
514
517
  if (!isRetryableStartupFailure(lastResult, durationMs)) {
515
518
  if (retries > 0 && !isFailedResult(lastResult)) lastResult.startupRetries = retries;
516
519
  return lastResult;
@@ -627,7 +630,7 @@ export async function runSingleAgentWithMainFallback(
627
630
  }
628
631
 
629
632
  result = await runWithStartupRetry(candidateOptions);
630
- if (result.parked || result.stopReason === "aborted") return finish(result);
633
+ if (result.stopReason === "aborted") return finish(result);
631
634
  if (!isModelLevelFailure(result)) return finish(result);
632
635
  // Any model-level failure advances immediately to the sole fallback (the
633
636
  // current main model). Retain selected-attempt tool diagnostics and usage;