@ferris1225/pi-subagents 4.1.2 → 4.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/src/setup.ts CHANGED
@@ -63,7 +63,7 @@ const MODULE_HINTS: Record<string, string> = {
63
63
  explorer: "read-only codebase recon (fast model)",
64
64
  worker: "implement / fix / refactor / test (full tools)",
65
65
  cleaner: "apply proven cleanup and deduplicate code (full tools)",
66
- documenter: "sync diff or whole-codebase comments/docs (full tools)",
66
+ documenter: "sync diff or whole-codebase comments/docs (docs write)",
67
67
  reviewer: "read-only audits and pre-commit gates",
68
68
  };
69
69
 
@@ -89,7 +89,7 @@ async function pickEnabledAgents(
89
89
  return promptSelectMany(
90
90
  ctx,
91
91
  "Enable which sub-agents?",
92
- "Space toggles • Enter confirms • Esc cancels",
92
+ "Space toggles • Enter confirms • Esc returns to settings",
93
93
  items,
94
94
  current,
95
95
  );
@@ -120,7 +120,7 @@ async function pickAgentModel(
120
120
  ctx: ExtensionCommandContext,
121
121
  agentName: string,
122
122
  currentRef: string | undefined,
123
- escNote = "cancels setup",
123
+ escNote = "cancels this setup pass",
124
124
  ): Promise<string | undefined> {
125
125
  return pickConfiguredModel(ctx, `Model for "${agentName}"?`, currentRef, escNote);
126
126
  }
@@ -151,7 +151,7 @@ async function pickAgentStrength(
151
151
  model: Model<Api> | undefined,
152
152
  current: ThinkingLevel | undefined,
153
153
  agentDefault: ThinkingLevel,
154
- escNote = "cancels setup",
154
+ escNote = "cancels this setup pass",
155
155
  ): Promise<ThinkingLevel | typeof AUTO_THINKING | undefined> {
156
156
  const supported = supportedThinkingLevels(model);
157
157
  const automatic = resolveThinkingLevel(model, agentDefault);
@@ -191,44 +191,48 @@ async function pickAgentToConfigure(
191
191
  return promptSelectOne(
192
192
  ctx,
193
193
  "Configure which agent?",
194
- "Type to filter • ↑/↓ • Enter selects • Esc ends this pass",
194
+ "Type to filter • ↑/↓ • Enter selects • Esc returns to settings",
195
195
  enabledAgents.map((name) => ({ value: name, label: moduleLabel(name) })),
196
196
  );
197
197
  }
198
198
 
199
- /** One agent: model, then thinking if the model exposes a choice. Esc at any
200
- * step ends the caller's pass; earlier agents in that pass stay applied. */
199
+ interface ConfiguredAgentChoice {
200
+ name: string;
201
+ model: string;
202
+ strength: ThinkingLevel | typeof AUTO_THINKING;
203
+ }
204
+
205
+ /** Configure one agent while preserving the UI back stack: thinking → model →
206
+ * agent selection. Esc from agent selection ends this configuration pass. */
201
207
  async function configureOneAgent(
202
208
  ctx: ExtensionCommandContext,
203
209
  config: SubagentsConfig,
204
- ): Promise<
205
- | {
206
- name: string;
207
- model: string;
208
- strength: ThinkingLevel | typeof AUTO_THINKING;
209
- }
210
- | undefined
211
- > {
212
- const name = await pickAgentToConfigure(ctx, config.enabledAgents);
213
- if (name === undefined) return undefined;
214
- const modelChoice = await pickAgentModel(
215
- ctx,
216
- name,
217
- config.agentModels[name],
218
- "stops earlier agent changes are kept",
219
- );
220
- if (modelChoice === undefined) return undefined;
221
- const model = effectiveModelForChoice(ctx, modelChoice);
222
- const strength = await pickAgentStrength(
223
- ctx,
224
- name,
225
- model,
226
- config.agentThinkingLevels[name],
227
- actualAgentThinkingDefault(ctx, config, name),
228
- "stops — earlier agent changes are kept",
229
- );
230
- if (strength === undefined) return undefined;
231
- return { name, model: modelChoice, strength };
210
+ ): Promise<ConfiguredAgentChoice | undefined> {
211
+ while (true) {
212
+ const name = await pickAgentToConfigure(ctx, config.enabledAgents);
213
+ if (name === undefined) return undefined;
214
+
215
+ while (true) {
216
+ const modelChoice = await pickAgentModel(
217
+ ctx,
218
+ name,
219
+ config.agentModels[name],
220
+ "returns to agent selection",
221
+ );
222
+ if (modelChoice === undefined) break;
223
+ const model = effectiveModelForChoice(ctx, modelChoice);
224
+ const strength = await pickAgentStrength(
225
+ ctx,
226
+ name,
227
+ model,
228
+ config.agentThinkingLevels[name],
229
+ actualAgentThinkingDefault(ctx, config, name),
230
+ "returns to model selection",
231
+ );
232
+ if (strength === undefined) continue;
233
+ return { name, model: modelChoice, strength };
234
+ }
235
+ }
232
236
  }
233
237
 
234
238
  async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Promise<boolean | undefined> {
@@ -280,21 +284,21 @@ function keepAgentEntries<T>(record: Record<string, T>, enabled: readonly string
280
284
  return Object.fromEntries(Object.entries(record).filter(([name]) => keep.has(name)));
281
285
  }
282
286
 
283
- async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, base: SubagentsConfig): Promise<void> {
287
+ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, base: SubagentsConfig): Promise<boolean> {
284
288
  const enabled = await pickEnabledAgents(ctx, base.enabledAgents);
285
- if (enabled === undefined) return notifyCancelled(ctx);
289
+ if (enabled === undefined) return false;
286
290
 
287
291
  let agentModels = keepAgentEntries(base.agentModels, enabled);
288
292
  for (const agentName of enabled) {
289
293
  const choice = await pickAgentModel(ctx, agentName, agentModels[agentName]);
290
- if (choice === undefined) return notifyCancelled(ctx);
294
+ if (choice === undefined) return false;
291
295
  agentModels = applyAgentModelChoice(agentModels, agentName, choice);
292
296
  }
293
297
 
294
298
  const injection = await pickInjection(ctx, base.proactiveInjection);
295
- if (injection === undefined) return notifyCancelled(ctx);
299
+ if (injection === undefined) return false;
296
300
  const scope = await pickScope(ctx, base.agentScope);
297
- if (scope === undefined) return notifyCancelled(ctx);
301
+ if (scope === undefined) return false;
298
302
  const maxConcurrency = await pickCount(
299
303
  ctx,
300
304
  "Max sub-agents running at once?",
@@ -302,7 +306,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
302
306
  base.maxConcurrency,
303
307
  DEFAULT_MAX_CONCURRENCY,
304
308
  );
305
- if (maxConcurrency === undefined) return notifyCancelled(ctx);
309
+ if (maxConcurrency === undefined) return false;
306
310
  const maxFixRounds = await pickCount(
307
311
  ctx,
308
312
  "Reviewer worker-fix rounds? (0 = no automatic fixes)",
@@ -310,7 +314,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
310
314
  base.maxFixRounds,
311
315
  DEFAULT_MAX_FIX_ROUNDS,
312
316
  );
313
- if (maxFixRounds === undefined) return notifyCancelled(ctx);
317
+ if (maxFixRounds === undefined) return false;
314
318
  const idleTimeoutSec = await pickCount(
315
319
  ctx,
316
320
  "Idle timeout in seconds? (0 = disabled)",
@@ -318,7 +322,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
318
322
  base.idleTimeoutSec,
319
323
  DEFAULT_IDLE_TIMEOUT_SEC,
320
324
  );
321
- if (idleTimeoutSec === undefined) return notifyCancelled(ctx);
325
+ if (idleTimeoutSec === undefined) return false;
322
326
 
323
327
  const next: SubagentsConfig = {
324
328
  enabledAgents: enabled,
@@ -342,114 +346,123 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
342
346
  };
343
347
  await saveConfig(next, configPath);
344
348
  ctx.ui.notify(`pi-subagents configured with Auto thinking. Saved to ${configPath}`, "info");
349
+ return true;
345
350
  }
346
351
 
347
352
  async function updateRuntimeSetting(
348
353
  ctx: ExtensionCommandContext,
349
354
  config: SubagentsConfig,
350
355
  ): Promise<SubagentsConfig | undefined> {
351
- const choice = await ctx.ui.select("Runtime setting", [
352
- "Proactive injection",
353
- "Agent scope",
354
- "Max concurrency",
355
- "Reviewer worker-fix rounds",
356
- "Idle timeout",
357
- ]);
358
- if (choice === undefined) return undefined;
359
- const next = { ...config };
360
- if (choice.startsWith("Proactive")) {
361
- const value = await pickInjection(ctx, config.proactiveInjection);
362
- if (value === undefined) return undefined;
363
- next.proactiveInjection = value;
364
- } else if (choice.startsWith("Agent scope")) {
365
- const value = await pickScope(ctx, config.agentScope);
366
- if (value === undefined) return undefined;
367
- next.agentScope = value;
368
- } else if (choice.startsWith("Max concurrency")) {
369
- const value = await pickCount(ctx, "Max sub-agents running at once?", CONCURRENCY_STEPS, config.maxConcurrency, DEFAULT_MAX_CONCURRENCY);
370
- if (value === undefined) return undefined;
371
- next.maxConcurrency = value;
372
- } else if (choice.startsWith("Reviewer")) {
373
- const value = await pickCount(ctx, "Reviewer worker-fix rounds?", FIX_ROUNDS_STEPS, config.maxFixRounds, DEFAULT_MAX_FIX_ROUNDS);
374
- if (value === undefined) return undefined;
375
- next.maxFixRounds = value;
376
- } else {
377
- const value = await pickCount(ctx, "Idle timeout in seconds?", IDLE_TIMEOUT_STEPS, config.idleTimeoutSec, DEFAULT_IDLE_TIMEOUT_SEC);
378
- if (value === undefined) return undefined;
379
- next.idleTimeoutSec = value;
356
+ while (true) {
357
+ const choice = await ctx.ui.select("Runtime setting", [
358
+ "Proactive injection",
359
+ "Agent scope",
360
+ "Max concurrency",
361
+ "Reviewer worker-fix rounds",
362
+ "Idle timeout",
363
+ ]);
364
+ if (choice === undefined) return undefined;
365
+ const next = { ...config };
366
+ if (choice.startsWith("Proactive")) {
367
+ const value = await pickInjection(ctx, config.proactiveInjection);
368
+ if (value === undefined) continue;
369
+ next.proactiveInjection = value;
370
+ } else if (choice.startsWith("Agent scope")) {
371
+ const value = await pickScope(ctx, config.agentScope);
372
+ if (value === undefined) continue;
373
+ 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
+ } else {
383
+ const value = await pickCount(ctx, "Idle timeout in seconds?", IDLE_TIMEOUT_STEPS, config.idleTimeoutSec, DEFAULT_IDLE_TIMEOUT_SEC);
384
+ if (value === undefined) continue;
385
+ next.idleTimeoutSec = value;
386
+ }
387
+ return next;
380
388
  }
381
- return next;
382
389
  }
383
390
 
384
391
  async function runMenu(ctx: ExtensionCommandContext, configPath: string, config: SubagentsConfig): Promise<void> {
385
- const choice = await ctx.ui.select("pi-subagents settings", [
386
- "Enable/disable agents",
387
- "Configure an agent (model + thinking)",
388
- "Runtime settings",
389
- "Full re-setup",
390
- ]);
391
- if (choice === undefined) return notifyCancelled(ctx);
392
- if (choice.startsWith("Full")) return runFullSetup(ctx, configPath, config);
392
+ while (true) {
393
+ const choice = await ctx.ui.select("pi-subagents settings", [
394
+ "Enable/disable agents",
395
+ "Configure an agent (model + thinking)",
396
+ "Runtime settings",
397
+ "Full re-setup",
398
+ ]);
399
+ if (choice === undefined) return;
400
+ if (choice.startsWith("Full")) {
401
+ if (await runFullSetup(ctx, configPath, config)) return;
402
+ continue;
403
+ }
393
404
 
394
- let next: SubagentsConfig = {
395
- ...config,
396
- agentModels: { ...config.agentModels },
397
- agentThinkingLevels: { ...config.agentThinkingLevels },
398
- };
399
- if (choice.startsWith("Enable")) {
400
- const enabled = await pickEnabledAgents(ctx, config.enabledAgents);
401
- if (enabled === undefined) return notifyCancelled(ctx);
402
- next.enabledAgents = enabled;
403
- // Newly enabling cleaner inherits the reviewer's configured model and
404
- // thinking level, so the file reflects what cleaner will actually run
405
- // instead of silently falling back to the current main model.
406
- if (!config.enabledAgents.includes("cleaner") && enabled.includes("cleaner")) {
407
- if (!next.agentModels.cleaner && config.agentModels.reviewer) {
408
- next.agentModels.cleaner = config.agentModels.reviewer;
409
- }
410
- if (!next.agentThinkingLevels.cleaner && config.agentThinkingLevels.reviewer) {
411
- next.agentThinkingLevels.cleaner = config.agentThinkingLevels.reviewer;
405
+ let next: SubagentsConfig = {
406
+ ...config,
407
+ agentModels: { ...config.agentModels },
408
+ agentThinkingLevels: { ...config.agentThinkingLevels },
409
+ };
410
+ if (choice.startsWith("Enable")) {
411
+ const enabled = await pickEnabledAgents(ctx, config.enabledAgents);
412
+ if (enabled === undefined) continue;
413
+ next.enabledAgents = enabled;
414
+ // Newly enabling cleaner inherits the reviewer's configured model and
415
+ // thinking level, so the file reflects what cleaner will actually run
416
+ // instead of silently falling back to the current main model.
417
+ if (!config.enabledAgents.includes("cleaner") && enabled.includes("cleaner")) {
418
+ if (!next.agentModels.cleaner && config.agentModels.reviewer) {
419
+ next.agentModels.cleaner = config.agentModels.reviewer;
420
+ }
421
+ if (!next.agentThinkingLevels.cleaner && config.agentThinkingLevels.reviewer) {
422
+ next.agentThinkingLevels.cleaner = config.agentThinkingLevels.reviewer;
423
+ }
412
424
  }
413
- }
414
- // Documenter intentionally follows the faster explorer route. Fresh
415
- // installs leave it unselected; enabling it later inherits any explorer
416
- // overrides instead of silently choosing a stronger model.
417
- if (!config.enabledAgents.includes("documenter") && enabled.includes("documenter")) {
418
- if (!next.agentModels.documenter && config.agentModels.explorer) {
419
- next.agentModels.documenter = config.agentModels.explorer;
425
+ // Documenter intentionally follows the faster explorer route. Fresh
426
+ // installs leave it unselected; enabling it later inherits any explorer
427
+ // overrides instead of silently choosing a stronger model.
428
+ if (!config.enabledAgents.includes("documenter") && enabled.includes("documenter")) {
429
+ if (!next.agentModels.documenter && config.agentModels.explorer) {
430
+ next.agentModels.documenter = config.agentModels.explorer;
431
+ }
432
+ if (!next.agentThinkingLevels.documenter && config.agentThinkingLevels.explorer) {
433
+ next.agentThinkingLevels.documenter = config.agentThinkingLevels.explorer;
434
+ }
420
435
  }
421
- if (!next.agentThinkingLevels.documenter && config.agentThinkingLevels.explorer) {
422
- next.agentThinkingLevels.documenter = config.agentThinkingLevels.explorer;
436
+ next.agentModels = keepAgentEntries(next.agentModels, enabled);
437
+ next.agentThinkingLevels = keepAgentEntries(next.agentThinkingLevels, enabled);
438
+ } else if (choice.startsWith("Configure")) {
439
+ // Per-agent loop: thinking Esc returns to that agent's model picker;
440
+ // model Esc returns to the agent picker; agent-picker Esc saves completed
441
+ // choices and returns to this settings menu.
442
+ let configuredAny = false;
443
+ while (true) {
444
+ const picked = await configureOneAgent(ctx, next);
445
+ if (!picked) break;
446
+ configuredAny = true;
447
+ next.agentModels = applyAgentModelChoice(next.agentModels, picked.name, picked.model);
448
+ if (picked.strength === AUTO_THINKING) delete next.agentThinkingLevels[picked.name];
449
+ else next.agentThinkingLevels[picked.name] = picked.strength;
423
450
  }
451
+ if (!configuredAny) continue;
452
+ await saveConfig(next, configPath);
453
+ ctx.ui.notify(`pi-subagents updated. Saved to ${configPath}`, "info");
454
+ config = next;
455
+ continue;
456
+ } else {
457
+ const updated = await updateRuntimeSetting(ctx, next);
458
+ if (updated === undefined) continue;
459
+ next = updated;
424
460
  }
425
- next.agentModels = keepAgentEntries(next.agentModels, enabled);
426
- next.agentThinkingLevels = keepAgentEntries(next.agentThinkingLevels, enabled);
427
- } else if (choice.startsWith("Configure")) {
428
- // Per-agent loop: model (+ thinking when the model exposes a choice), then
429
- // back to the agent picker so several agents can be set in one pass. Esc
430
- // at any step ends the loop; agents already configured in this pass are kept.
431
- let configuredAny = false;
432
- while (true) {
433
- const picked = await configureOneAgent(ctx, next);
434
- if (picked === undefined) break;
435
- configuredAny = true;
436
- next.agentModels = applyAgentModelChoice(next.agentModels, picked.name, picked.model);
437
- if (picked.strength === AUTO_THINKING) delete next.agentThinkingLevels[picked.name];
438
- else next.agentThinkingLevels[picked.name] = picked.strength;
439
- }
440
- if (!configuredAny) return notifyCancelled(ctx);
441
- } else {
442
- const updated = await updateRuntimeSetting(ctx, next);
443
- if (updated === undefined) return notifyCancelled(ctx);
444
- next = updated;
445
- }
446
461
 
447
- await saveConfig(next, configPath);
448
- ctx.ui.notify(`pi-subagents updated. Saved to ${configPath}`, "info");
449
- }
450
-
451
- function notifyCancelled(ctx: ExtensionCommandContext): void {
452
- ctx.ui.notify("pi-subagents setup cancelled.", "info");
462
+ await saveConfig(next, configPath);
463
+ ctx.ui.notify(`pi-subagents updated. Saved to ${configPath}`, "info");
464
+ return;
465
+ }
453
466
  }
454
467
 
455
468
  /** Entry point for the /subagents-setup command. */
@@ -462,7 +475,9 @@ export async function runSetup(ctx: ExtensionCommandContext, configPath: string
462
475
  const exists = await configExists(configPath);
463
476
  const config = await loadConfig(configPath);
464
477
  if (exists) await runMenu(ctx, configPath, config);
465
- else await runFullSetup(ctx, configPath, { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_ENABLED_AGENTS] });
478
+ else if (!(await runFullSetup(ctx, configPath, { ...DEFAULT_CONFIG, enabledAgents: [...DEFAULT_ENABLED_AGENTS] }))) {
479
+ ctx.ui.notify("pi-subagents setup cancelled.", "info");
480
+ }
466
481
  } catch (error) {
467
482
  ctx.ui.notify(`pi-subagents setup failed: ${errorMessage(error)}`, "error");
468
483
  }
package/src/spawn.ts CHANGED
@@ -327,7 +327,7 @@ export function getResultOutput(result: SingleResult): string {
327
327
  }
328
328
 
329
329
  export function buildResumePrompt(task: string, reason: string): string {
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. Original task: ${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.`;
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
333
  export function buildFallbackResumeReason(fromModel?: string): string {
@@ -351,6 +351,9 @@ export interface RunSingleOptions {
351
351
  sessionId?: string;
352
352
  /** Initial RPC prompt. Kept under the old name to limit caller churn. */
353
353
  stdinText?: string;
354
+ /** Refresh parent-derived tools immediately before every startup retry and
355
+ * selected-to-main fallback process is spawned. */
356
+ resolveAgentForAttempt?: (agent: AgentConfig) => AgentConfig;
354
357
  signal?: AbortSignal;
355
358
  onLive?: (event: SubagentLiveEvent) => void;
356
359
  makeDetails: (results: SingleResult[]) => SubagentDetails;
@@ -496,7 +499,10 @@ export async function runSingleAgentWithMainFallback(
496
499
  }
497
500
  const start = Date.now();
498
501
  try {
499
- lastResult = await runSingleAgent(opts);
502
+ const attemptOptions = opts.resolveAgentForAttempt
503
+ ? { ...opts, agent: opts.resolveAgentForAttempt(opts.agent) }
504
+ : opts;
505
+ lastResult = await runSingleAgent(attemptOptions);
500
506
  } catch (error) {
501
507
  const failed = await dispatchFailure(error);
502
508
  return controlledDisposition(opts, failed) ?? failed;
@@ -11,7 +11,12 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { existsSync } from "node:fs";
12
12
  import { rm } from "node:fs/promises";
13
13
  import { resolve } from "node:path";
14
- import { discoverAgents, isWriteCapableAgent, type AgentConfig } from "./agents.ts";
14
+ import {
15
+ discoverAgents,
16
+ isWriteCapableAgent,
17
+ resolveAgentTools,
18
+ type AgentConfig,
19
+ } from "./agents.ts";
15
20
  import { completionTriggersTurn, type CompletionMessageItem } from "./completion.ts";
16
21
  import {
17
22
  DEFAULT_THINKING_LEVEL,
@@ -43,7 +48,7 @@ import {
43
48
  resolveAgentModelRoute,
44
49
  resolveThinkingLevel,
45
50
  } from "./models.ts";
46
- import { monitor, sumUsage } from "./monitor.ts";
51
+ import { monitor, sumUsage, type ContinuationKind } from "./monitor.ts";
47
52
  import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
48
53
  import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
49
54
  import { forkRetainedSession } from "./session-fork.ts";
@@ -185,6 +190,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
185
190
  prompt?: string;
186
191
  worktree?: WorktreeIsolation;
187
192
  forkedFromRunId?: number;
193
+ continuationKind?: ContinuationKind;
188
194
  }
189
195
 
190
196
  interface ResumeReservation {
@@ -225,7 +231,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
225
231
  cwd: string | undefined,
226
232
  isolation: IsolationMode = "shared",
227
233
  existingThread?: SubagentThread,
228
- newObjectiveOnResume = false,
234
+ appendedObjectiveOnResume = false,
229
235
  environment?: DispatchEnvironment,
230
236
  seed?: SessionSeed,
231
237
  resumeReservation?: ResumeReservation,
@@ -239,8 +245,11 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
239
245
  const runCtx = environment?.ctx ?? ctx;
240
246
  const runConfig = environment?.config ?? config;
241
247
  const runAgents = environment?.agents ?? agents;
242
- const agent = runAgents.find((candidate) => candidate.name === agentName);
243
- if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
248
+ const discoveredAgent = runAgents.find((candidate) => candidate.name === agentName);
249
+ if (!discoveredAgent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
250
+ const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
251
+ resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
252
+ const agent = resolveLiveAgentTools(discoveredAgent);
244
253
  if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
245
254
  return {
246
255
  ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as worker, cleaner, or documenter.`),
@@ -288,6 +297,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
288
297
  const runId = existingThread?.id ?? monitor.addRun(agent.name, task, route.agent.model, thinkingLevel, {
289
298
  isolation,
290
299
  ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
300
+ ...(seed?.continuationKind ? { continuationKind: seed.continuationKind } : {}),
291
301
  });
292
302
  const generation = (existingThread?.generation ?? 0) + 1;
293
303
  const pending: SingleResult = {
@@ -302,7 +312,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
302
312
  ...(seed?.forkedFromRunId !== undefined ? { forkedFromRunId: seed.forkedFromRunId } : {}),
303
313
  };
304
314
  if (existingThread) {
305
- monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation);
315
+ monitor.restartRun(runId, agent.name, task, route.agent.model, thinkingLevel, isolation, {
316
+ elapsedMs: existingThread.elapsedMs,
317
+ continuationKind: appendedObjectiveOnResume ? "resume-appended" : "resume-retained",
318
+ });
306
319
  runtime.settledRuns.delete(runId);
307
320
  }
308
321
 
@@ -367,6 +380,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
367
380
  control,
368
381
  generationCompletion: Promise.resolve(),
369
382
  lifecycleVersion: 0,
383
+ elapsedMs: 0,
370
384
  sessionId: seed?.sessionId,
371
385
  sessionDir: seed?.sessionDir,
372
386
  forkedFromRunId: seed?.forkedFromRunId,
@@ -504,6 +518,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
504
518
  });
505
519
  };
506
520
 
521
+ const persistElapsedTime = (): void => {
522
+ thread.elapsedMs = monitor.getElapsedMs(runId) ?? thread.elapsedMs;
523
+ };
524
+
507
525
  thread.park = async (): Promise<"queued" | "active"> => {
508
526
  if (thread.retired) throw new Error(`Run #${runId} was retired by subagent_stop.`);
509
527
  if (thread.lifecycleOperation) throw new Error(`Run #${runId} is already handling ${thread.lifecycleOperation}.`);
@@ -544,7 +562,14 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
544
562
  thread.state = "parked";
545
563
  thread.queueController = undefined;
546
564
  runtime.runControllers.delete(runId);
565
+ const parkedRun = monitor.findRun(runId);
566
+ if (parkedRun?.managedWorkflow && parkedRun.task !== thread.task) {
567
+ // The active child row previously showed this stage objective. Once it
568
+ // disappears, keep the parked parent aligned with what resume retains.
569
+ monitor.setTask(runId, thread.task);
570
+ }
547
571
  monitor.setStatus(runId, "parked");
572
+ persistElapsedTime();
548
573
  return queued ? "queued" : "active";
549
574
  } finally {
550
575
  if (thread.lifecycleVersion === version && thread.lifecycleOperation === "park") {
@@ -822,6 +847,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
822
847
  prompt: forkObjective ?? FORK_CONTINUATION_PROMPT,
823
848
  worktree: childWorktree,
824
849
  forkedFromRunId: runId,
850
+ continuationKind: forkObjective ? "fork-appended" : "fork-retained",
825
851
  },
826
852
  );
827
853
  if (child.exitCode !== -1 || child.runId === undefined) {
@@ -877,6 +903,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
877
903
  {
878
904
  defaultCwd: executionCwd,
879
905
  agent: route.agent,
906
+ resolveAgentForAttempt: resolveLiveAgentTools,
880
907
  agentName,
881
908
  task,
882
909
  cwd: executionCwd,
@@ -891,7 +918,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
891
918
  ? {
892
919
  sessionId: priorSessionId,
893
920
  sessionDir: priorSessionDir,
894
- stdinText: seed?.prompt ?? (newObjectiveOnResume
921
+ stdinText: seed?.prompt ?? (appendedObjectiveOnResume
895
922
  ? task
896
923
  : buildResumePrompt(priorTask ?? task, "the retained thread was resumed")),
897
924
  }
@@ -955,9 +982,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
955
982
  const workflowPlan = getManagedWorkflowPlan(result, runConfig, workflowAvailability);
956
983
  if (workflowPlan && runtime.sessionActive) {
957
984
  thread.state = "running";
958
- // The stable parent row remains active until every internal writer and
959
- // reviewer settles. Internal rows are independently queryable but never
960
- // enter this top-level lifecycle or publish completions.
985
+ // The stable parent row now represents workflow ownership, not whichever
986
+ // model stage ran most recently. Internal rows own their exact role/model/
987
+ // thinking/timing telemetry and remain independently queryable.
988
+ monitor.setManagedWorkflow(runId, true);
961
989
  monitor.setStatus(runId, "running");
962
990
  monitor.setActivity(
963
991
  runId,
@@ -978,8 +1006,10 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
978
1006
  rememberLatest: (latest) => {
979
1007
  if (runtime.threads.get(runId) !== thread || thread.generation !== generation) return;
980
1008
  thread.lastResult = latest;
1009
+ // Retained control follows the newest child session, but the live parent
1010
+ // row keeps the original top-level role/model/usage. The active internal
1011
+ // row already owns the current stage's role and telemetry.
981
1012
  thread.agentName = latest.agent;
982
- monitor.setAgent(runId, latest.agent);
983
1013
  thread.task = latest.task;
984
1014
  thread.sessionId = latest.sessionId;
985
1015
  thread.sessionDir = latest.sessionDir;
@@ -1021,9 +1051,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
1021
1051
  thread.lifecycleOperation === "settle" &&
1022
1052
  !thread.retired;
1023
1053
  try {
1024
- // For isolated writers this is deliberately after the managed documenter
1025
- // and reviewer stages: every child sees the same worktree, then one
1026
- // lifecycle owner integrates the complete writer+docs state exactly once.
1054
+ // For isolated writers this is deliberately after the managed reviewer
1055
+ // and documentation stages: every child sees the same worktree, then one
1056
+ // lifecycle owner integrates the complete writer+fixes+docs state exactly once.
1027
1057
  await thread.finalizeIsolation(generation, result);
1028
1058
  if (!ownsSettlement()) return;
1029
1059
  if (workflowOutcome && isolation === "worktree") {
@@ -1041,6 +1071,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
1041
1071
  // Stamp the terminal monitor state before projecting it. This gives every
1042
1072
  // path a fixed endedAt even when the row is removed immediately.
1043
1073
  monitor.setStatus(runId, failed ? "failed" : "done");
1074
+ persistElapsedTime();
1044
1075
  if (!runtime.sessionActive || !ownsSettlement()) return;
1045
1076
 
1046
1077
  const modelLevel = failed && isModelLevelFailure(result);
@@ -1175,6 +1206,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
1175
1206
  if (!ownsSettlement()) return;
1176
1207
  thread.state = "failed";
1177
1208
  monitor.setStatus(runId, "failed");
1209
+ persistElapsedTime();
1178
1210
  finishRun(runId, "failed", { silent: true });
1179
1211
  runtime.registerRunResult(runId, crashed);
1180
1212
  runtime.runControllers.delete(runId);