pi-subagents 0.34.0 → 0.35.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +78 -9
  2. package/README.md +213 -32
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +91 -49
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +65 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +9 -1
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -127
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +34 -7
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +90 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. package/src/runs/background/wait.ts +0 -394
@@ -7,11 +7,13 @@ import {
7
7
  type AsyncJobState,
8
8
  type AsyncStartedEvent,
9
9
  type ControlEvent,
10
+ type SteeringNotice,
10
11
  type SubagentState,
11
12
  POLL_INTERVAL_MS,
12
13
  RESULTS_DIR,
13
14
  SUBAGENT_CONTROL_EVENT,
14
15
  SUBAGENT_CONTROL_INTERCOM_EVENT,
16
+ SUBAGENT_STEERING_NOTICE_EVENT,
15
17
  } from "../../shared/types.ts";
16
18
  import { readStatus } from "../../shared/utils.ts";
17
19
  import { normalizeParallelGroups } from "./parallel-groups.ts";
@@ -23,6 +25,7 @@ interface AsyncJobTrackerOptions {
23
25
  completionRetentionMs?: number;
24
26
  pollIntervalMs?: number;
25
27
  resultsDir?: string;
28
+ widgetEnabled?: boolean;
26
29
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
27
30
  now?: () => number;
28
31
  }
@@ -30,9 +33,20 @@ interface AsyncJobTrackerOptions {
30
33
  const CONTROL_EVENT_READ_CHUNK_BYTES = 64 * 1024;
31
34
  const MAX_CONTROL_EVENT_LINE_BYTES = 1024 * 1024;
32
35
  const CONTROL_EVENT_SCAN_WINDOW_BYTES = 2 * 1024 * 1024;
36
+ const MAX_RECENT_FLEET_JOBS = 20;
37
+
38
+ function rememberFleetJob(state: SubagentState, job: AsyncJobState): void {
39
+ state.fleetJobs ??= new Map();
40
+ state.fleetJobs.set(job.asyncId, job);
41
+ const terminal = [...state.fleetJobs.values()]
42
+ .filter((candidate) => candidate.status === "complete" || candidate.status === "failed" || candidate.status === "paused" || candidate.status === "stopped")
43
+ .sort((left, right) => (right.updatedAt ?? right.startedAt ?? 0) - (left.updatedAt ?? left.startedAt ?? 0));
44
+ for (const stale of terminal.slice(MAX_RECENT_FLEET_JOBS)) state.fleetJobs.delete(stale.asyncId);
45
+ }
33
46
 
34
47
  export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: SubagentState, asyncDirRoot: string, options: AsyncJobTrackerOptions = {}): {
35
48
  ensurePoller: () => void;
49
+ refreshWidget: (ctx: ExtensionContext) => void;
36
50
  handleStarted: (data: unknown) => void;
37
51
  handleComplete: (data: unknown) => void;
38
52
  resetJobs: (ctx?: ExtensionContext) => void;
@@ -41,10 +55,12 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
41
55
  const completionRetentionMs = options.completionRetentionMs ?? 10000;
42
56
  const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
43
57
  const resultsDir = options.resultsDir ?? RESULTS_DIR;
58
+ const steeringNoticeSeen = new Map<string, number>();
44
59
  const rerenderWidget = (ctx: ExtensionContext, jobs = Array.from(state.asyncJobs.values())) => {
45
- renderWidget(ctx, jobs);
60
+ renderWidget(ctx, options.widgetEnabled === false ? [] : jobs);
46
61
  ctx.ui.requestRender?.();
47
62
  };
63
+ const refreshWidget = (ctx: ExtensionContext) => rerenderWidget(ctx);
48
64
  const restoredControlEventCursor = (asyncDir: string) => {
49
65
  try {
50
66
  return fs.statSync(path.join(asyncDir, "events.jsonl")).size;
@@ -73,6 +89,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
73
89
  currentPath: run.currentPath,
74
90
  turnCount: run.turnCount,
75
91
  toolCount: run.toolCount,
92
+ steering: run.steering,
76
93
  mode: run.mode,
77
94
  agents: visibleSteps.map((step) => step.agent),
78
95
  currentStep: run.currentStep,
@@ -89,6 +106,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
89
106
  timeoutMs: run.timeoutMs,
90
107
  deadlineAt: run.deadlineAt,
91
108
  timedOut: run.timedOut,
109
+ stopped: run.stopped,
92
110
  turnBudget: run.turnBudget,
93
111
  turnBudgetExceeded: run.turnBudgetExceeded,
94
112
  wrapUpRequested: run.wrapUpRequested,
@@ -144,7 +162,24 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
144
162
  console.error(`Ignoring malformed async control event in '${eventsPath}':`, error);
145
163
  return;
146
164
  }
147
- if (!parsed || typeof parsed !== "object" || (parsed as { type?: unknown }).type !== "subagent.control") return;
165
+ if (!parsed || typeof parsed !== "object") return;
166
+ if ((parsed as { type?: unknown }).type === "subagent.steering.notice") {
167
+ const notice = parsed as Partial<SteeringNotice>;
168
+ if (typeof notice.requestId !== "string" || typeof notice.runId !== "string" || (notice.state !== "failed" && notice.state !== "partial" && notice.state !== "recovered") || typeof notice.message !== "string") return;
169
+ if (typeof state.currentSessionId === "string" && notice.currentSessionId !== state.currentSessionId) return;
170
+ const key = `${notice.runId}:${notice.requestId}:${notice.state}`;
171
+ if (steeringNoticeSeen.has(key)) return;
172
+ const now = Date.now();
173
+ steeringNoticeSeen.set(key, now);
174
+ if (steeringNoticeSeen.size > 200) {
175
+ for (const [seenKey, seenAt] of steeringNoticeSeen) {
176
+ if (now - seenAt > 10 * 60 * 1000 || steeringNoticeSeen.size > 200) steeringNoticeSeen.delete(seenKey);
177
+ }
178
+ }
179
+ pi.events.emit(SUBAGENT_STEERING_NOTICE_EVENT, { ...notice, source: "async", asyncDir: job.asyncDir, noticeText: notice.message });
180
+ return;
181
+ }
182
+ if ((parsed as { type?: unknown }).type !== "subagent.control") return;
148
183
  const record = parsed as { event?: ControlEvent; channels?: string[]; childIntercomTarget?: string; noticeText?: string; intercom?: { to?: string; message?: string } };
149
184
  if (!record.event || !Array.isArray(record.channels)) return;
150
185
  const payload = {
@@ -269,7 +304,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
269
304
  if (status) {
270
305
  const previousStatus = job.status;
271
306
  job.status = status.state;
272
- if (job.status !== "complete" && job.status !== "failed" && job.status !== "paused") cancelCleanup(job.asyncId);
307
+ if (job.status !== "complete" && job.status !== "failed" && job.status !== "paused" && job.status !== "stopped") cancelCleanup(job.asyncId);
273
308
  job.sessionId = status.sessionId ?? job.sessionId;
274
309
  job.activityState = status.activityState;
275
310
  job.lastActivityAt = status.lastActivityAt ?? job.lastActivityAt;
@@ -278,6 +313,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
278
313
  job.currentPath = status.currentPath;
279
314
  job.turnCount = status.turnCount ?? job.turnCount;
280
315
  job.toolCount = status.toolCount ?? job.toolCount;
316
+ job.steering = status.steering ?? job.steering;
281
317
  job.mode = status.mode;
282
318
  job.currentStep = status.currentStep ?? job.currentStep;
283
319
  job.chainStepCount = status.chainStepCount ?? job.chainStepCount;
@@ -308,12 +344,16 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
308
344
  job.timeoutMs = status.timeoutMs ?? job.timeoutMs;
309
345
  job.deadlineAt = status.deadlineAt ?? job.deadlineAt;
310
346
  job.timedOut = status.timedOut ?? job.timedOut;
347
+ job.stopped = status.stopped ?? job.stopped;
311
348
  job.turnBudget = status.turnBudget ?? job.turnBudget;
312
349
  job.turnBudgetExceeded = status.turnBudgetExceeded ?? job.turnBudgetExceeded;
313
350
  job.wrapUpRequested = status.wrapUpRequested ?? job.wrapUpRequested;
314
351
  job.sessionFile = status.sessionFile ?? job.sessionFile;
315
- if ((job.status === "complete" || job.status === "failed" || job.status === "paused") && !nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
316
- scheduleCleanup(job.asyncId);
352
+ if (job.status === "complete" || job.status === "failed" || job.status === "paused" || job.status === "stopped") {
353
+ rememberFleetJob(state, job);
354
+ if (!nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
355
+ scheduleCleanup(job.asyncId);
356
+ }
317
357
  }
318
358
  if (widgetRenderKey(job) !== widgetStateBefore) widgetChanged = true;
319
359
  continue;
@@ -328,6 +368,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
328
368
  job.status = "failed";
329
369
  job.updatedAt = Date.now();
330
370
  }
371
+ rememberFleetJob(state, job);
331
372
  if (!hasLiveNestedDescendants(job.nestedChildren) && !state.cleanupTimers.has(job.asyncId)) {
332
373
  scheduleCleanup(job.asyncId);
333
374
  }
@@ -374,6 +415,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
374
415
  turnBudget: info.turnBudget,
375
416
  controlEventCursor: 0,
376
417
  });
418
+ rememberFleetJob(state, state.asyncJobs.get(info.id)!);
377
419
  ensurePoller();
378
420
  if (state.lastUiContext) {
379
421
  rerenderWidget(state.lastUiContext);
@@ -381,14 +423,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
381
423
  };
382
424
 
383
425
  const handleComplete = (data: unknown) => {
384
- const result = data as { id?: string; success?: boolean; asyncDir?: string; sessionId?: string };
426
+ const result = data as { id?: string; success?: boolean; state?: AsyncJobState["status"]; asyncDir?: string; sessionId?: string; stopped?: boolean };
385
427
  if (typeof state.currentSessionId === "string" && result.sessionId !== state.currentSessionId) return;
386
428
  const asyncId = result.id;
387
429
  if (!asyncId) return;
388
430
  const job = state.asyncJobs.get(asyncId);
389
431
  let nestedRefreshFailed = false;
390
432
  if (job) {
391
- job.status = result.success ? "complete" : "failed";
433
+ job.status = result.state ?? (result.success ? "complete" : "failed");
434
+ job.stopped = result.stopped ?? job.stopped;
392
435
  job.updatedAt = Date.now();
393
436
  if (result.asyncDir) job.asyncDir = result.asyncDir;
394
437
  try {
@@ -398,6 +441,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
398
441
  console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
399
442
  }
400
443
  }
444
+ if (job) rememberFleetJob(state, job);
401
445
  if (state.lastUiContext) {
402
446
  rerenderWidget(state.lastUiContext);
403
447
  }
@@ -410,6 +454,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
410
454
  }
411
455
  state.cleanupTimers.clear();
412
456
  state.asyncJobs.clear();
457
+ state.fleetJobs?.clear();
413
458
  state.foregroundControls?.clear();
414
459
  state.lastForegroundControlId = null;
415
460
  state.resultFileCoalescer.clear();
@@ -430,12 +475,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
430
475
  return;
431
476
  }
432
477
  for (const run of runs) {
433
- state.asyncJobs.set(run.id, summaryToJob(run));
478
+ const job = summaryToJob(run);
479
+ state.asyncJobs.set(run.id, job);
480
+ rememberFleetJob(state, job);
434
481
  }
435
482
  if (runs.length === 0) return;
436
483
  ensurePoller();
437
484
  if (state.lastUiContext?.hasUI) rerenderWidget(state.lastUiContext);
438
485
  };
439
486
 
440
- return { ensurePoller, handleStarted, handleComplete, resetJobs, restoreActiveJobs };
487
+ return { ensurePoller, refreshWidget, handleStarted, handleComplete, resetJobs, restoreActiveJobs };
441
488
  }
@@ -1,12 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type SubagentState } from "../../shared/types.ts";
4
- import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
5
- import { deliverInterruptRequest } from "./control-channel.ts";
3
+ import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type SteeringRecoveryDescriptor } from "../../shared/types.ts";
4
+ import type { AgentConfig } from "../../agents/agents.ts";
5
+ import { validateAcceptanceInput } from "../shared/acceptance.ts";
6
+ import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
7
+ import { resolveTurnBudgetConfig } from "../shared/turn-budget.ts";
6
8
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
7
9
 
8
- export const ASYNC_RESUME_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
9
-
10
10
  export interface AsyncResumeParams {
11
11
  id?: string;
12
12
  runId?: string;
@@ -23,6 +23,7 @@ export interface AsyncResumeDeps {
23
23
 
24
24
  export interface AsyncResumeOptions {
25
25
  requireSessionFile?: boolean;
26
+ sessionId?: string;
26
27
  }
27
28
 
28
29
  export type AsyncResumeTarget = {
@@ -32,49 +33,13 @@ export type AsyncResumeTarget = {
32
33
  state: AsyncStatus["state"];
33
34
  agent: string;
34
35
  index: number;
35
- intercomTarget: string;
36
36
  cwd?: string;
37
37
  sessionFile?: string;
38
+ model?: string;
39
+ thinking?: string;
40
+ recoveryDescriptor?: SteeringRecoveryDescriptor;
38
41
  };
39
42
 
40
- type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => boolean;
41
-
42
- export function interruptLiveAsyncResumeTarget(input: {
43
- target: AsyncResumeTarget & { kind: "live" };
44
- state?: Pick<SubagentState, "asyncJobs">;
45
- kill?: KillFn;
46
- now?: () => number;
47
- resultsDir?: string;
48
- }): { ok: true; asyncId: string } | { ok: false; message: string } {
49
- const asyncId = input.target.runId;
50
- if (!input.target.asyncDir) {
51
- return { ok: false, message: `Async run ${asyncId} is live but does not have an async directory to interrupt.` };
52
- }
53
- const status = reconcileAsyncRun(input.target.asyncDir, { resultsDir: input.resultsDir, kill: input.kill, now: input.now }).status;
54
- if (!status || status.state !== "running" || typeof status.pid !== "number") {
55
- return { ok: false, message: `Async run ${asyncId} is live but no interrupt-capable runner pid was found.` };
56
- }
57
- try {
58
- deliverInterruptRequest({
59
- asyncDir: input.target.asyncDir,
60
- pid: status.pid,
61
- kill: input.kill,
62
- signal: ASYNC_RESUME_INTERRUPT_SIGNAL,
63
- now: input.now,
64
- source: "async-resume",
65
- });
66
- const tracked = input.state?.asyncJobs.get(asyncId);
67
- if (tracked) {
68
- tracked.activityState = undefined;
69
- tracked.updatedAt = input.now?.() ?? Date.now();
70
- }
71
- return { ok: true, asyncId };
72
- } catch (error) {
73
- const message = error instanceof Error ? error.message : String(error);
74
- return { ok: false, message: `Failed to interrupt async run ${asyncId}: ${message}` };
75
- }
76
- }
77
-
78
43
  interface AsyncResultFile {
79
44
  id?: string;
80
45
  runId?: string;
@@ -83,8 +48,11 @@ interface AsyncResultFile {
83
48
  state?: string;
84
49
  success?: boolean;
85
50
  cwd?: string;
51
+ sessionId?: string;
86
52
  sessionFile?: string;
87
- results?: Array<{ agent?: string; success?: boolean; sessionFile?: string; intercomTarget?: string }>;
53
+ model?: string;
54
+ thinking?: string;
55
+ results?: Array<{ agent?: string; success?: boolean; sessionFile?: string; intercomTarget?: string; model?: string; thinking?: string }>;
88
56
  }
89
57
 
90
58
  export interface AsyncRunLocation {
@@ -122,9 +90,11 @@ function validateResultFile(value: unknown, resultPath: string): AsyncResultFile
122
90
  const agent = validateOptionalString(child, "agent", resultPath, `results[${index}].agent`);
123
91
  const sessionFile = validateOptionalString(child, "sessionFile", resultPath, `results[${index}].sessionFile`);
124
92
  const intercomTarget = validateOptionalString(child, "intercomTarget", resultPath, `results[${index}].intercomTarget`);
93
+ const model = validateOptionalString(child, "model", resultPath, `results[${index}].model`);
94
+ const thinking = validateOptionalString(child, "thinking", resultPath, `results[${index}].thinking`);
125
95
  const success = child.success;
126
96
  if (success !== undefined && typeof success !== "boolean") throw new Error(`Invalid async result file '${resultPath}': results[${index}].success must be a boolean.`);
127
- return { agent, sessionFile, intercomTarget, ...(typeof success === "boolean" ? { success } : {}) };
97
+ return { agent, sessionFile, intercomTarget, model, thinking, ...(typeof success === "boolean" ? { success } : {}) };
128
98
  });
129
99
  }
130
100
  const success = data.success;
@@ -136,7 +106,10 @@ function validateResultFile(value: unknown, resultPath: string): AsyncResultFile
136
106
  mode: validateOptionalString(data, "mode", resultPath),
137
107
  state: validateOptionalString(data, "state", resultPath),
138
108
  cwd: validateOptionalString(data, "cwd", resultPath),
109
+ sessionId: validateOptionalString(data, "sessionId", resultPath),
139
110
  sessionFile: validateOptionalString(data, "sessionFile", resultPath),
111
+ model: validateOptionalString(data, "model", resultPath),
112
+ thinking: validateOptionalString(data, "thinking", resultPath),
140
113
  ...(typeof success === "boolean" ? { success } : {}),
141
114
  ...(results ? { results } : {}),
142
115
  };
@@ -252,7 +225,7 @@ export function resolveAsyncRunLocation(params: AsyncResumeParams, asyncDirRoot:
252
225
  }
253
226
 
254
227
  function resultState(result: AsyncResultFile): AsyncStatus["state"] {
255
- if (result.state === "complete" || result.state === "failed" || result.state === "paused" || result.state === "running" || result.state === "queued") {
228
+ if (result.state === "complete" || result.state === "failed" || result.state === "paused" || result.state === "stopped" || result.state === "running" || result.state === "queued") {
256
229
  return result.state;
257
230
  }
258
231
  return result.success ? "complete" : "failed";
@@ -268,12 +241,106 @@ function validateStatusForResume(status: AsyncStatus | null, source: string): vo
268
241
  if (!Array.isArray(status.steps)) throw new Error(`Invalid async status '${source}': steps must be an array.`);
269
242
  status.steps.forEach((step, index) => {
270
243
  if (!step || typeof step !== "object" || Array.isArray(step)) throw new Error(`Invalid async status '${source}': steps[${index}] must be an object.`);
271
- if (typeof step.agent !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].agent must be a string.`);
272
- if (step.sessionFile !== undefined && typeof step.sessionFile !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].sessionFile must be a string.`);
244
+ const stepRecord = step as Record<string, unknown>;
245
+ if (typeof stepRecord.agent !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].agent must be a string.`);
246
+ if (stepRecord.sessionFile !== undefined && typeof stepRecord.sessionFile !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].sessionFile must be a string.`);
247
+ if (stepRecord.model !== undefined && typeof stepRecord.model !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].model must be a string.`);
248
+ if (stepRecord.thinking !== undefined && typeof stepRecord.thinking !== "string") throw new Error(`Invalid async status '${source}': steps[${index}].thinking must be a string.`);
273
249
  });
274
250
  }
275
251
  }
276
252
 
253
+ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): SteeringRecoveryDescriptor | undefined {
254
+ if (!asyncDir) return undefined;
255
+ const descriptorPath = path.join(asyncDir, "recovery-descriptor.json");
256
+ if (!fs.existsSync(descriptorPath)) return undefined;
257
+ let value: unknown;
258
+ try {
259
+ value = JSON.parse(fs.readFileSync(descriptorPath, "utf-8"));
260
+ } catch (error) {
261
+ throw new Error(`Failed to parse async recovery descriptor '${descriptorPath}': ${getErrorMessage(error)}`, { cause: error instanceof Error ? error : undefined });
262
+ }
263
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
264
+ const parsed = value as Record<string, unknown>;
265
+ const allowedFields = new Set([
266
+ "version", "sourceRunId", "agent", "sessionFile", "cwd", "model", "fallbackModels", "thinking", "tools", "extensions",
267
+ "subagentOnlyExtensions", "mcpDirectTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritSkills", "skills",
268
+ "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "acceptance", "sessionDir", "artifactConfig",
269
+ "artifactsDir", "maxOutput", "controlConfig", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share",
270
+ ]);
271
+ for (const field of Object.keys(parsed)) {
272
+ if (!allowedFields.has(field)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': unknown field '${field}'.`);
273
+ }
274
+ const requiredStrings = ["sourceRunId", "agent", "cwd", "systemPromptMode", "outputMode"] as const;
275
+ for (const field of requiredStrings) {
276
+ if (typeof parsed[field] !== "string" || !(parsed[field] as string).trim()) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a non-empty string.`);
277
+ }
278
+ if (parsed.version !== 1) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': version must be 1.`);
279
+ if (parsed.systemPromptMode !== "append" && parsed.systemPromptMode !== "replace") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': systemPromptMode is invalid.`);
280
+ if (parsed.outputMode !== "inline" && parsed.outputMode !== "file-only") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': outputMode is invalid.`);
281
+ for (const field of ["inheritProjectContext", "inheritSkills", "share"] as const) {
282
+ if (typeof parsed[field] !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a boolean.`);
283
+ }
284
+ if (!Number.isInteger(parsed.maxSubagentDepth) || (parsed.maxSubagentDepth as number) < 0) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': maxSubagentDepth must be a non-negative integer.`);
285
+ for (const field of ["fallbackModels", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "skills", "skillPath"] as const) {
286
+ const item = parsed[field];
287
+ if (item !== undefined && (!Array.isArray(item) || item.some((entry) => typeof entry !== "string" || !entry.trim()))) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must contain non-empty strings.`);
288
+ }
289
+ if (parsed.systemPrompt !== undefined && typeof parsed.systemPrompt !== "string") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': systemPrompt must be a string.`);
290
+ for (const field of ["sessionFile", "model", "thinking", "agentFilePath", "outputPath", "sessionDir", "artifactsDir"] as const) {
291
+ if (parsed[field] !== undefined && (typeof parsed[field] !== "string" || !(parsed[field] as string).trim())) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a non-empty string.`);
292
+ }
293
+ if (parsed.completionGuard !== undefined && typeof parsed.completionGuard !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': completionGuard must be a boolean.`);
294
+ if (parsed.memory !== undefined) {
295
+ if (!parsed.memory || typeof parsed.memory !== "object" || Array.isArray(parsed.memory)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': memory must be an object.`);
296
+ const memory = parsed.memory as Record<string, unknown>;
297
+ if ((memory.scope !== "project" && memory.scope !== "user") || typeof memory.path !== "string" || !memory.path.trim()) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': memory is invalid.`);
298
+ }
299
+ if (parsed.absoluteDeadlineAt !== undefined && (!Number.isFinite(parsed.absoluteDeadlineAt) || (parsed.absoluteDeadlineAt as number) <= 0)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': absoluteDeadlineAt must be a positive timestamp.`);
300
+ if (parsed.initialTurnBudget !== undefined) {
301
+ const result = resolveTurnBudgetConfig(parsed.initialTurnBudget, "recoveryDescriptor.initialTurnBudget");
302
+ if (result.error) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${result.error}`);
303
+ }
304
+ if (parsed.initialToolBudget !== undefined) {
305
+ const result = validateToolBudgetConfig(parsed.initialToolBudget, "recoveryDescriptor.initialToolBudget");
306
+ if (result.error) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${result.error}`);
307
+ }
308
+ if (parsed.maxOutput !== undefined) {
309
+ if (!parsed.maxOutput || typeof parsed.maxOutput !== "object" || Array.isArray(parsed.maxOutput)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': maxOutput must be an object.`);
310
+ for (const field of ["bytes", "lines"] as const) {
311
+ const item = (parsed.maxOutput as Record<string, unknown>)[field];
312
+ if (item !== undefined && (!Number.isInteger(item) || (item as number) < 1)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': maxOutput.${field} must be a positive integer.`);
313
+ }
314
+ }
315
+ if (parsed.artifactConfig !== undefined) {
316
+ if (!parsed.artifactConfig || typeof parsed.artifactConfig !== "object" || Array.isArray(parsed.artifactConfig)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': artifactConfig must be an object.`);
317
+ const artifact = parsed.artifactConfig as Record<string, unknown>;
318
+ for (const field of ["enabled", "includeInput", "includeOutput", "includeJsonl", "includeMetadata"] as const) {
319
+ if (typeof artifact[field] !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': artifactConfig.${field} must be a boolean.`);
320
+ }
321
+ if (artifact.includeTranscript !== undefined && typeof artifact.includeTranscript !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': artifactConfig.includeTranscript must be a boolean.`);
322
+ if (!Number.isInteger(artifact.cleanupDays) || (artifact.cleanupDays as number) < 0) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': artifactConfig.cleanupDays must be a non-negative integer.`);
323
+ }
324
+ if (parsed.controlConfig !== undefined) {
325
+ if (!parsed.controlConfig || typeof parsed.controlConfig !== "object" || Array.isArray(parsed.controlConfig)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': controlConfig must be an object.`);
326
+ const control = parsed.controlConfig as Record<string, unknown>;
327
+ if (typeof control.enabled !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': controlConfig.enabled must be a boolean.`);
328
+ for (const field of ["needsAttentionAfterMs", "activeNoticeAfterMs", "failedToolAttemptsBeforeAttention"] as const) {
329
+ if (!Number.isInteger(control[field]) || (control[field] as number) < 1) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': controlConfig.${field} must be a positive integer.`);
330
+ }
331
+ for (const field of ["activeNoticeAfterTurns", "activeNoticeAfterTokens"] as const) {
332
+ if (control[field] !== undefined && (!Number.isInteger(control[field]) || (control[field] as number) < 1)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': controlConfig.${field} must be a positive integer.`);
333
+ }
334
+ if (!Array.isArray(control.notifyOn) || control.notifyOn.some((item) => item !== "active_long_running" && item !== "needs_attention")) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': controlConfig.notifyOn is invalid.`);
335
+ if (!Array.isArray(control.notifyChannels) || control.notifyChannels.some((item) => item !== "event" && item !== "async" && item !== "intercom")) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': controlConfig.notifyChannels is invalid.`);
336
+ }
337
+ if (parsed.acceptance !== undefined) {
338
+ const errors = validateAcceptanceInput(parsed.acceptance, "recoveryDescriptor.acceptance");
339
+ if (errors.length) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${errors.join(" ")}`);
340
+ }
341
+ return parsed as unknown as SteeringRecoveryDescriptor;
342
+ }
343
+
277
344
  function validateResumeSessionFile(runId: string, sessionFile: string): string {
278
345
  if (path.extname(sessionFile) !== ".jsonl") throw new Error(`Async run '${runId}' session file must be a .jsonl file: ${sessionFile}`);
279
346
  const resolved = path.resolve(sessionFile);
@@ -295,10 +362,16 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
295
362
  : undefined;
296
363
  const status = reconciliation?.status ?? null;
297
364
  validateStatusForResume(status, location.asyncDir ? path.join(location.asyncDir, "status.json") : "status.json");
365
+ const recoveryDescriptor = readAsyncRecoveryDescriptor(location.asyncDir);
298
366
  const result = location.resultPath ? readResultFile(location.resultPath) : undefined;
299
367
  const runId = status?.runId ?? result?.runId ?? result?.id ?? location.resolvedId ?? (location.asyncDir ? path.basename(location.asyncDir) : "unknown");
368
+ if (options.sessionId && ((status && status.sessionId !== options.sessionId) || (result && result.sessionId !== options.sessionId))) {
369
+ throw new Error(`Async run '${runId}' was not found in the active session.`);
370
+ }
371
+ if (recoveryDescriptor && recoveryDescriptor.sourceRunId !== runId) throw new Error(`Async run '${runId}' has a recovery descriptor for a different source run.`);
300
372
  const state = status?.state ?? (result ? resultState(result) : undefined);
301
373
  if (!state) throw new Error(`Status file not found for async run '${runId}'.`);
374
+ if (state === "stopped") throw new Error(`Async run '${runId}' was stopped and cannot be resumed. Start a new run instead.`);
302
375
 
303
376
  const statusSteps = status?.steps ?? [];
304
377
  const resultSteps = result?.results ?? [];
@@ -319,9 +392,11 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
319
392
  state,
320
393
  agent: selectedStep.agent,
321
394
  index: requestedIndex,
322
- intercomTarget: resolveSubagentIntercomTarget(runId, selectedStep.agent, requestedIndex),
323
395
  cwd: status?.cwd ?? result?.cwd,
324
396
  sessionFile: selectedStep.sessionFile ?? status?.sessionFile ?? result?.sessionFile,
397
+ model: selectedStep.model,
398
+ thinking: selectedStep.thinking,
399
+ ...(recoveryDescriptor ? { recoveryDescriptor } : {}),
325
400
  };
326
401
  }
327
402
  if (selectedStep?.status === "pending") throw new Error(`Async run '${runId}' child ${requestedIndex} is pending and has not started yet. Wait for it to run or complete before resuming.`);
@@ -341,9 +416,11 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
341
416
  state,
342
417
  agent: selected.step.agent,
343
418
  index: selected.index,
344
- intercomTarget: resolveSubagentIntercomTarget(runId, selected.step.agent, selected.index),
345
419
  cwd: status?.cwd ?? result?.cwd,
346
420
  sessionFile: selected.step.sessionFile ?? status?.sessionFile ?? result?.sessionFile,
421
+ model: selected.step.model,
422
+ thinking: selected.step.thinking,
423
+ ...(recoveryDescriptor ? { recoveryDescriptor } : {}),
347
424
  };
348
425
  }
349
426
  }
@@ -356,11 +433,14 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
356
433
  if (index < 0 || index >= stepCount) throw new Error(`Async run '${runId}' has ${stepCount} children. Index ${index} is out of range.`);
357
434
  const agent = statusSteps[index]?.agent ?? resultSteps[index]?.agent ?? result?.agent;
358
435
  if (!agent) throw new Error(`Could not determine child agent for async run '${runId}'.`);
436
+ if (recoveryDescriptor && recoveryDescriptor.agent !== agent) throw new Error(`Async run '${runId}' has a recovery descriptor for '${recoveryDescriptor.agent}', not '${agent}'.`);
359
437
  const sessionFile = statusSteps[index]?.sessionFile
360
438
  ?? resultSteps[index]?.sessionFile
361
439
  ?? (stepCount === 1 ? status?.sessionFile ?? result?.sessionFile : undefined);
362
440
  if (!sessionFile && requireSessionFile) throw new Error(`Async run '${runId}' child ${index} does not have a persisted session file to resume from.`);
363
441
  const resolvedSessionFile = sessionFile ? validateResumeSessionFile(runId, sessionFile) : undefined;
442
+ const stepModel = statusSteps[index]?.model ?? resultSteps[index]?.model ?? (stepCount === 1 ? result?.model : undefined);
443
+ const stepThinking = statusSteps[index]?.thinking ?? resultSteps[index]?.thinking ?? (stepCount === 1 ? result?.thinking : undefined);
364
444
 
365
445
  return {
366
446
  kind: "revive",
@@ -369,9 +449,36 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
369
449
  state,
370
450
  agent,
371
451
  index,
372
- intercomTarget: resolveSubagentIntercomTarget(runId, agent, index),
373
452
  cwd: status?.cwd ?? result?.cwd,
374
453
  ...(resolvedSessionFile ? { sessionFile: resolvedSessionFile } : {}),
454
+ ...(stepModel ? { model: stepModel } : {}),
455
+ ...(stepThinking ? { thinking: stepThinking } : {}),
456
+ ...(recoveryDescriptor ? { recoveryDescriptor } : {}),
457
+ };
458
+ }
459
+
460
+ export function applySteeringRecoveryAgentConfig(agentConfig: AgentConfig, descriptor: SteeringRecoveryDescriptor): AgentConfig {
461
+ return {
462
+ ...agentConfig,
463
+ model: descriptor.model,
464
+ fallbackModels: descriptor.fallbackModels ? [...descriptor.fallbackModels] : undefined,
465
+ thinking: descriptor.thinking,
466
+ tools: descriptor.tools ? [...descriptor.tools] : undefined,
467
+ extensions: descriptor.extensions ? [...descriptor.extensions] : undefined,
468
+ subagentOnlyExtensions: descriptor.subagentOnlyExtensions ? [...descriptor.subagentOnlyExtensions] : undefined,
469
+ mcpDirectTools: descriptor.mcpDirectTools ? [...descriptor.mcpDirectTools] : undefined,
470
+ systemPrompt: descriptor.systemPrompt,
471
+ systemPromptMode: descriptor.systemPromptMode,
472
+ inheritProjectContext: descriptor.inheritProjectContext,
473
+ inheritSkills: descriptor.inheritSkills,
474
+ skills: descriptor.skills ? [...descriptor.skills] : undefined,
475
+ skillPath: descriptor.skillPath ? [...descriptor.skillPath] : undefined,
476
+ filePath: descriptor.agentFilePath,
477
+ completionGuard: descriptor.completionGuard,
478
+ memory: descriptor.memory ? { ...descriptor.memory } : undefined,
479
+ output: descriptor.outputPath,
480
+ toolBudget: descriptor.initialToolBudget,
481
+ maxSubagentDepth: descriptor.maxSubagentDepth,
375
482
  };
376
483
  }
377
484