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
@@ -29,7 +29,19 @@ function exactAsyncLocation(id: string, asyncDirRoot: string, resultsDir: string
29
29
 
30
30
  function foregroundIds(state: SubagentState | undefined): string[] {
31
31
  if (!state) return [];
32
- return [...new Set([...state.foregroundControls.keys(), ...(state.foregroundRuns?.keys() ?? [])])];
32
+ const remembered = state.currentSessionId
33
+ ? [...(state.foregroundRuns?.values() ?? [])]
34
+ .filter((run) => run.sessionId === state.currentSessionId)
35
+ .map((run) => run.runId)
36
+ : [];
37
+ return [...new Set([...state.foregroundControls.keys(), ...remembered])];
38
+ }
39
+
40
+ function hasExactForegroundId(state: SubagentState | undefined, id: string): boolean {
41
+ if (!state) return false;
42
+ if (state.foregroundControls.has(id)) return true;
43
+ const remembered = state.foregroundRuns?.get(id);
44
+ return Boolean(remembered && state.currentSessionId && remembered.sessionId === state.currentSessionId);
33
45
  }
34
46
 
35
47
  function nestedScopeFromState(state: SubagentState | undefined): NestedRunResolutionScope | undefined {
@@ -58,7 +70,7 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
58
70
  const resultsDir = deps.resultsDir ?? RESULTS_DIR;
59
71
 
60
72
  const nestedScope = deps.nested ?? nestedScopeFromState(deps.state);
61
- if (deps.state?.foregroundControls.has(id) || deps.state?.foregroundRuns?.has(id)) return { kind: "foreground", id };
73
+ if (hasExactForegroundId(deps.state, id)) return { kind: "foreground", id };
62
74
  const exactAsync = exactAsyncLocation(id, asyncDirRoot, resultsDir);
63
75
  if (exactAsync) return { kind: "async", id, location: exactAsync };
64
76
  const exactNested = findNestedRunMatchesById(id, nestedScope ? { scope: nestedScope } : {});
@@ -6,7 +6,7 @@ import { formatAsyncResultTranscript, formatAsyncRunTranscript, formatNestedRunT
6
6
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
7
7
  import { formatModelThinking } from "../../shared/formatters.ts";
8
8
  import { formatActivityLabel } from "../../shared/status-format.ts";
9
- import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type Details, type ForegroundResumeRun, type NestedRunSummary, type SubagentState } from "../../shared/types.ts";
9
+ import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type Details, type ForegroundResumeRun, type NestedRunSummary, type SteeringStatus, type SubagentState } from "../../shared/types.ts";
10
10
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
11
11
  import { resolveAsyncRunLocation } from "./async-resume.ts";
12
12
  import { resolveSubagentRunId } from "./run-id-resolver.ts";
@@ -38,7 +38,8 @@ function hasExistingSessionFile(value: unknown): value is string {
38
38
  return typeof value === "string" && fs.existsSync(value);
39
39
  }
40
40
 
41
- function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown }>, fallbackSessionFile?: unknown): string {
41
+ function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown }>, fallbackSessionFile?: unknown, options: { stopped?: boolean } = {}): string {
42
+ if (options.stopped) return "Resume: unavailable; stopped runs are not resumable. Start a new run instead.";
42
43
  const knownChildren = children
43
44
  .map((child, index) => ({ child, index }))
44
45
  .filter(({ child }) => typeof child.agent === "string");
@@ -73,15 +74,15 @@ function nestedRunDisplayName(run: NestedRunSummary): string {
73
74
  return run.id;
74
75
  }
75
76
 
76
- function formatSteeringSummary(input: { steerCount?: number; lastSteerAt?: number }): string | undefined {
77
- const parts: string[] = [];
78
- if (input.steerCount !== undefined) parts.push(`${input.steerCount} steer${input.steerCount === 1 ? "" : "s"}`);
79
- if (typeof input.lastSteerAt === "number" && Number.isFinite(input.lastSteerAt)) parts.push(`last ${new Date(input.lastSteerAt).toISOString()}`);
80
- return parts.length ? parts.join(", ") : undefined;
77
+ function formatSteeringSummary(input: { steering?: SteeringStatus }): string | undefined {
78
+ const steering = input.steering;
79
+ if (!steering || steering.requested === 0) return undefined;
80
+ const lateAcknowledgments = steering.recent.reduce((count, request) => count + request.targets.filter((target) => target.lateDeliveredAt !== undefined).length, 0);
81
+ return `${steering.requested} requested, ${steering.scheduled} scheduled, ${steering.pending} pending, ${steering.delivered} delivered, ${steering.failed} failed, ${steering.recovered} recovered${lateAcknowledgments ? `, ${lateAcknowledgments} late acknowledged` : ""}`;
81
82
  }
82
83
 
83
84
  function rememberedForegroundChildOutput(child: ForegroundResumeRun["children"][number]): string {
84
- const outputPath = child.artifactPaths?.outputPath;
85
+ const outputPath = child.artifactPaths?.outputPath ?? child.savedOutputPath;
85
86
  if (outputPath && fs.existsSync(outputPath)) {
86
87
  try {
87
88
  const artifactOutput = fs.readFileSync(outputPath, "utf-8").trim();
@@ -107,24 +108,29 @@ function formatRememberedForegroundStatus(run: ForegroundResumeRun): string {
107
108
  `${child.index + 1}. ${child.agent} ${child.status}`,
108
109
  child.exitCode !== undefined ? `exit ${child.exitCode}` : undefined,
109
110
  child.detachedReason ? `detached: ${child.detachedReason}` : undefined,
111
+ child.acceptance ? `acceptance: ${child.acceptance.status}` : undefined,
112
+ child.error ? `error: ${child.error}` : undefined,
110
113
  output ? `output: ${output.slice(0, 160)}` : undefined,
111
114
  ].filter(Boolean);
112
115
  lines.push(parts.join(", "));
113
116
  if (child.sessionFile) lines.push(` Session: ${child.sessionFile}`);
114
117
  if (child.transcriptPath) lines.push(` Transcript: ${child.transcriptPath}`);
115
118
  if (child.artifactPaths?.outputPath) lines.push(` Output: ${child.artifactPaths.outputPath}`);
119
+ if (child.savedOutputPath && child.savedOutputPath !== child.artifactPaths?.outputPath) lines.push(` Saved output: ${child.savedOutputPath}`);
120
+ if (child.outputSaveError) lines.push(` Output warning: ${child.outputSaveError}`);
116
121
  if (child.transcriptError) lines.push(` Transcript warning: ${child.transcriptError}`);
117
122
  }
118
123
  lines.push("", `Status: subagent({ action: "status", id: "${run.runId}" })`);
119
124
  if (run.children.length === 1) lines.push(`Transcript: subagent({ action: "status", id: "${run.runId}", view: "transcript" })`);
120
125
  else lines.push(`Transcript: subagent({ action: "status", id: "${run.runId}", index: 0, view: "transcript" })`);
121
- const resumable = run.children.find((child) => child.status !== "detached" && hasExistingSessionFile(child.sessionFile));
122
- if (resumable) {
126
+ const detached = run.children.some((child) => child.status === "detached");
127
+ const resumable = run.children.find((child) => hasExistingSessionFile(child.sessionFile));
128
+ if (detached) {
129
+ lines.push(`Recovery: reply to the supervisor request first, then wait with subagent_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
130
+ } else if (resumable) {
123
131
  lines.push(run.children.length === 1
124
132
  ? `Revive: subagent({ action: "resume", id: "${run.runId}", message: "..." })`
125
133
  : `Revive child: subagent({ action: "resume", id: "${run.runId}", index: ${resumable.index}, message: "..." })`);
126
- } else if (run.children.some((child) => child.status === "detached")) {
127
- lines.push("Recovery: child detached for intercom coordination; status will show recovered output after the child exits when Pi can observe it.");
128
134
  } else {
129
135
  lines.push("Resume: unavailable; no child session file was persisted.");
130
136
  }
@@ -147,6 +153,8 @@ function formatRememberedForegroundTranscript(run: ForegroundResumeRun, options:
147
153
  child.sessionFile ? `Session: ${child.sessionFile}` : undefined,
148
154
  child.transcriptPath ? `Transcript: ${child.transcriptPath}` : undefined,
149
155
  child.artifactPaths?.outputPath ? `Output: ${child.artifactPaths.outputPath}` : undefined,
156
+ child.savedOutputPath && child.savedOutputPath !== child.artifactPaths?.outputPath ? `Saved output: ${child.savedOutputPath}` : undefined,
157
+ child.outputSaveError ? `Output warning: ${child.outputSaveError}` : undefined,
150
158
  ].filter((line): line is string => Boolean(line));
151
159
  lines.push("Result transcript tail:");
152
160
  if (outputLines.length === 0) lines.push(" (no recovered final output available yet)");
@@ -388,7 +396,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
388
396
  if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
389
397
  if (status.state === "running") lines.push(`Steer running child: subagent({ action: "steer", id: "${status.runId}", message: "..." })`);
390
398
  if (status.state !== "running") {
391
- lines.push(formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile));
399
+ lines.push(formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile, { stopped: status.state === "stopped" || status.stopped === true }));
392
400
  }
393
401
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
394
402
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
@@ -409,11 +417,11 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
409
417
  return { content: [{ type: "text", text: message }], isError: true, details: { mode: "single", results: [] } };
410
418
  }
411
419
  }
412
- const status = data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
420
+ const status = data.state === "stopped" ? "stopped" : data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
413
421
  const runId = data.runId ?? data.id ?? resolvedId;
414
422
  const lines = [`Run: ${runId}`, `State: ${status}`, `Result: ${resultPath}`];
415
423
  const children = Array.isArray(data.results) ? data.results : data.agent ? [{ agent: data.agent, sessionFile: data.sessionFile }] : [];
416
- lines.push(formatResumeGuidance(runId, children, data.sessionFile));
424
+ lines.push(formatResumeGuidance(runId, children, data.sessionFile, { stopped: status === "stopped" }));
417
425
  if (data.summary) lines.push("", data.summary);
418
426
  return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
419
427
  } catch (error) {
@@ -12,6 +12,7 @@ import {
12
12
  type ExtensionConfig,
13
13
  } from "../../shared/types.ts";
14
14
  import type { SubagentParamsLike } from "../foreground/subagent-executor.ts";
15
+ import { validateExecutionAcceptance } from "../shared/acceptance.ts";
15
16
 
16
17
  export const SCHEDULED_RUNS_DIR = path.join(TEMP_ROOT_DIR, "scheduled-subagent-runs");
17
18
  export const SCHEDULED_RUN_ACTIONS = ["schedule", "schedule-list", "schedule-status", "schedule-cancel"] as const;
@@ -228,6 +229,8 @@ function sanitizeScheduledParams(params: SubagentParamsLike): { params?: Subagen
228
229
  if (params.context === "fork") return { error: "Scheduled subagent runs require fresh context. Forked parent-session context is not safe at fire time." };
229
230
  if (params.async === false) return { error: "Scheduled subagent runs are always async; omit async or set async: true." };
230
231
  if (params.clarify === true) return { error: "Scheduled subagent runs cannot open clarify UI; omit clarify or set clarify: false." };
232
+ const acceptanceErrors = validateExecutionAcceptance(params);
233
+ if (acceptanceErrors.length > 0) return { error: acceptanceErrors.join(" ") };
231
234
 
232
235
  const {
233
236
  action: _action,
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
4
  import { RESULTS_DIR, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
5
+ import { resolveEffectiveThinking } from "../../shared/model-info.ts";
5
6
  import { normalizeParallelGroups } from "./parallel-groups.ts";
6
7
  import { nestedSummaryFromAsyncStatus, projectNestedEvents, resolveNestedAsyncDir, writeNestedEvent, type NestedRoute } from "../shared/nested-events.ts";
7
8
 
@@ -109,20 +110,30 @@ interface ResultChildOutcome {
109
110
  error?: string;
110
111
  sessionFile?: string;
111
112
  model?: string;
113
+ thinking?: string;
112
114
  attemptedModels?: string[];
113
115
  modelAttempts?: NonNullable<AsyncStatus["steps"]>[number]["modelAttempts"];
114
116
  }
115
117
 
116
118
  interface ResultRepairData {
117
- state: "complete" | "failed" | "paused";
119
+ state: "complete" | "failed" | "paused" | "stopped";
118
120
  results?: ResultChildOutcome[];
119
121
  }
120
122
 
121
123
  function readResultRepairData(resultPath: string): ResultRepairData | undefined {
122
124
  try {
123
- const data = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { success?: boolean; state?: string; exitCode?: number; results?: ResultChildOutcome[] };
124
- const state = data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
125
- return { state, ...(Array.isArray(data.results) ? { results: data.results } : {}) };
125
+ const data = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { success?: boolean; state?: string; exitCode?: number; results?: unknown };
126
+ const state = data.success ? "complete" : data.state === "stopped" ? "stopped" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
127
+ const results = Array.isArray(data.results)
128
+ ? data.results.map((entry, index) => {
129
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return {};
130
+ const child = entry as ResultChildOutcome;
131
+ if (child.model !== undefined && typeof child.model !== "string") throw new Error(`Invalid async result file '${resultPath}': results[${index}].model must be a string.`);
132
+ if (child.thinking !== undefined && typeof child.thinking !== "string") throw new Error(`Invalid async result file '${resultPath}': results[${index}].thinking must be a string.`);
133
+ return child;
134
+ })
135
+ : undefined;
136
+ return { state, ...(results ? { results } : {}) };
126
137
  } catch (error) {
127
138
  if (isNotFoundError(error)) return undefined;
128
139
  throw new Error(`Failed to read async result file '${resultPath}': ${getErrorMessage(error)}`, {
@@ -131,7 +142,7 @@ function readResultRepairData(resultPath: string): ResultRepairData | undefined
131
142
  }
132
143
  }
133
144
 
134
- function childState(overallState: ResultRepairData["state"], child: ResultChildOutcome | undefined): "complete" | "failed" | "paused" {
145
+ function childState(overallState: ResultRepairData["state"], child: ResultChildOutcome | undefined): "complete" | "failed" | "paused" | "stopped" {
135
146
  if (child?.success === true) return "complete";
136
147
  if (child?.success === false) return "failed";
137
148
  return overallState;
@@ -144,22 +155,27 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
144
155
  if (step.status !== "running" && step.status !== "pending") return step;
145
156
  const child = repair.results?.[index];
146
157
  const state = childState(repair.state, child);
158
+ const model = child?.model ?? step.model;
159
+ const thinking = resolveEffectiveThinking(model, child?.thinking ?? step.thinking);
147
160
  return {
148
161
  ...step,
149
162
  status: state === "complete" ? "complete" as const : state,
150
163
  endedAt: step.endedAt ?? now,
151
164
  durationMs: step.startedAt !== undefined && step.durationMs === undefined ? Math.max(0, now - step.startedAt) : step.durationMs,
152
165
  exitCode: step.exitCode ?? (state === "complete" || state === "paused" ? 0 : 1),
153
- error: state === "failed" ? step.error ?? child?.error : step.error,
166
+ error: state === "failed" || state === "stopped" ? step.error ?? child?.error : step.error,
167
+ stopped: state === "stopped" ? true : step.stopped,
154
168
  sessionFile: step.sessionFile ?? child?.sessionFile,
155
- model: step.model ?? child?.model,
156
- attemptedModels: step.attemptedModels ?? child?.attemptedModels,
157
- modelAttempts: step.modelAttempts ?? child?.modelAttempts,
169
+ model,
170
+ thinking,
171
+ attemptedModels: child?.attemptedModels ?? step.attemptedModels,
172
+ modelAttempts: child?.modelAttempts ?? step.modelAttempts,
158
173
  };
159
174
  });
160
175
  return {
161
176
  ...status,
162
177
  state: repair.state,
178
+ ...(repair.state === "stopped" ? { stopped: true } : {}),
163
179
  activityState: undefined,
164
180
  lastUpdate: now,
165
181
  endedAt: status.endedAt ?? now,
@@ -267,7 +283,7 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
267
283
  }
268
284
 
269
285
  function terminal(state: AsyncStatus["state"]): boolean {
270
- return state === "complete" || state === "failed" || state === "paused";
286
+ return state === "complete" || state === "failed" || state === "paused" || state === "stopped";
271
287
  }
272
288
 
273
289
  function* nestedRuns(children: NestedRunSummary[] | undefined): Generator<NestedRunSummary> {
@@ -330,6 +346,12 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
330
346
  const startedStatus = !status && options.startedRun ? buildStartedStatus(asyncDir, options.startedRun, now) : undefined;
331
347
  const effectiveStatus = status ?? startedStatus;
332
348
  if (!effectiveStatus) return { status: null, repaired: false };
349
+ const statusPath = path.join(asyncDir, "status.json");
350
+ for (const [index, step] of (effectiveStatus.steps ?? []).entries()) {
351
+ const stepRecord = step as Record<string, unknown>;
352
+ if (stepRecord.model !== undefined && typeof stepRecord.model !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].model must be a string.`);
353
+ if (stepRecord.thinking !== undefined && typeof stepRecord.thinking !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].thinking must be a string.`);
354
+ }
333
355
 
334
356
  const runId = effectiveStatus.runId || path.basename(asyncDir);
335
357
  const resultPath = path.join(options.resultsDir ?? RESULTS_DIR, `${runId}.json`);
@@ -0,0 +1,237 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
4
+ import type {
5
+ AsyncStatus,
6
+ ResolvedToolBudget,
7
+ ResolvedTurnBudget,
8
+ SteerActionResult,
9
+ SteeringRecoveryDescriptor,
10
+ SteeringRequestStatus,
11
+ SteeringStatus,
12
+ SteeringTargetState,
13
+ SteeringTargetStatus,
14
+ } from "../../shared/types.ts";
15
+ import { readStatus } from "../../shared/utils.ts";
16
+
17
+ export const MAX_STEERING_REQUESTS = 20;
18
+ export const STEERING_MESSAGE_PREVIEW_LIMIT = 160;
19
+
20
+ export function createSteeringStatus(): SteeringStatus {
21
+ return { requested: 0, scheduled: 0, pending: 0, delivered: 0, failed: 0, recovered: 0, recent: [] };
22
+ }
23
+
24
+ export function steeringStatus(status: Pick<AsyncStatus, "steering">): SteeringStatus {
25
+ return status.steering ?? createSteeringStatus();
26
+ }
27
+
28
+ export function recordSteeringRequest(
29
+ status: SteeringStatus,
30
+ input: { id: string; requestedAt: number; source?: string; message: string; targets: Array<{ index: number; state: SteeringTargetState; reason?: string }> },
31
+ ): SteeringRequestStatus {
32
+ const existing = status.recent.find((request) => request.id === input.id);
33
+ if (existing) return existing;
34
+ const request: SteeringRequestStatus = {
35
+ id: input.id,
36
+ requestedAt: input.requestedAt,
37
+ ...(input.source ? { source: input.source } : {}),
38
+ messagePreview: input.message.slice(0, STEERING_MESSAGE_PREVIEW_LIMIT),
39
+ targets: input.targets.map((target) => ({ index: target.index, state: target.state, ...(target.reason ? { reason: target.reason } : {}) })),
40
+ };
41
+ status.requested++;
42
+ status.lastRequestedAt = input.requestedAt;
43
+ status.recent = [...status.recent, request].slice(-MAX_STEERING_REQUESTS);
44
+ for (const target of request.targets) incrementStateCount(status, target.state);
45
+ return request;
46
+ }
47
+
48
+ function incrementStateCount(status: SteeringStatus, state: SteeringTargetState): void {
49
+ if (state === "scheduled") status.scheduled++;
50
+ else if (state === "routed") status.pending++;
51
+ else if (state === "delivered" || state === "late") status.delivered++;
52
+ else if (state === "failed") status.failed++;
53
+ else if (state === "recovered") status.recovered++;
54
+ }
55
+
56
+ export function updateSteeringTarget(
57
+ status: SteeringStatus,
58
+ requestId: string,
59
+ index: number,
60
+ state: SteeringTargetState,
61
+ now: number,
62
+ fields: Pick<SteeringTargetStatus, "reason" | "replacementRunId"> = {},
63
+ ): SteeringTargetStatus | undefined {
64
+ const request = status.recent.find((candidate) => candidate.id === requestId);
65
+ const target = request?.targets.find((candidate) => candidate.index === index);
66
+ if (!target) return undefined;
67
+ if (state === "late" && target.state === "recovered") {
68
+ if (target.lateDeliveredAt === undefined) {
69
+ target.lateDeliveredAt = now;
70
+ status.delivered++;
71
+ status.lastDeliveredAt = now;
72
+ }
73
+ if (fields.reason) target.reason = fields.reason;
74
+ return target;
75
+ }
76
+ if (target.state === state) {
77
+ if (state === "routed" && target.routedAt === undefined) target.routedAt = now;
78
+ if (state === "delivered" && target.deliveredAt === undefined) target.deliveredAt = now;
79
+ if (state === "late" && target.lateDeliveredAt === undefined) target.lateDeliveredAt = now;
80
+ if (fields.reason) target.reason = fields.reason;
81
+ if (fields.replacementRunId) target.replacementRunId = fields.replacementRunId;
82
+ return target;
83
+ }
84
+ if (target.state === "routed" && state !== "routed") status.pending = Math.max(0, status.pending - 1);
85
+ target.state = state;
86
+ if (state === "routed") target.routedAt = now;
87
+ if (state === "delivered") {
88
+ target.deliveredAt = now;
89
+ status.lastDeliveredAt = now;
90
+ }
91
+ if (state === "late") {
92
+ target.lateDeliveredAt = now;
93
+ status.lastDeliveredAt = now;
94
+ }
95
+ if (state === "failed") target.failedAt = now;
96
+ if (state === "recovered") target.recoveredAt = now;
97
+ if (fields.reason) target.reason = fields.reason;
98
+ if (fields.replacementRunId) target.replacementRunId = fields.replacementRunId;
99
+ incrementStateCount(status, state);
100
+ return target;
101
+ }
102
+
103
+ export function findSteeringRequest(status: SteeringStatus, requestId: string): SteeringRequestStatus | undefined {
104
+ return status.recent.find((request) => request.id === requestId);
105
+ }
106
+
107
+ export function actionResultFromSteeringStatus(status: SteeringStatus, sourceRunId: string, requestId: string, replacementRunId?: string): SteerActionResult | undefined {
108
+ const request = findSteeringRequest(status, requestId);
109
+ if (!request) return undefined;
110
+ const targets = request.targets.map((target) => ({
111
+ index: target.index,
112
+ state: target.state,
113
+ ...(target.deliveredAt !== undefined ? { deliveredAt: target.deliveredAt } : {}),
114
+ ...(target.lateDeliveredAt !== undefined ? { lateDeliveredAt: target.lateDeliveredAt } : {}),
115
+ ...(target.reason ? { reason: target.reason } : {}),
116
+ ...(target.replacementRunId ? { replacementRunId: target.replacementRunId } : {}),
117
+ }));
118
+ const states = targets.map((target) => target.state);
119
+ let state: SteerActionResult["state"] = "pending";
120
+ if (states.length > 0 && states.every((candidate) => candidate === "delivered")) state = "delivered";
121
+ else if (states.length > 0 && states.every((candidate) => candidate === "scheduled")) state = "scheduled";
122
+ else if (states.length > 0 && states.every((candidate) => candidate === "recovered")) state = "recovered";
123
+ else if (states.length > 0 && states.every((candidate) => candidate === "failed" || candidate === "late")) state = "failed";
124
+ else if (states.some((candidate) => candidate === "failed" || candidate === "late") && states.some((candidate) => candidate !== "failed" && candidate !== "late")) state = "partial";
125
+ const effectiveReplacementRunId = replacementRunId ?? request.targets.find((target) => target.replacementRunId)?.replacementRunId;
126
+ return { requestId, state, sourceRunId, ...(effectiveReplacementRunId ? { replacementRunId: effectiveReplacementRunId } : {}), targets };
127
+ }
128
+
129
+ export function steeringActionIsTerminal(result: SteerActionResult | undefined): boolean {
130
+ return result?.state === "delivered" || result?.state === "scheduled" || result?.state === "partial" || result?.state === "recovered" || result?.state === "failed";
131
+ }
132
+
133
+ export function terminalSteeringNoticeState(status: SteeringStatus, requestId: string): "failed" | "partial" | undefined {
134
+ const request = status.recent.find((candidate) => candidate.id === requestId);
135
+ if (!request || request.targets.some((target) => target.state === "routed" || target.state === "scheduled")) return undefined;
136
+ const hasSuccess = request.targets.some((target) => target.state === "delivered" || target.state === "recovered");
137
+ const hasFailure = request.targets.some((target) => target.state === "failed" || target.state === "late");
138
+ if (hasSuccess && hasFailure) return "partial";
139
+ return hasFailure ? "failed" : undefined;
140
+ }
141
+
142
+ export function claimSteeringRecovery(
143
+ asyncDir: string,
144
+ input: { requestId: string; sourceRunId: string; committedAt: number },
145
+ ): { claimPath: string; markerPath: string } {
146
+ const recoveryDir = path.join(asyncDir, "control", "steer-recovery");
147
+ fs.mkdirSync(recoveryDir, { recursive: true, mode: 0o700 });
148
+ const claimPath = path.join(recoveryDir, "claim.json");
149
+ let fd: number;
150
+ try {
151
+ fd = fs.openSync(claimPath, "wx", 0o600);
152
+ } catch (error) {
153
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
154
+ throw new Error("Another steering recovery is already committed for this source run.");
155
+ }
156
+ throw error;
157
+ }
158
+ let writeError: unknown;
159
+ try {
160
+ fs.writeFileSync(fd, JSON.stringify({ version: 1, ...input }, null, 2), "utf-8");
161
+ fs.fsyncSync(fd);
162
+ } catch (error) {
163
+ writeError = error;
164
+ } finally {
165
+ fs.closeSync(fd);
166
+ }
167
+ if (writeError !== undefined) {
168
+ fs.rmSync(claimPath, { force: true });
169
+ throw writeError;
170
+ }
171
+ const markerPath = path.join(recoveryDir, `${Buffer.from(input.requestId).toString("base64url")}.json`);
172
+ try {
173
+ writePrivateAtomicJson(markerPath, { version: 1, ...input });
174
+ } catch (error) {
175
+ fs.rmSync(claimPath, { force: true });
176
+ throw error;
177
+ }
178
+ return { claimPath, markerPath };
179
+ }
180
+
181
+ export function readSteeringStatus(asyncDir: string): SteeringStatus | undefined {
182
+ return readStatus(asyncDir)?.steering;
183
+ }
184
+
185
+ export function remainingSteeringRecoveryLimits(
186
+ descriptor: Pick<SteeringRecoveryDescriptor, "absoluteDeadlineAt" | "initialTurnBudget" | "initialToolBudget">,
187
+ status: Pick<AsyncStatus, "turnBudget" | "turnCount" | "toolBudget" | "toolCount">,
188
+ now = Date.now(),
189
+ ): { timeoutMs?: number; absoluteDeadlineAt?: number; turnBudget?: ResolvedTurnBudget; toolBudget?: ResolvedToolBudget } {
190
+ const limits: { timeoutMs?: number; absoluteDeadlineAt?: number; turnBudget?: ResolvedTurnBudget; toolBudget?: ResolvedToolBudget } = {};
191
+ if (descriptor.absoluteDeadlineAt !== undefined) {
192
+ const timeoutMs = descriptor.absoluteDeadlineAt - now;
193
+ if (timeoutMs <= 0) throw new Error("Source run has no remaining deadline budget; it remains paused.");
194
+ limits.timeoutMs = timeoutMs;
195
+ limits.absoluteDeadlineAt = descriptor.absoluteDeadlineAt;
196
+ }
197
+ if (descriptor.initialTurnBudget) {
198
+ const consumed = status.turnBudget?.turnCount ?? status.turnCount ?? 0;
199
+ const totalRemaining = descriptor.initialTurnBudget.maxTurns + descriptor.initialTurnBudget.graceTurns - consumed;
200
+ if (totalRemaining <= 0) throw new Error("Source run has no remaining turn budget; it remains paused.");
201
+ const softRemaining = Math.max(0, descriptor.initialTurnBudget.maxTurns - consumed);
202
+ limits.turnBudget = softRemaining > 0
203
+ ? { maxTurns: softRemaining, graceTurns: totalRemaining - softRemaining }
204
+ : { maxTurns: 1, graceTurns: totalRemaining - 1 };
205
+ }
206
+ if (descriptor.initialToolBudget) {
207
+ const consumed = status.toolBudget?.toolCount ?? status.toolCount ?? 0;
208
+ const hard = descriptor.initialToolBudget.hard - consumed;
209
+ if (hard <= 0) throw new Error("Source run has no remaining tool budget; it remains paused.");
210
+ const soft = descriptor.initialToolBudget.soft === undefined ? undefined : descriptor.initialToolBudget.soft - consumed;
211
+ limits.toolBudget = {
212
+ hard,
213
+ ...(soft !== undefined && soft > 0 && soft < hard ? { soft } : {}),
214
+ block: descriptor.initialToolBudget.block,
215
+ };
216
+ }
217
+ return limits;
218
+ }
219
+
220
+ export async function waitForSteeringAction(input: {
221
+ asyncDir: string;
222
+ sourceRunId: string;
223
+ requestId: string;
224
+ timeoutMs: number;
225
+ signal?: AbortSignal;
226
+ }): Promise<SteerActionResult | undefined> {
227
+ const deadline = Date.now() + input.timeoutMs;
228
+ while (Date.now() <= deadline) {
229
+ if (input.signal?.aborted) return undefined;
230
+ const status = readSteeringStatus(input.asyncDir);
231
+ const result = status ? actionResultFromSteeringStatus(status, input.sourceRunId, input.requestId) : undefined;
232
+ if (steeringActionIsTerminal(result)) return result;
233
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.min(50, Math.max(1, deadline - Date.now()))));
234
+ }
235
+ const status = readSteeringStatus(input.asyncDir);
236
+ return status ? actionResultFromSteeringStatus(status, input.sourceRunId, input.requestId) : undefined;
237
+ }