ennodia 0.4.1 → 0.5.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.
package/src/harnesses.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
2
4
  import { signalOwnedProcess } from "./process";
3
5
  import { type Skill } from "./skills";
6
+ import type { HarnessReadiness } from "./readiness";
4
7
 
5
8
  export type HarnessKind = "cli" | "app";
6
9
 
@@ -84,6 +87,8 @@ export type HarnessAdapter = {
84
87
  /** Whether the adapter supports manager-owned native session continuation. */
85
88
  supportsSessionContinuation?: boolean;
86
89
  buildCommand?: (commandPath: string, input: HarnessRunInput) => CommandSpec;
90
+ /** Extract only the final answer from structured readiness-probe output. */
91
+ readinessAnswer?: (stdout: string) => string | undefined;
87
92
  /** Best-effort usage extraction from a finished task's captured output. */
88
93
  extractUsage?: (stdout: string, stderr: string) => HarnessUsage | undefined;
89
94
  /** Extract a native session ID from captured stdout as it arrives. */
@@ -133,6 +138,7 @@ export type HarnessDiscovery = {
133
138
  capabilities: string[];
134
139
  notes: string[];
135
140
  inputGuidance?: string[];
141
+ readiness?: HarnessReadiness;
136
142
  };
137
143
 
138
144
  export type DiscoverHarnessesOptions = {
@@ -357,7 +363,8 @@ export const harnessAdapters: HarnessAdapter[] = [
357
363
  id: "hermes-agent",
358
364
  name: "Hermes Agent",
359
365
  kind: "cli",
360
- commandCandidates: ["hermes-agent", "hermes"],
366
+ // hermes-agent is the legacy single-query runner, not the chat CLI.
367
+ commandCandidates: ["hermes"],
361
368
  versionArgs: ["--version"],
362
369
  capabilities: ["reasoning", "code", "agents", "non-interactive-cli"],
363
370
  notes: [
@@ -418,6 +425,18 @@ export const harnessAdapters: HarnessAdapter[] = [
418
425
  notes: [
419
426
  "Runs through `cline` with auto-approval explicitly disabled.",
420
427
  ],
428
+ readinessAnswer: (stdout) => {
429
+ // Public CLI JSON contract: docs/cli/cli-reference.mdx in cline/cline.
430
+ for (const line of stdout.trim().split(/\r?\n/).reverse()) {
431
+ try {
432
+ const event = JSON.parse(line);
433
+ if (event.type === "run_result") {
434
+ return event.finishReason !== "aborted" && typeof event.text === "string" ? event.text : undefined;
435
+ }
436
+ } catch { /* Ignore diagnostics outside the JSON result. */ }
437
+ }
438
+ return undefined;
439
+ },
421
440
  buildCommand: (commandPath, input) => {
422
441
  const args = ["--auto-approve", "false", "--json"];
423
442
 
@@ -627,7 +646,13 @@ async function discoverHarness(
627
646
  ): Promise<HarnessDiscovery> {
628
647
  const commandPath = adapter.commandCandidates
629
648
  .map((candidate) => Bun.which(candidate))
630
- .find((match): match is string => Boolean(match));
649
+ .find((match): match is string => Boolean(match))
650
+ // GUI/MCP launches may omit native install roots; keep every existing PATH candidate first.
651
+ ?? (process.platform === "darwin" ? adapter.commandCandidates
652
+ .map((candidate) => Bun.which(candidate, {
653
+ PATH: ["/opt/homebrew/bin", "/usr/local/bin", join(homedir(), ".local", "bin")].join(":"),
654
+ }))
655
+ .find((match): match is string => Boolean(match)) : undefined);
631
656
 
632
657
  const appPath = adapter.appPaths?.find((path) => existsSync(path));
633
658
  let version: string | undefined;
package/src/history.ts CHANGED
@@ -58,6 +58,8 @@ export class FileHistorySink implements HistorySink {
58
58
  this.maxRuns = Math.max(1, options.maxRuns ?? DEFAULT_HISTORY_MAX_RUNS);
59
59
  }
60
60
 
61
+ get storageDirectory(): string { return this.dir; }
62
+
61
63
  async recordRun(snapshot: RunHistorySnapshot): Promise<void> {
62
64
  await mkdir(this.dir, { recursive: true, mode: 0o700 });
63
65
  // Frame each append with a leading newline so a previous truncated record
package/src/index.ts CHANGED
@@ -146,6 +146,7 @@ export {
146
146
  parseJudgeAnalysis,
147
147
  type CompareAnalysis,
148
148
  type AdvisorResult,
149
+ type KeepInMindNote,
149
150
  type CompareCandidate,
150
151
  type CompareCandidateInput,
151
152
  type CompareEvent,
@@ -176,6 +177,8 @@ export {
176
177
  type TaskOutputPreview,
177
178
  } from "./diagnosis";
178
179
  export { ENNODIA_VERSION } from "./version";
180
+ export { inspectInstallation, MIN_BUN_VERSION, type InstallationReport, type InstallationScope, type InstallationCheck, type InstallationOptions } from "./installation";
181
+ export { HarnessReadinessManager, HarnessReadinessError, readinessBlocked, harnessAttention, harnessUpgradeCommand, nativeReadinessFailure, type HarnessReadiness, type HarnessReadinessOptions, type HarnessAttention, type HarnessCheckInput, type PreflightReport, type ReadinessState, type MaintenanceCommandRunner } from "./readiness";
179
182
  export {
180
183
  discoverSkills,
181
184
  discoverSkillsWithWarnings,
@@ -0,0 +1,143 @@
1
+ import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { ENNODIA_VERSION } from "./version";
7
+
8
+ export const MIN_BUN_VERSION = "1.3.14";
9
+ export type InstallationScope = "source-checkout" | "project-local" | "global" | "cache" | "unknown";
10
+ export type InstallationCheck = { id: string; status: "pass" | "warning" | "fail"; message: string };
11
+ export type InstallationReport = {
12
+ checkedAt: string;
13
+ status: "healthy" | "limited" | "broken";
14
+ version: string;
15
+ packageVersion?: string;
16
+ distribution: "source" | "compiled";
17
+ scope: InstallationScope;
18
+ evidence: string;
19
+ codeRoot: string;
20
+ serverCwd: string;
21
+ launcher?: string;
22
+ runtime: { name: "bun" | "unsupported"; version?: string; executable: string; minimumVersion: string; platform: string; arch: string };
23
+ checks: InstallationCheck[];
24
+ storage: { temporaryDirectory: string; history: { enabled: boolean; directory?: string }; harnessUpdates: { enabled: boolean; directory: string } };
25
+ limitations: string[];
26
+ };
27
+
28
+ export type InstallationOptions = {
29
+ /** Defaults to the loaded module's location, never the caller's workspace. */
30
+ codeRoot?: string;
31
+ cwd?: string;
32
+ home?: string;
33
+ temporaryDirectory?: string;
34
+ env?: Record<string, string | undefined>;
35
+ bunVersion?: string | null;
36
+ history?: { enabled: boolean; directory?: string };
37
+ autoUpgrade?: boolean;
38
+ upgradeStateDirectory?: string;
39
+ };
40
+
41
+ /** Local, read-only evidence. Does not inspect credentials, contact registries,
42
+ * create directories, or call providers. Access checks do not prove disk capacity. */
43
+ export function inspectInstallation(options: InstallationOptions = {}): InstallationReport {
44
+ const env = options.env ?? process.env;
45
+ const home = canonical(options.home ?? homedir());
46
+ const codeRoot = canonical(options.codeRoot ?? fileURLToPath(new URL("..", import.meta.url)));
47
+ const compiled = /(?:^|\/)\$bunfs(?:\/|$)/.test(codeRoot.replaceAll("\\", "/"));
48
+ const serverCwd = canonical(options.cwd ?? process.cwd());
49
+ const bunVersion = options.bunVersion === undefined ? process.versions.bun : options.bunVersion ?? undefined;
50
+ const temporaryDirectory = canonical(options.temporaryDirectory ?? tmpdir());
51
+ const historySetting = options.history ?? { enabled: env.ENNODIA_HISTORY !== "0", directory: env.ENNODIA_HISTORY_DIR ?? join(home, ".ennodia", "history") };
52
+ const history = { ...historySetting, directory: historySetting.directory ? resolve(historySetting.directory) : undefined };
53
+ const updates = { enabled: options.autoUpgrade ?? env.ENNODIA_AUTO_UPDATE !== "false", directory: resolve(options.upgradeStateDirectory ?? join(env.XDG_STATE_HOME ?? join(home, ".local", "state"), "ennodia", "harnesses")) };
54
+ const checks: InstallationCheck[] = [];
55
+ const add = (id: string, status: InstallationCheck["status"], message: string) => checks.push({ id, status, message });
56
+ add("runtime", bunVersion && atLeast(bunVersion, MIN_BUN_VERSION) ? "pass" : "fail", bunVersion ? `Bun ${bunVersion}; requires >=${MIN_BUN_VERSION}.` : `Bun >=${MIN_BUN_VERSION} is required.`);
57
+
58
+ const npm = readMetadata(join(codeRoot, "package.json"));
59
+ const jsr = readMetadata(join(codeRoot, "jsr.json"));
60
+ const metadata = npm?.name === "ennodia" ? npm : jsr?.name === "@cherninlab/ennodia" ? jsr : undefined;
61
+ const packageVersion = typeof metadata?.version === "string" ? metadata.version : undefined;
62
+ add("package-version", packageVersion === ENNODIA_VERSION ? "pass" : packageVersion ? "fail" : "warning", packageVersion ? `Metadata ${packageVersion}; loaded code ${ENNODIA_VERSION}.` : "Package metadata is unavailable; only the loaded code version is known.");
63
+
64
+ // JSR does not ship the npm launcher. Development tooling is not required.
65
+ const declaredModules = Array.isArray(npm?.files) ? npm.files.filter((p): p is string => typeof p === "string" && /^src\/[^/*]+\.ts$/.test(p)) : [];
66
+ const required = [...new Set(["src/index.ts", "src/core.ts", "src/cli.ts", "src/readiness.ts", "src/installation.ts", ...declaredModules, ...(npm?.name === "ennodia" ? ["bin/ennodia"] : [])])];
67
+ const missing = required.filter(p => !readableFile(join(codeRoot, p)));
68
+ add("runtime-files", compiled || !missing.length ? "pass" : "fail", compiled ? "Loaded core code is embedded in the executable; separate source entrypoints are not required." : missing.length ? `Missing or unreadable runtime files: ${missing.join(", ")}. Reinstall this distribution.` : "Core entrypoints and declared runtime modules are readable.");
69
+ const skillFiles = ["pragmatic", "compositional-audit"].map(id => join(codeRoot, "skills", id, "SKILL.md"));
70
+ add("bundled-skills", skillFiles.every(readableFile) ? "pass" : "warning", skillFiles.every(readableFile) ? "Bundled skills are readable." : "Bundled skills are incomplete; skill discovery or installation may be limited.");
71
+ const dependencies = ["@modelcontextprotocol/sdk/server/mcp.js", "zod"];
72
+ if (compiled) add("dependencies", "warning", "Executable uses bundled dependencies; separate dependency-file integrity cannot be checked.");
73
+ else {
74
+ const require = createRequire(join(codeRoot, "src/index.ts"));
75
+ const unresolved = dependencies.filter(id => { try { return !readableFile(require.resolve(id)); } catch { return true; } });
76
+ add("dependencies", unresolved.length ? "warning" : "pass", unresolved.length ? `Dependencies cannot be resolved from the code location: ${unresolved.join(", ")}. Install dependencies for this source distribution.` : "MCP dependencies resolve from the code location.");
77
+ }
78
+
79
+ const { scope, evidence } = installationScope(codeRoot, home, env);
80
+ if (scope === "unknown") add("installation-scope", "warning", "Global versus local scope could not be established from filesystem evidence.");
81
+ else add("installation-scope", "pass", evidence);
82
+ const temporaryAccessible = writableDirectory(temporaryDirectory, false);
83
+ add("temporary-storage", temporaryAccessible ? "pass" : "fail", `Temporary workspace directory ${temporaryDirectory}: ${temporaryAccessible ? "filesystem access available" : "directory missing or filesystem access unavailable"}.`);
84
+ if (history.enabled) {
85
+ const accessible = history.directory && writableDirectory(history.directory) && writableExistingFile(join(history.directory, "runs.jsonl"));
86
+ add("history-storage", accessible ? "pass" : "warning", history.directory ? `History storage ${history.directory}: filesystem access ${accessible ? "available" : "unavailable; receipts may not persist"}.` : "Custom history sink; filesystem access cannot be checked.");
87
+ }
88
+ if (updates.enabled) {
89
+ const accessible = writableDirectory(updates.directory);
90
+ add("maintenance-storage", accessible ? "pass" : "warning", `Harness update receipts and locks ${updates.directory}: filesystem access ${accessible ? "available" : "unavailable; upgrades may fail"}.`);
91
+ }
92
+
93
+ const limitations = checks.filter(c => c.status !== "pass").map(c => c.message);
94
+ if (scope === "cache") limitations.push("This code runs from a package cache. Cache cleanup or a future unpinned launch can change the loaded version; restart after changing the install.");
95
+ if (!updates.enabled) limitations.push("Automatic harness upgrades are disabled.");
96
+ if (!history.enabled) limitations.push("Run history is disabled; receipts will not persist through restart.");
97
+ const launcherPath = env.ENNODIA_LAUNCHER_PATH;
98
+ // Ignore stale inherited launcher metadata (e.g. when a worker starts another installation).
99
+ const launcher = launcherPath && isAbsolute(launcherPath) && canonical(launcherPath) === canonical(join(codeRoot, "bin/ennodia")) ? launcherPath : undefined;
100
+ return {
101
+ checkedAt: new Date().toISOString(), status: checks.some(c => c.status === "fail") ? "broken" : limitations.length ? "limited" : "healthy",
102
+ version: ENNODIA_VERSION, packageVersion, distribution: compiled ? "compiled" : "source", scope, evidence, codeRoot, serverCwd, launcher,
103
+ runtime: { name: bunVersion ? "bun" : "unsupported", version: bunVersion, executable: process.execPath, minimumVersion: MIN_BUN_VERSION, platform: process.platform, arch: process.arch },
104
+ checks, storage: { temporaryDirectory, history, harnessUpdates: updates }, limitations,
105
+ };
106
+ }
107
+
108
+ function installationScope(root: string, home: string, env: Record<string, string | undefined>): { scope: InstallationScope; evidence: string } {
109
+ if (existsSync(join(root, ".git"))) return { scope: "source-checkout", evidence: "Loaded code root contains Git checkout metadata." };
110
+ const npmCache = canonical(env.npm_config_cache ?? join(home, ".npm"));
111
+ const bunInstall = canonical(env.BUN_INSTALL ?? join(home, ".bun"));
112
+ const bunxCache = [canonical(tmpdir()), canonical("/tmp")].some(temp => within(root, temp)) && /(?:^|\/)bunx-\d+-[^/]+\/(?:.*\/)?node_modules\/ennodia$/.test(root);
113
+ if (within(root, join(npmCache, "_npx")) || within(root, join(bunInstall, "install/cache")) || bunxCache) return { scope: "cache", evidence: "Loaded code is inside an npm exec/npx or Bun package cache." };
114
+ if (root === join(bunInstall, "install/global/node_modules/ennodia")) return { scope: "global", evidence: "Loaded code is inside Bun's global installation directory." };
115
+ const globalSuffix = `${sep}lib${sep}node_modules${sep}ennodia`;
116
+ if (root.endsWith(globalSuffix)) {
117
+ const launcher = join(root.slice(0, -globalSuffix.length), "bin", "ennodia");
118
+ if (existsSync(launcher) && canonical(launcher) === canonical(join(root, "bin/ennodia"))) return { scope: "global", evidence: "Global prefix bin/ennodia resolves to this lib/node_modules/ennodia installation." };
119
+ }
120
+ const dependencyParent = dirname(dirname(root));
121
+ if (dirname(root).endsWith(`${sep}node_modules`) && readMetadata(join(dependencyParent, "package.json"))) return { scope: "project-local", evidence: "Loaded code is a node_modules dependency beneath a project package.json." };
122
+ return { scope: "unknown", evidence: "Code location does not establish an installation scope." };
123
+ }
124
+
125
+ function readMetadata(path: string): Record<string, unknown> | undefined {
126
+ try { const value: unknown = JSON.parse(readFileSync(path, "utf8")); return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined; } catch { return undefined; }
127
+ }
128
+ function canonical(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
129
+ function readableFile(path: string): boolean { try { accessSync(path, constants.R_OK); return statSync(path).isFile(); } catch { return false; } }
130
+ function writableExistingFile(path: string): boolean { try { accessSync(path, constants.R_OK | constants.W_OK); return statSync(path).isFile(); } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; } }
131
+ function writableDirectory(path: string, allowMissing = true): boolean {
132
+ let ancestor = resolve(path);
133
+ if (!allowMissing && !existsSync(ancestor)) return false;
134
+ while (!existsSync(ancestor) && dirname(ancestor) !== ancestor) ancestor = dirname(ancestor);
135
+ try { accessSync(ancestor, constants.R_OK | constants.W_OK | constants.X_OK); return statSync(ancestor).isDirectory(); } catch { return false; }
136
+ }
137
+ function within(path: string, directory: string): boolean { const suffix = relative(directory, path); return suffix !== "" && suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix); }
138
+ function atLeast(version: string, minimum: string): boolean {
139
+ if (!/^\d+\.\d+\.\d+(?:[-+].*)?$/.test(version)) return false;
140
+ const parts = version.split(/[.+-]/).slice(0, 3).map(Number), required = minimum.split(".").map(Number);
141
+ for (let i = 0; i < 3; i++) { if (parts[i]! !== required[i]!) return parts[i]! > required[i]!; }
142
+ return !version.split("+")[0]!.includes("-");
143
+ }
@@ -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,298 @@
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): ReadinessState | undefined {
45
+ const lines = [...stripVTControlCharacters(stderr).split(/\r?\n/), ...stripVTControlCharacters(stdout).split(/\r?\n/)];
46
+ for (const line of lines) {
47
+ let message = line.trim();
48
+ try {
49
+ const event = JSON.parse(line);
50
+ if (event.type === "error") message = typeof event.message === "string" ? event.message : event.error?.message ?? "";
51
+ else if (event.type === "turn.failed") message = event.error?.message ?? "";
52
+ else continue;
53
+ } catch { /* native text CLIs have no JSON envelope */ }
54
+ message = stripVTControlCharacters(message).trim();
55
+ if (/^(?:Error:\s*)?(?:This |The |Your )?API key (?:has expired|is expired)\b/i.test(message)) return "credentials-expired";
56
+ 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";
57
+ 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";
58
+ if (/^(?:Error:\s*)?(?:model .* (?:not found|not available|not supported)|unknown model\b|ModelNotFoundError\b)/i.test(message)) return "model-unavailable";
59
+ 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";
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ function recipe(id: string): { auth?: string[]; login: string[]; update?: string[] } {
65
+ switch (id) {
66
+ case "codex": return { auth: ["login", "status"], login: ["login"] };
67
+ case "claude-code": return { auth: ["auth", "status", "--json"], login: ["auth", "login"], update: ["update"] };
68
+ case "opencode": return { auth: ["auth", "list"], login: ["auth", "login"], update: ["upgrade"] };
69
+ case "kilo": return { auth: ["auth", "list"], login: ["auth", "login"], update: ["upgrade"] };
70
+ case "hermes-agent": return { auth: ["auth", "list"], login: ["model"], update: ["update", "--yes"] };
71
+ case "kiro": return { auth: ["whoami", "--format", "json"], login: ["login"], update: ["update", "--non-interactive"] };
72
+ case "antigravity": return { login: [], update: ["update"] };
73
+ default: return { login: [] };
74
+ }
75
+ }
76
+
77
+ /** Preserve the installed distribution and channel. Never force an upgrade. */
78
+ export function harnessUpgradeCommand(h: HarnessDiscovery): string[] | undefined {
79
+ if (!h.commandPath) return undefined;
80
+ let actual: string;
81
+ try { actual = realpathSync(h.commandPath); } catch { return undefined; }
82
+ const brew = Bun.which("brew");
83
+ const cask = /\/Caskroom\/([^/]+)\//.exec(actual)?.[1];
84
+ const formula = /\/Cellar\/([^/]+)\//.exec(actual)?.[1];
85
+ const distribution = cask ?? formula;
86
+ 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"] };
87
+ if (distribution) return brew && distributions[h.id]?.includes(distribution) ? [brew, "upgrade", ...(cask ? ["--cask", "--greedy", "--no-quit"] : []), distribution] : undefined;
88
+ if (h.id === "codex") {
89
+ const npm = Bun.which("npm");
90
+ return npm && /\/node_modules\/@openai\/codex\//.test(actual) ? [npm, "install", "-g", "@openai/codex@latest"] : undefined;
91
+ }
92
+ const args = recipe(h.id).update;
93
+ return args ? [h.commandPath, ...args] : undefined;
94
+ }
95
+
96
+ const messages: Record<ReadinessState, string> = {
97
+ unverified: "Model access is unverified; a small request is required before review.",
98
+ ready: "The selected route returned a verified response.",
99
+ "login-required": "Sign-in is required before review.",
100
+ "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.",
101
+ "setup-required": "Install or repair the native CLI/provider configuration before review.",
102
+ "model-unavailable": "The selected model is unavailable on this route.",
103
+ "quota-exhausted": "Provider quota or credits are exhausted; sign-in will not resolve it.",
104
+ unreachable: "The readiness check did not complete; inspect connectivity or native setup before retrying.",
105
+ updating: "An upgrade is in progress. Recheck after it completes.",
106
+ };
107
+
108
+ /** Core owns readiness, failure memory and maintenance; native CLIs own credentials. */
109
+ export class HarnessReadinessManager {
110
+ private readonly cache = new Map<string, { at: number; value: HarnessReadiness }>();
111
+ private readonly flights = new Map<string, Promise<HarnessReadiness>>();
112
+ private readonly updates = new Map<string, NonNullable<HarnessReadiness["upgrade"]>>();
113
+ private readonly busyHarnesses = new Set<string>();
114
+ private readonly upgradeFlights = new Map<string, Promise<void>>();
115
+ private readonly abort = new AbortController();
116
+ private closing = false;
117
+ private readonly runner: MaintenanceCommandRunner;
118
+ private readonly now: () => number;
119
+ private readonly stateDir: string;
120
+ readonly autoUpgrade: boolean;
121
+ get stateDirectory(): string { return this.stateDir; }
122
+ constructor(private readonly options: HarnessReadinessOptions = {}) {
123
+ this.runner = options.runCommand ?? ((spec, timeout) => runMaintenanceCommand(spec, timeout, this.abort.signal));
124
+ this.now = options.now ?? Date.now;
125
+ this.stateDir = options.stateDir ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "ennodia", "harnesses");
126
+ this.autoUpgrade = options.autoUpgrade ?? process.env.ENNODIA_AUTO_UPDATE !== "false";
127
+ }
128
+ private key(h: HarnessDiscovery, model?: string, cwd?: string): string {
129
+ // Environment values may be secret: hash them, never expose or persist them.
130
+ const environment = createHash("sha256").update(JSON.stringify(process.env)).digest("hex");
131
+ return JSON.stringify([h.id, h.commandPath, h.version, model, resolve(cwd ?? process.cwd()), environment]);
132
+ }
133
+ private value(h: HarnessDiscovery, state: ReadinessState, model?: string): HarnessReadiness {
134
+ const r = recipe(h.id);
135
+ const result: HarnessReadiness = { state, checkedAt: new Date(this.now()).toISOString(), model, message: messages[state], upgrade: this.updates.get(h.id) };
136
+ if (state !== "ready") result.recovery = {
137
+ command: h.commandPath ? [h.commandPath, ...(state === "login-required" || state === "credentials-expired" || state === "unverified" ? r.login : ["--help"])] : [],
138
+ instruction: !h.commandPath ? "Install the supported native CLI, then recheck."
139
+ : state === "unverified" ? "Run the targeted recheck to verify model access. Sign in only when a login blocker is reported."
140
+ : state === "login-required" ? "Run the native command in an interactive terminal using the intended account/provider, then recheck."
141
+ : 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."
142
+ : 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."
143
+ : "Inspect the reported blocker through the native CLI; then recheck the same route. Do not change account, provider or model silently.",
144
+ recheck: { tool: "ennodia_check_harnesses", harnessIds: [h.id], ...(model ? { model } : {}), verify: true },
145
+ };
146
+ return result;
147
+ }
148
+ describe(h: HarnessDiscovery, state: ReadinessState, model?: string): HarnessReadiness { return this.value(h, state, model); }
149
+ invalidate(id: string): void {
150
+ for (const [key] of this.cache) if (JSON.parse(key)[0] === id) this.cache.delete(key);
151
+ }
152
+ private installationState(h: HarnessDiscovery): { stamp: string; lock: string } {
153
+ const identity = createHash("sha256").update(h.commandPath ?? h.id).digest("hex").slice(0, 20);
154
+ return { stamp: join(this.stateDir, `${identity}.json`), lock: join(this.stateDir, `${identity}.lock`) };
155
+ }
156
+ private clearDeadLease(lock: string): void {
157
+ if (!existsSync(lock)) return;
158
+ let owner: number;
159
+ try { owner = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")).pid; }
160
+ catch {
161
+ // A process can die between mkdir and recording its ownership.
162
+ try { if (this.now() - lstatSync(lock).mtimeMs > 120_000) rmSync(lock, { recursive: true, force: true }); } catch { /* raced with release */ }
163
+ return;
164
+ }
165
+ if (!Number.isInteger(owner) || owner < 1) return;
166
+ try { process.kill(owner, 0); }
167
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") rmSync(lock, { recursive: true, force: true }); }
168
+ }
169
+ async check(h: HarnessDiscovery, adapter?: HarnessAdapter, input: HarnessCheckInput = {}): Promise<HarnessReadiness> {
170
+ if (this.closing) throw new Error("Harness maintenance is shutting down.");
171
+ const { lock } = this.installationState(h);
172
+ this.clearDeadLease(lock);
173
+ if (this.busyHarnesses.has(h.id) || existsSync(lock)) return this.value(h, "updating", input.model);
174
+ const key = this.key(h, input.model, input.cwd);
175
+ const existing = this.cache.get(key);
176
+ 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;
177
+ const flightKey = `${key}:${Boolean(input.verify)}`;
178
+ const flight = this.flights.get(flightKey);
179
+ if (flight) return flight;
180
+ const pending = this.performCheck(h, adapter, input).catch(() => this.value(h, "unreachable", input.model)).then(value => {
181
+ this.cache.set(key, { at: this.now(), value });
182
+ return value;
183
+ }).finally(() => this.flights.delete(flightKey));
184
+ this.flights.set(flightKey, pending);
185
+ return pending;
186
+ }
187
+ private async performCheck(h: HarnessDiscovery, adapter: HarnessAdapter | undefined, input: HarnessCheckInput): Promise<HarnessReadiness> {
188
+ if (!h.runnable || !h.commandPath || !adapter?.buildCommand) return this.value(h, "setup-required", input.model);
189
+ const { lock } = this.installationState(h);
190
+ this.clearDeadLease(lock);
191
+ if (this.busyHarnesses.has(h.id) || existsSync(lock)) return this.value(h, "updating", input.model);
192
+ const auth = recipe(h.id).auth;
193
+ if (auth) {
194
+ const result = await this.runner({ command: h.commandPath, args: auth }, 5_000);
195
+ const state = nativeReadinessFailure(result.stdout, result.stderr);
196
+ if (state) return this.value(h, state, input.model);
197
+ if (h.id === "claude-code") {
198
+ try { if (JSON.parse(result.stdout).loggedIn === false) return this.value(h, "login-required", input.model); } catch { /* verification below */ }
199
+ }
200
+ if (result.timedOut) return this.value(h, "unreachable", input.model);
201
+ if (result.exitCode !== 0) return this.value(h, ["codex", "claude-code", "kiro"].includes(h.id) ? "login-required" : "setup-required", input.model);
202
+ }
203
+ if (!input.verify) return this.value(h, "unverified", input.model);
204
+ const dir = mkdtempSync(join(tmpdir(), "ennodia-readiness-"));
205
+ const nonce = `ENNODIA_READY_${randomUUID().replaceAll("-", "")}`;
206
+ try {
207
+ 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 });
208
+ const result = await this.runner(spec, 25_000);
209
+ const failure = nativeReadinessFailure(result.stdout, result.stderr);
210
+ if (failure) return this.value(h, failure, input.model);
211
+ if (result.timedOut || result.exitCode !== 0 || adapter.failureReason?.(result.stdout, result.stderr)) return this.value(h, "unreachable", input.model);
212
+ let answer = adapter.readinessAnswer ? adapter.readinessAnswer(result.stdout) ?? "" : result.stdout;
213
+ try { answer = readFileSync(join(dir, "answer.txt"), "utf8"); } catch { /* native text output */ }
214
+ // An echoed prompt is not success. Require a standalone returned nonce.
215
+ if (!stripVTControlCharacters(answer).split(/\r?\n/).some(line => line.trim() === nonce)) return this.value(h, "setup-required", input.model);
216
+ return this.value(h, "ready", input.model);
217
+ } finally { rmSync(dir, { recursive: true, force: true }); }
218
+ }
219
+ observe(h: HarnessDiscovery, input: Pick<HarnessRunInput, "model" | "cwd">, result: { status: string; stdout: string; stderr: string; timedOut: boolean; hasOutput?: boolean }): void {
220
+ if (this.closing) return;
221
+ if (result.status === "cancelled" || result.timedOut) return;
222
+ const failure = result.status === "failed" ? nativeReadinessFailure(result.stdout, result.stderr) : undefined;
223
+ if (failure) this.invalidate(h.id);
224
+ if (failure || (result.status === "succeeded" && result.hasOutput)) {
225
+ this.cache.set(this.key(h, input.model, input.cwd), { at: this.now(), value: this.value(h, failure ?? "ready", input.model) });
226
+ }
227
+ }
228
+ /** Upgrade at most daily across server restarts; use an exclusive per-install lease. */
229
+ async upgrade(h: HarnessDiscovery, force = false): Promise<void> {
230
+ if (this.closing) throw new Error("Harness maintenance is shutting down.");
231
+ const existing = this.upgradeFlights.get(h.id);
232
+ if (existing) return existing;
233
+ const pending = this.performUpgrade(h, force).finally(() => this.upgradeFlights.delete(h.id));
234
+ this.upgradeFlights.set(h.id, pending);
235
+ return pending;
236
+ }
237
+ private async performUpgrade(h: HarnessDiscovery, force: boolean): Promise<void> {
238
+ const command = harnessUpgradeCommand(h);
239
+ const checkedAt = new Date(this.now()).toISOString();
240
+ const save = (status: NonNullable<HarnessReadiness["upgrade"]>["status"], message: string) => {
241
+ this.updates.set(h.id, { status, checkedAt, command, message }); this.invalidate(h.id);
242
+ };
243
+ if (!command) { save("unsupported", "No supported updater was found for this installation. Use its original installer."); return; }
244
+ if (this.options.isBusy?.() || this.flights.size > 0) { save("deferred", "Upgrade deferred while Ennodia work is active."); return; }
245
+ const { stamp, lock } = this.installationState(h);
246
+ try {
247
+ mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
248
+ if (!force) {
249
+ try {
250
+ const previous = JSON.parse(readFileSync(stamp, "utf8"));
251
+ if (this.now() - previous.at < 86_400_000) {
252
+ if (previous.result) this.updates.set(h.id, previous.result);
253
+ return;
254
+ }
255
+ } catch { /* first check */ }
256
+ }
257
+ this.clearDeadLease(lock);
258
+ try { mkdirSync(lock); writeFileSync(join(lock, "owner.json"), JSON.stringify({ pid: process.pid }), { mode: 0o600 }); } catch {
259
+ save("deferred", "Another maintenance process holds the installation lock. Recheck later."); return;
260
+ }
261
+ } catch { save("failed", "Maintenance state could not be written; no upgrade was attempted."); return; }
262
+ this.busyHarnesses.add(h.id);
263
+ try {
264
+ writeFileSync(stamp, JSON.stringify({ at: this.now() }), { mode: 0o600 });
265
+ const result = await this.runner({ command: command[0]!, args: command.slice(1) }, 120_000);
266
+ 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.");
267
+ writeFileSync(stamp, JSON.stringify({ at: this.now(), result: this.updates.get(h.id) }), { mode: 0o600 });
268
+ } catch { save("failed", "Native updater failed to start. Run the displayed command interactively."); }
269
+ finally { this.busyHarnesses.delete(h.id); rmSync(lock, { recursive: true, force: true }); }
270
+ }
271
+ async shutdown(): Promise<void> {
272
+ this.closing = true;
273
+ this.abort.abort();
274
+ await Promise.allSettled([...this.flights.values(), ...this.upgradeFlights.values()]);
275
+ }
276
+ }
277
+
278
+ export async function runMaintenanceCommand(spec: CommandSpec, timeoutMs: number, signal?: AbortSignal): Promise<CommandResult> {
279
+ if (signal?.aborted) throw new Error("Harness maintenance was cancelled.");
280
+ 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" });
281
+ if (spec.stdin && child.stdin) { child.stdin.write(spec.stdin); child.stdin.end(); }
282
+ let timedOut = false;
283
+ const readers = [child.stdout.getReader(), child.stderr.getReader()];
284
+ const chunks = ["", ""];
285
+ const kill = () => {
286
+ signalOwnedProcess(child, "SIGKILL", process.platform !== "win32");
287
+ for (const reader of readers) void reader.cancel().catch(() => {});
288
+ };
289
+ const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
290
+ signal?.addEventListener("abort", kill, { once: true });
291
+ try {
292
+ await Promise.all(readers.map(async (reader, index) => {
293
+ const decoder = new TextDecoder();
294
+ 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(); }
295
+ }));
296
+ return { exitCode: await child.exited, stdout: chunks[0]!, stderr: chunks[1]!, timedOut };
297
+ } finally { clearTimeout(timer); signal?.removeEventListener("abort", kill); signalOwnedProcess(child, "SIGKILL", process.platform !== "win32"); }
298
+ }