ennodia 0.4.1 → 0.5.1

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.
@@ -156,11 +156,7 @@ export class PlanAdviceManager {
156
156
  this.maxAdvice = Math.max(1, options.maxAdvice ?? DEFAULT_MAX_ADVICE);
157
157
  }
158
158
 
159
- start(input: PlanAdviceManagerStartInput): PlanAdviceView {
160
- if (this.shuttingDown) {
161
- throw new Error("PlanAdviceManager is shutting down.");
162
- }
163
-
159
+ estimateBudget(input: PlanAdviceManagerStartInput): BudgetCheck {
164
160
  const advisorPrompt = buildPlanAdvisorPrompt(input.prompt, input.inventory);
165
161
  const advisorBudget = checkBudgetLimits(
166
162
  estimateTaskBatchBudget({
@@ -173,6 +169,15 @@ export class PlanAdviceManager {
173
169
  input.budget,
174
170
  );
175
171
  assertBudgetWithinLimits(advisorBudget);
172
+ return advisorBudget;
173
+ }
174
+
175
+ start(input: PlanAdviceManagerStartInput): PlanAdviceView {
176
+ if (this.shuttingDown) {
177
+ throw new Error("PlanAdviceManager is shutting down.");
178
+ }
179
+ const advisorPrompt = buildPlanAdvisorPrompt(input.prompt, input.inventory);
180
+ const advisorBudget = this.estimateBudget(input);
176
181
 
177
182
  // The Advisor receives the closed inventory snapshot, not the target
178
183
  // workspace. A fresh empty cwd also avoids accidentally inheriting the
@@ -286,7 +291,7 @@ export class PlanAdviceManager {
286
291
  };
287
292
  }
288
293
 
289
- authorizeExecution(
294
+ validateExecution(
290
295
  id: string,
291
296
  expectedPlanDigest: string,
292
297
  currentInventory: AdvisorInventorySnapshot,
@@ -324,9 +329,15 @@ export class PlanAdviceManager {
324
329
  throw new Error("Advised plan changed after approval; no tasks were started.");
325
330
  }
326
331
 
327
- // Keep this final state change after every caller-supplied validation. The
328
- // callback is synchronous, so no second authorization can interleave.
329
332
  validate?.(structuredClone(compiled.plan));
333
+ return structuredClone(compiled.plan);
334
+ }
335
+
336
+ authorizeExecution(id: string, expectedPlanDigest: string, currentInventory: AdvisorInventorySnapshot, validate?: (plan: AdvisorExecutionPlan) => void): AdvisorExecutionPlan {
337
+ const plan = this.validateExecution(id, expectedPlanDigest, currentInventory, validate);
338
+ const record = this.require(id);
339
+ // Validation and consumption remain synchronous and single-use. Async
340
+ // readiness checks happen before this and cannot consume a blocked plan.
330
341
  record.status = "consumed";
331
342
  record.consumedAtMs = Date.now();
332
343
  record.endedAtMs = record.consumedAtMs;
@@ -335,7 +346,7 @@ export class PlanAdviceManager {
335
346
  message: "Validated plan authorized for one execution.",
336
347
  });
337
348
 
338
- return structuredClone(compiled.plan);
349
+ return plan;
339
350
  }
340
351
 
341
352
  async shutdown(options: { deadlineMs?: number } = {}): Promise<void> {
package/src/planner.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import type { HarnessDiscovery } from "./harnesses";
2
2
  import { hasMultimodalInput, MULTIMODAL_INPUT_GUIDANCE } from "./harnesses";
3
+ import { harnessAttention, readinessBlocked, type HarnessAttention } from "./readiness";
3
4
  import {
4
5
  CATEGORY_HARNESS_PRIORITIES,
5
6
  type RouteCategory,
6
7
  } from "./priority";
7
8
 
8
9
  export type RoutePlan = {
10
+ actionRequired?: HarnessAttention[];
9
11
  category: RouteCategory;
10
12
  reasons: string[];
11
13
  candidates: string[];
@@ -25,7 +27,7 @@ export function planRoute(
25
27
  options: PlanRouteOptions = {},
26
28
  ): RoutePlan {
27
29
  const lower = prompt.toLowerCase();
28
- const runnable = harnesses.filter((harness) => harness.runnable);
30
+ const runnable = harnesses.filter((harness) => harness.runnable && !readinessBlocked(harness.readiness));
29
31
  const runnableIds = new Set(runnable.map((harness) => harness.id));
30
32
  const reasons: string[] = [];
31
33
  const audioInspectionSignals = /\bnative audio\b|\baudio listening\b|\blistening comparison\b|\blisten to\b[^.!?\n]{0,80}\b(audio|recordings?|files?|samples?)\b|\b(compare|assess|judge)\b[^.!?\n]{0,60}\b(audio|recordings?|voice|sound)\b/.test(lower);
@@ -92,6 +94,7 @@ export function planRoute(
92
94
  const compareSuggested = parallelSuggested;
93
95
 
94
96
  return {
97
+ ...(harnessAttention(harnesses.filter(h => h.runnable)).length ? { actionRequired: harnessAttention(harnesses.filter(h => h.runnable)) } : {}),
95
98
  category,
96
99
  reasons,
97
100
  candidates,
@@ -0,0 +1,302 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { stripVTControlCharacters } from "node:util";
6
+ import type { CommandSpec, HarnessAdapter, HarnessDiscovery, HarnessRunInput } from "./harnesses";
7
+ import { signalOwnedProcess } from "./process";
8
+
9
+ export type ReadinessState = "unverified" | "ready" | "login-required" | "credentials-expired" | "setup-required" | "model-unavailable" | "quota-exhausted" | "unreachable" | "updating";
10
+ export type HarnessReadiness = {
11
+ state: ReadinessState;
12
+ checkedAt: string;
13
+ model?: string;
14
+ message: string;
15
+ recovery?: { command: string[]; instruction: string; recheck: { tool: "ennodia_check_harnesses"; harnessIds: string[]; model?: string; verify: boolean } };
16
+ upgrade?: { status: "succeeded" | "failed" | "deferred" | "unsupported"; checkedAt: string; command?: string[]; message: string };
17
+ };
18
+ export type HarnessAttention = { harnessId: string; harnessName: string; readiness: HarnessReadiness };
19
+ export type HarnessCheckInput = { harnessIds?: string[]; model?: string; cwd?: string; refresh?: boolean; verify?: boolean; upgrade?: boolean };
20
+ export type PreflightReport = { checks: HarnessAttention[]; modelProbes: number; estimatedInputTokens: number; usage: "unknown" };
21
+ export type CommandResult = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean };
22
+ export type MaintenanceCommandRunner = (spec: CommandSpec, timeoutMs: number) => Promise<CommandResult>;
23
+ export type HarnessReadinessOptions = {
24
+ runCommand?: MaintenanceCommandRunner;
25
+ now?: () => number;
26
+ stateDir?: string;
27
+ autoUpgrade?: boolean;
28
+ isBusy?: () => boolean;
29
+ };
30
+
31
+ const BLOCKED = new Set<ReadinessState>(["login-required", "credentials-expired", "setup-required", "model-unavailable", "quota-exhausted", "unreachable", "updating"]);
32
+ export function readinessBlocked(readiness?: HarnessReadiness): boolean { return Boolean(readiness && BLOCKED.has(readiness.state)); }
33
+ export function harnessAttention(harnesses: HarnessDiscovery[]): HarnessAttention[] {
34
+ return harnesses.filter(h => readinessBlocked(h.readiness)).map(h => ({ harnessId: h.id, harnessName: h.name, readiness: h.readiness! }));
35
+ }
36
+ export class HarnessReadinessError extends Error {
37
+ constructor(readonly actionRequired: HarnessAttention[]) {
38
+ super(`Review not started. ${actionRequired.map(a => `${a.harnessName}: ${a.readiness.message}`).join(" ")}`);
39
+ this.name = "HarnessReadinessError";
40
+ }
41
+ }
42
+
43
+ /** Recognize native failure envelopes, never quoted prose inside an answer. */
44
+ export function nativeReadinessFailure(stdout: string, stderr: string, allowPlainStdout = false): ReadinessState | undefined {
45
+ const stderrLines = stripVTControlCharacters(stderr).split(/\r?\n/);
46
+ const lines = [...stderrLines, ...stripVTControlCharacters(stdout).split(/\r?\n/)];
47
+ for (const [index, line] of lines.entries()) {
48
+ let message = line.trim();
49
+ try {
50
+ const event = JSON.parse(line);
51
+ if (event.type === "error") message = typeof event.message === "string" ? event.message : event.error?.message ?? "";
52
+ else if (event.type === "turn.failed") message = event.error?.message ?? "";
53
+ else continue;
54
+ } catch {
55
+ // Only status/nonce checks can distinguish plain stdout from worker prose.
56
+ if (index >= stderrLines.length && !allowPlainStdout) continue;
57
+ }
58
+ message = stripVTControlCharacters(message).trim();
59
+ if (/^(?:Error:\s*)?(?:This |The |Your )?API key (?:has expired|is expired)\b/i.test(message)) return "credentials-expired";
60
+ if (/^(?:(?:API )?Error:\s*)?(?:Failed to authenticate\b|OAuth session expired\b|Authentication failed\b|Not logged in\b|Unauthorized\b|401\b|Re-authentication required\b|No access token found\b|Token refresh failed\b|Failed to refresh (?:OAuth|token)\b|Your authentication token .*expired\b)/i.test(message)) return "login-required";
61
+ if (/^(?:Error:\s*)?(?:You've hit your usage limit\b|quota (?:exceeded|exhausted)\b|insufficient (?:credits|balance)\b|rate limit exceeded\b|Add credits to continue\b)/i.test(message)) return "quota-exhausted";
62
+ if (/^(?:Error:\s*)?(?:model .* (?:not found|not available|not supported)|unknown model\b|ModelNotFoundError\b)/i.test(message)) return "model-unavailable";
63
+ if (/^(?:Error:\s*)?(?:No (?:API key|provider|credentials)\b|Missing .*API.?KEY\b|unrecognized arguments\b|unknown (?:option|command)\b)/i.test(message)) return "setup-required";
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ function recipe(id: string): { auth?: string[]; login: string[]; update?: string[] } {
69
+ switch (id) {
70
+ case "codex": return { auth: ["login", "status"], login: ["login"] };
71
+ case "claude-code": return { auth: ["auth", "status", "--json"], login: ["auth", "login"], update: ["update"] };
72
+ case "opencode": return { auth: ["auth", "list"], login: ["auth", "login"], update: ["upgrade"] };
73
+ case "kilo": return { auth: ["auth", "list"], login: ["auth", "login"], update: ["upgrade"] };
74
+ case "hermes-agent": return { auth: ["auth", "list"], login: ["model"], update: ["update", "--yes"] };
75
+ case "kiro": return { auth: ["whoami", "--format", "json"], login: ["login"], update: ["update", "--non-interactive"] };
76
+ case "antigravity": return { login: [], update: ["update"] };
77
+ default: return { login: [] };
78
+ }
79
+ }
80
+
81
+ /** Preserve the installed distribution and channel. Never force an upgrade. */
82
+ export function harnessUpgradeCommand(h: HarnessDiscovery): string[] | undefined {
83
+ if (!h.commandPath) return undefined;
84
+ let actual: string;
85
+ try { actual = realpathSync(h.commandPath); } catch { return undefined; }
86
+ const brew = Bun.which("brew");
87
+ const cask = /\/Caskroom\/([^/]+)\//.exec(actual)?.[1];
88
+ const formula = /\/Cellar\/([^/]+)\//.exec(actual)?.[1];
89
+ const distribution = cask ?? formula;
90
+ const distributions: Record<string, string[]> = { codex: ["codex"], "claude-code": ["claude-code", "claude-code@latest"], opencode: ["opencode"], kilo: ["kilo"], kiro: ["kiro-cli"], "hermes-agent": ["hermes", "hermes-agent"], antigravity: ["antigravity", "antigravity-cli"], cline: ["cline"] };
91
+ if (distribution) return brew && distributions[h.id]?.includes(distribution) ? [brew, "upgrade", ...(cask ? ["--cask", "--greedy", "--no-quit"] : []), distribution] : undefined;
92
+ if (h.id === "codex") {
93
+ const npm = Bun.which("npm");
94
+ return npm && /\/node_modules\/@openai\/codex\//.test(actual) ? [npm, "install", "-g", "@openai/codex@latest"] : undefined;
95
+ }
96
+ const args = recipe(h.id).update;
97
+ return args ? [h.commandPath, ...args] : undefined;
98
+ }
99
+
100
+ const messages: Record<ReadinessState, string> = {
101
+ unverified: "Model access is unverified; a small request is required before review.",
102
+ ready: "The selected route returned a verified response.",
103
+ "login-required": "Sign-in is required before review.",
104
+ "credentials-expired": "The configured provider API key has expired. Replace the key in the native CLI/provider settings; signing in again will not renew it.",
105
+ "setup-required": "Install or repair the native CLI/provider configuration before review.",
106
+ "model-unavailable": "The selected model is unavailable on this route.",
107
+ "quota-exhausted": "Provider quota or credits are exhausted; sign-in will not resolve it.",
108
+ unreachable: "The readiness check did not complete; inspect connectivity or native setup before retrying.",
109
+ updating: "An upgrade is in progress. Recheck after it completes.",
110
+ };
111
+
112
+ /** Core owns readiness, failure memory and maintenance; native CLIs own credentials. */
113
+ export class HarnessReadinessManager {
114
+ private readonly cache = new Map<string, { at: number; value: HarnessReadiness }>();
115
+ private readonly flights = new Map<string, Promise<HarnessReadiness>>();
116
+ private readonly updates = new Map<string, NonNullable<HarnessReadiness["upgrade"]>>();
117
+ private readonly busyHarnesses = new Set<string>();
118
+ private readonly upgradeFlights = new Map<string, Promise<void>>();
119
+ private readonly abort = new AbortController();
120
+ private closing = false;
121
+ private readonly runner: MaintenanceCommandRunner;
122
+ private readonly now: () => number;
123
+ private readonly stateDir: string;
124
+ readonly autoUpgrade: boolean;
125
+ get stateDirectory(): string { return this.stateDir; }
126
+ constructor(private readonly options: HarnessReadinessOptions = {}) {
127
+ this.runner = options.runCommand ?? ((spec, timeout) => runMaintenanceCommand(spec, timeout, this.abort.signal));
128
+ this.now = options.now ?? Date.now;
129
+ this.stateDir = options.stateDir ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "ennodia", "harnesses");
130
+ this.autoUpgrade = options.autoUpgrade ?? process.env.ENNODIA_AUTO_UPDATE !== "false";
131
+ }
132
+ private key(h: HarnessDiscovery, model?: string, cwd?: string): string {
133
+ // Environment values may be secret: hash them, never expose or persist them.
134
+ const environment = createHash("sha256").update(JSON.stringify(process.env)).digest("hex");
135
+ return JSON.stringify([h.id, h.commandPath, h.version, model, resolve(cwd ?? process.cwd()), environment]);
136
+ }
137
+ private value(h: HarnessDiscovery, state: ReadinessState, model?: string): HarnessReadiness {
138
+ const r = recipe(h.id);
139
+ const result: HarnessReadiness = { state, checkedAt: new Date(this.now()).toISOString(), model, message: messages[state], upgrade: this.updates.get(h.id) };
140
+ if (state !== "ready") result.recovery = {
141
+ command: h.commandPath ? [h.commandPath, ...(state === "login-required" || state === "credentials-expired" || state === "unverified" ? r.login : ["--help"])] : [],
142
+ instruction: !h.commandPath ? "Install the supported native CLI, then recheck."
143
+ : state === "unverified" ? "Run the targeted recheck to verify model access. Sign in only when a login blocker is reported."
144
+ : state === "login-required" ? "Run the native command in an interactive terminal using the intended account/provider, then recheck."
145
+ : state === "credentials-expired" ? "Replace the expired API key through the native credential flow for the intended provider, then recheck the same route. Signing in again will not renew the key."
146
+ : state === "quota-exhausted" ? "Restore credits or quota through the intended provider, then recheck the same route. Do not repeat sign-in or switch to a different model silently."
147
+ : "Inspect the reported blocker through the native CLI; then recheck the same route. Do not change account, provider or model silently.",
148
+ recheck: { tool: "ennodia_check_harnesses", harnessIds: [h.id], ...(model ? { model } : {}), verify: true },
149
+ };
150
+ return result;
151
+ }
152
+ describe(h: HarnessDiscovery, state: ReadinessState, model?: string): HarnessReadiness { return this.value(h, state, model); }
153
+ invalidate(id: string): void {
154
+ for (const [key] of this.cache) if (JSON.parse(key)[0] === id) this.cache.delete(key);
155
+ }
156
+ private installationState(h: HarnessDiscovery): { stamp: string; lock: string } {
157
+ const identity = createHash("sha256").update(h.commandPath ?? h.id).digest("hex").slice(0, 20);
158
+ return { stamp: join(this.stateDir, `${identity}.json`), lock: join(this.stateDir, `${identity}.lock`) };
159
+ }
160
+ private clearDeadLease(lock: string): void {
161
+ if (!existsSync(lock)) return;
162
+ let owner: number;
163
+ try { owner = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")).pid; }
164
+ catch {
165
+ // A process can die between mkdir and recording its ownership.
166
+ try { if (this.now() - lstatSync(lock).mtimeMs > 120_000) rmSync(lock, { recursive: true, force: true }); } catch { /* raced with release */ }
167
+ return;
168
+ }
169
+ if (!Number.isInteger(owner) || owner < 1) return;
170
+ try { process.kill(owner, 0); }
171
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") rmSync(lock, { recursive: true, force: true }); }
172
+ }
173
+ async check(h: HarnessDiscovery, adapter?: HarnessAdapter, input: HarnessCheckInput = {}): Promise<HarnessReadiness> {
174
+ if (this.closing) throw new Error("Harness maintenance is shutting down.");
175
+ const { lock } = this.installationState(h);
176
+ this.clearDeadLease(lock);
177
+ if (this.busyHarnesses.has(h.id) || existsSync(lock)) return this.value(h, "updating", input.model);
178
+ const key = this.key(h, input.model, input.cwd);
179
+ const existing = this.cache.get(key);
180
+ if (!input.refresh && existing && (!input.verify || existing.value.state !== "unverified") && this.now() - existing.at < (existing.value.state === "ready" ? 300_000 : 30_000)) return existing.value;
181
+ const flightKey = `${key}:${Boolean(input.verify)}`;
182
+ const flight = this.flights.get(flightKey);
183
+ if (flight) return flight;
184
+ const pending = this.performCheck(h, adapter, input).catch(() => this.value(h, "unreachable", input.model)).then(value => {
185
+ this.cache.set(key, { at: this.now(), value });
186
+ return value;
187
+ }).finally(() => this.flights.delete(flightKey));
188
+ this.flights.set(flightKey, pending);
189
+ return pending;
190
+ }
191
+ private async performCheck(h: HarnessDiscovery, adapter: HarnessAdapter | undefined, input: HarnessCheckInput): Promise<HarnessReadiness> {
192
+ if (!h.runnable || !h.commandPath || !adapter?.buildCommand) return this.value(h, "setup-required", input.model);
193
+ const { lock } = this.installationState(h);
194
+ this.clearDeadLease(lock);
195
+ if (this.busyHarnesses.has(h.id) || existsSync(lock)) return this.value(h, "updating", input.model);
196
+ const auth = recipe(h.id).auth;
197
+ if (auth) {
198
+ const result = await this.runner({ command: h.commandPath, args: auth }, 5_000);
199
+ const state = nativeReadinessFailure(result.stdout, result.stderr, true);
200
+ if (state) return this.value(h, state, input.model);
201
+ if (h.id === "claude-code") {
202
+ try { if (JSON.parse(result.stdout).loggedIn === false) return this.value(h, "login-required", input.model); } catch { /* verification below */ }
203
+ }
204
+ if (result.timedOut) return this.value(h, "unreachable", input.model);
205
+ if (result.exitCode !== 0) return this.value(h, ["codex", "claude-code", "kiro"].includes(h.id) ? "login-required" : "setup-required", input.model);
206
+ }
207
+ if (!input.verify) return this.value(h, "unverified", input.model);
208
+ const dir = mkdtempSync(join(tmpdir(), "ennodia-readiness-"));
209
+ const nonce = `ENNODIA_READY_${randomUUID().replaceAll("-", "")}`;
210
+ try {
211
+ const spec = adapter.buildCommand(h.commandPath, { prompt: `Reply with exactly ${nonce}. Do not use tools, read files, or perform any other work.`, cwd: dir, model: input.model, timeoutMs: 20_000, finalMessagePath: join(dir, "answer.txt"), nativeSandbox: adapter.supportsNativeSandbox ? "read-only" : undefined });
212
+ const result = await this.runner(spec, 25_000);
213
+ const failure = nativeReadinessFailure(result.stdout, result.stderr, true);
214
+ if (failure) return this.value(h, failure, input.model);
215
+ if (result.timedOut || result.exitCode !== 0 || adapter.failureReason?.(result.stdout, result.stderr)) return this.value(h, "unreachable", input.model);
216
+ let answer = adapter.readinessAnswer ? adapter.readinessAnswer(result.stdout) ?? "" : result.stdout;
217
+ try { answer = readFileSync(join(dir, "answer.txt"), "utf8"); } catch { /* native text output */ }
218
+ // An echoed prompt is not success. Require a standalone returned nonce.
219
+ if (!stripVTControlCharacters(answer).split(/\r?\n/).some(line => line.trim() === nonce)) return this.value(h, "setup-required", input.model);
220
+ return this.value(h, "ready", input.model);
221
+ } finally { rmSync(dir, { recursive: true, force: true }); }
222
+ }
223
+ observe(h: HarnessDiscovery, input: Pick<HarnessRunInput, "model" | "cwd">, result: { status: string; stdout: string; stderr: string; timedOut: boolean; hasOutput?: boolean }): void {
224
+ if (this.closing) return;
225
+ if (result.status === "cancelled" || result.timedOut) return;
226
+ const failure = result.status === "failed" ? nativeReadinessFailure(result.stdout, result.stderr) : undefined;
227
+ if (failure) this.invalidate(h.id);
228
+ if (failure || (result.status === "succeeded" && result.hasOutput)) {
229
+ this.cache.set(this.key(h, input.model, input.cwd), { at: this.now(), value: this.value(h, failure ?? "ready", input.model) });
230
+ }
231
+ }
232
+ /** Upgrade at most daily across server restarts; use an exclusive per-install lease. */
233
+ async upgrade(h: HarnessDiscovery, force = false): Promise<void> {
234
+ if (this.closing) throw new Error("Harness maintenance is shutting down.");
235
+ const existing = this.upgradeFlights.get(h.id);
236
+ if (existing) return existing;
237
+ const pending = this.performUpgrade(h, force).finally(() => this.upgradeFlights.delete(h.id));
238
+ this.upgradeFlights.set(h.id, pending);
239
+ return pending;
240
+ }
241
+ private async performUpgrade(h: HarnessDiscovery, force: boolean): Promise<void> {
242
+ const command = harnessUpgradeCommand(h);
243
+ const checkedAt = new Date(this.now()).toISOString();
244
+ const save = (status: NonNullable<HarnessReadiness["upgrade"]>["status"], message: string) => {
245
+ this.updates.set(h.id, { status, checkedAt, command, message }); this.invalidate(h.id);
246
+ };
247
+ if (!command) { save("unsupported", "No supported updater was found for this installation. Use its original installer."); return; }
248
+ if (this.options.isBusy?.() || this.flights.size > 0) { save("deferred", "Upgrade deferred while Ennodia work is active."); return; }
249
+ const { stamp, lock } = this.installationState(h);
250
+ try {
251
+ mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
252
+ if (!force) {
253
+ try {
254
+ const previous = JSON.parse(readFileSync(stamp, "utf8"));
255
+ if (this.now() - previous.at < 86_400_000) {
256
+ if (previous.result) this.updates.set(h.id, previous.result);
257
+ return;
258
+ }
259
+ } catch { /* first check */ }
260
+ }
261
+ this.clearDeadLease(lock);
262
+ try { mkdirSync(lock); writeFileSync(join(lock, "owner.json"), JSON.stringify({ pid: process.pid }), { mode: 0o600 }); } catch {
263
+ save("deferred", "Another maintenance process holds the installation lock. Recheck later."); return;
264
+ }
265
+ } catch { save("failed", "Maintenance state could not be written; no upgrade was attempted."); return; }
266
+ this.busyHarnesses.add(h.id);
267
+ try {
268
+ writeFileSync(stamp, JSON.stringify({ at: this.now() }), { mode: 0o600 });
269
+ const result = await this.runner({ command: command[0]!, args: command.slice(1) }, 120_000);
270
+ save(result.exitCode === 0 && !result.timedOut ? "succeeded" : "failed", result.exitCode === 0 && !result.timedOut ? "Native updater completed; version and readiness must be rechecked." : "Native updater did not complete successfully. Run the displayed update command interactively; no force flags were used.");
271
+ writeFileSync(stamp, JSON.stringify({ at: this.now(), result: this.updates.get(h.id) }), { mode: 0o600 });
272
+ } catch { save("failed", "Native updater failed to start. Run the displayed command interactively."); }
273
+ finally { this.busyHarnesses.delete(h.id); rmSync(lock, { recursive: true, force: true }); }
274
+ }
275
+ async shutdown(): Promise<void> {
276
+ this.closing = true;
277
+ this.abort.abort();
278
+ await Promise.allSettled([...this.flights.values(), ...this.upgradeFlights.values()]);
279
+ }
280
+ }
281
+
282
+ export async function runMaintenanceCommand(spec: CommandSpec, timeoutMs: number, signal?: AbortSignal): Promise<CommandResult> {
283
+ if (signal?.aborted) throw new Error("Harness maintenance was cancelled.");
284
+ const child = Bun.spawn([spec.command, ...spec.args], { cwd: spec.cwd, env: { ...process.env, ...spec.env }, stdin: spec.stdin ? "pipe" : "ignore", stdout: "pipe", stderr: "pipe", detached: process.platform !== "win32" });
285
+ if (spec.stdin && child.stdin) { child.stdin.write(spec.stdin); child.stdin.end(); }
286
+ let timedOut = false;
287
+ const readers = [child.stdout.getReader(), child.stderr.getReader()];
288
+ const chunks = ["", ""];
289
+ const kill = () => {
290
+ signalOwnedProcess(child, "SIGKILL", process.platform !== "win32");
291
+ for (const reader of readers) void reader.cancel().catch(() => {});
292
+ };
293
+ const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
294
+ signal?.addEventListener("abort", kill, { once: true });
295
+ try {
296
+ await Promise.all(readers.map(async (reader, index) => {
297
+ const decoder = new TextDecoder();
298
+ try { for (;;) { const { done, value } = await reader.read(); if (done) break; chunks[index] = (chunks[index] + decoder.decode(value, { stream: true })).slice(-32_000); } } finally { reader.releaseLock(); }
299
+ }));
300
+ return { exitCode: await child.exited, stdout: chunks[0]!, stderr: chunks[1]!, timedOut };
301
+ } finally { clearTimeout(timer); signal?.removeEventListener("abort", kill); signalOwnedProcess(child, "SIGKILL", process.platform !== "win32"); }
302
+ }
package/src/runs.ts CHANGED
@@ -6,8 +6,9 @@ import {
6
6
  type BudgetCheck,
7
7
  type BudgetLimits,
8
8
  } from "./budget";
9
- import { MAX_PROMPT_CANDIDATE_CHARS, type CompareManager, type CompareView } from "./compare";
9
+ import { MAX_PROMPT_CANDIDATE_CHARS, type CompareManager, type CompareView, type KeepInMindNote } from "./compare";
10
10
  import { diagnoseTasks, type TaskDiagnosis } from "./diagnosis";
11
+ import { HarnessReadinessError, harnessAttention, nativeReadinessFailure, type HarnessAttention, type PreflightReport, type HarnessReadiness, type ReadinessState } from "./readiness";
11
12
  import type {
12
13
  DiscoverHarnessesOptions,
13
14
  HarnessAdapter,
@@ -98,6 +99,10 @@ export type RunStartInput = {
98
99
  };
99
100
 
100
101
  export type RunView = {
102
+ installation?: InstallationReport;
103
+ actionRequired?: HarnessAttention[];
104
+ preflight?: PreflightReport;
105
+ coverage?: { requested: number; succeeded: number; failed: number; skipped: number };
101
106
  id: string;
102
107
  status: RunStatus;
103
108
  mode: RunMode;
@@ -126,6 +131,8 @@ export type RunView = {
126
131
  etaConfidence: "timeout-budget" | "unknown" | "complete";
127
132
  finalAnswer?: string;
128
133
  finalAnswerChars: number;
134
+ /** Recipient context from Compare, kept separate from answer artifacts. */
135
+ keepInMind?: KeepInMindNote[];
129
136
  error?: string;
130
137
  diagnosis?: TaskDiagnosis;
131
138
  failedTaskDiagnosis?: TaskDiagnosis;
@@ -158,11 +165,14 @@ export type PlanRoute = (
158
165
  ) => RoutePlan;
159
166
 
160
167
  export type RunManagerDependencies = {
168
+ getInstallation?: () => InstallationReport;
161
169
  taskManager: TaskManager;
162
170
  compareManager: CompareManager;
163
171
  discoverHarnesses: DiscoverHarnesses;
164
172
  findHarnessAdapter: FindHarnessAdapter;
165
173
  planRoute: PlanRoute;
174
+ prepareHarnesses?: (harnesses: HarnessDiscovery[], ids: string[], input: RunStartInput) => Promise<PreflightReport>;
175
+ describeReadiness?: (harness: HarnessDiscovery, state: ReadinessState, model?: string) => HarnessReadiness;
166
176
  };
167
177
 
168
178
  export type RunManagerShutdownOptions = {
@@ -175,6 +185,8 @@ export type RunManagerOptions = {
175
185
  };
176
186
 
177
187
  type InternalRun = {
188
+ actionRequired?: HarnessAttention[];
189
+ preflight?: PreflightReport;
178
190
  id: string;
179
191
  status: RunStatus;
180
192
  mode: RunMode;
@@ -191,6 +203,7 @@ type InternalRun = {
191
203
  taskIds: string[];
192
204
  compareId?: string;
193
205
  finalAnswer?: string;
206
+ keepInMind?: KeepInMindNote[];
194
207
  error?: string;
195
208
  diagnosis?: TaskDiagnosis;
196
209
  failedTaskDiagnosis?: TaskDiagnosis;
@@ -258,6 +271,8 @@ export class RunManager {
258
271
  const selectedHarnessIds = selectHarnessIds(input.harnessId, mode, plan);
259
272
 
260
273
  if (selectedHarnessIds.length === 0) {
274
+ const blocked = harnessAttention(harnesses);
275
+ if (blocked.length) throw new HarnessReadinessError(blocked);
261
276
  throw new Error("No runnable harnesses were found.");
262
277
  }
263
278
 
@@ -290,6 +305,9 @@ export class RunManager {
290
305
  throw new Error(`Budget limit exceeded: ${budget.issues.join(" ")}`);
291
306
  }
292
307
 
308
+ const preflight = await this.dependencies.prepareHarnesses?.(harnesses, selectedHarnessIds, {
309
+ ...input, budget: input.budget?.maxEstimatedInputTokens !== undefined ? { ...input.budget, maxEstimatedInputTokens: input.budget.maxEstimatedInputTokens - budget.estimate.estimatedTotalInputTokens } : input.budget,
310
+ });
293
311
  const now = Date.now();
294
312
  const run: InternalRun = {
295
313
  id: randomUUID(),
@@ -309,6 +327,8 @@ export class RunManager {
309
327
  createdAtMs: now,
310
328
  updatedAtMs: now,
311
329
  events: [],
330
+ preflight,
331
+ actionRequired: input.harnessId ? undefined : plan.actionRequired,
312
332
  appliedSkills: input.skills?.map(toAppliedSkillInfo),
313
333
  budget,
314
334
  };
@@ -460,6 +480,11 @@ export class RunManager {
460
480
  const task = await this.dependencies.taskManager.waitForTerminal(taskId);
461
481
  if (task && run.status !== "cancelled") {
462
482
  this.pushEvent(run, eventFromTask(task));
483
+ const state = task.status === "failed" ? nativeReadinessFailure(task.stdout, task.stderr) : undefined;
484
+ if (state) {
485
+ const discovery = (await this.dependencies.discoverHarnesses()).find(h => h.id === task.harnessId);
486
+ if (discovery?.readiness) run.actionRequired = [...(run.actionRequired ?? []).filter(a => a.harnessId !== task.harnessId), { harnessId: task.harnessId, harnessName: task.harnessName, readiness: this.dependencies.describeReadiness?.(discovery, state, task.model) ?? { ...discovery.readiness, state, model: task.model } }];
487
+ }
463
488
  }
464
489
  return task;
465
490
  });
@@ -568,6 +593,9 @@ export class RunManager {
568
593
  ? `${advice}\n\nChosen answer from ${chosen.label ?? chosen.id}, unchanged:\n\n${chosen.content}`
569
594
  : advice;
570
595
  if (result.status === "succeeded" && finalAnswer) {
596
+ if (result.advisor?.keepInMind?.length) {
597
+ run.keepInMind = result.advisor.keepInMind;
598
+ }
571
599
  this.pushEvent(run, {
572
600
  type: "compare-succeeded",
573
601
  compareId: compare.id,
@@ -667,9 +695,19 @@ export class RunManager {
667
695
  etaConfidence: eta.etaConfidence,
668
696
  finalAnswer: finalAnswer ? truncate(finalAnswer, maxAnswerChars) : undefined,
669
697
  finalAnswerChars: finalAnswer?.length ?? 0,
698
+ keepInMind: run.keepInMind,
670
699
  error: run.error,
671
700
  diagnosis: run.diagnosis,
672
701
  failedTaskDiagnosis: run.failedTaskDiagnosis,
702
+ actionRequired: run.actionRequired,
703
+ installation: this.dependencies.getInstallation?.(),
704
+ preflight: run.preflight,
705
+ coverage: {
706
+ requested: run.selectedHarnessIds.length + (run.harnessOverridden ? 0 : run.plan.actionRequired?.length ?? 0),
707
+ succeeded: run.taskIds.filter(id => this.dependencies.taskManager.get(id, { includeOutput: false, includeEvents: false })?.status === "succeeded").length,
708
+ failed: run.taskIds.filter(id => ["failed", "cancelled"].includes(this.dependencies.taskManager.get(id, { includeOutput: false, includeEvents: false })?.status ?? "")).length,
709
+ skipped: run.harnessOverridden ? 0 : run.plan.actionRequired?.length ?? 0,
710
+ },
673
711
  eventCount: run.events.length,
674
712
  events: options.includeEvents === false ? [] : tailItems(run.events, maxEvents),
675
713
  appliedSkills: run.appliedSkills,
@@ -1094,3 +1132,4 @@ async function settleWithDeadline(
1094
1132
  });
1095
1133
  });
1096
1134
  }
1135
+ import type { InstallationReport } from "./installation";