ennodia 0.5.0 → 0.5.2

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/README.md CHANGED
@@ -85,7 +85,7 @@ See [how to read these notes](https://ennodia.cherninlab.com/docs/guides/underst
85
85
 
86
86
  Codex CLI, Claude Code, OpenCode, Kilo Code, Kiro CLI, Cline, Hermes Agent, and Antigravity.
87
87
  Model availability and tool permissions depend on the installed agent and its provider configuration.
88
- Discovery reports native login checks and recovery actions. Before execution, Ennodia verifies each selected model route with a small request when recent success is unavailable.
88
+ Discovery reports native login checks and confirmed recovery actions. The requested task verifies model access without a separate model call first.
89
89
  Codex workers use a read-only sandbox by default.
90
90
 
91
91
  Ennodia discovers native Agent Skills and can request them for specific tasks.
@@ -142,7 +142,7 @@ Savings are not yet validated.
142
142
  Ennodia checks selected workers, Judge, and Advisor routes before execution.
143
143
  Blocked routes return a native recovery action before reviewers start.
144
144
  Ennodia says nothing about its own status unless an agent you asked for is blocked.
145
- Rechecks, setup, and blocked starts include a setup panel with the exact fix.
145
+ Rechecks and blocked starts return compact diagnostics. The full setup panel appears only for an explicit setup request.
146
146
 
147
147
  Your agent calls `ennodia_setup` to show each agent's status and fix in your current session.
148
148
  Hosts that render MCP Apps, such as Claude Desktop and Visual Studio Code, can show it as a card with check buttons.
@@ -34,6 +34,9 @@ Start with one worker and a small set of files.
34
34
  Ennodia checks each selected agent before execution. Your agent hears nothing about Ennodia's status unless an agent you requested is blocked.
35
35
  A blocked agent comes with its exact fix, such as a sign-in command.
36
36
 
37
+ The requested task verifies model access. Ennodia does not add a separate model call before it.
38
+ An optional verification timeout leaves access unverified and does not prevent a task from starting.
39
+
37
40
  For setup or recovery, give your agent this request:
38
41
 
39
42
  ```text
package/docs/install.md CHANGED
@@ -81,7 +81,9 @@ Tell the user which step is needed before trying to call Ennodia.
81
81
 
82
82
  Ennodia includes an installation self-check in run results and rechecks. Its MCP startup instructions carry only installation problems. It uses the loaded code location to identify a source checkout, project-local dependency, global installation, package cache, or unknown scope. It checks the Bun version, package version, core files, bundled skills, and filesystem access for temporary workspaces, history, and harness update receipts. Development files are not required for a packaged install. The npm launcher stops before startup if Bun is too old.
83
83
 
84
- Tell the user about reported installation problems before the first run. Call `ennodia_check_harnesses` with `refresh: true` to refresh the installation report and native harness status after a repair. Filesystem access checks do not prove free disk space or future writes. They do not verify provider access or the latest published package version. A global install gives no extra permissions. The user's normal permissions still apply.
84
+ Report confirmed installation blockers briefly. A running process using an older version needs a reconnect. This does not mean the installation is broken. Do not repeat unchanged warnings.
85
+
86
+ Call `ennodia_check_harnesses` with `refresh: true` to refresh the installation report and native harness status after a repair. Filesystem access checks do not prove free disk space or future writes. They do not verify provider access or the latest published package version. A global install gives no extra permissions. The user's normal permissions still apply.
85
87
 
86
88
  The report also identifies compiled code. A compiled executable does not need separate source entrypoints. Missing bundled skills remain a reported limitation. Compilation is currently an experimental build path.
87
89
 
@@ -732,6 +732,8 @@ After the worker settles, inspect its task receipt. If `canContinue` is true,
732
732
  pass that task ID as `continueTaskId` in the next run. The follow-up keeps the
733
733
  harness and directory. It inherits the model and reasoning effort unless you
734
734
  supply new values. Each follow-up has a new task ID and execution allowance.
735
+
736
+ Native status checks use the inherited or explicitly supplied model before the follow-up starts.
735
737
  An earlier turn cannot be used once a follow-up has started.
736
738
 
737
739
  Continuation requires a task still held by the same running Ennodia server.
@@ -811,7 +813,7 @@ Sign-in and credential changes stay in each agent's own CLI.
811
813
 
812
814
  #### Setup card
813
815
 
814
- `ennodia_setup` and `ennodia_check_harnesses` name the `ui://ennodia/setup.html` resource in `_meta.ui.resourceUri`.
816
+ Only `ennodia_setup` names the `ui://ennodia/setup.html` resource in `_meta.ui.resourceUri`.
815
817
  The resource uses the MCP Apps type `text/html;profile=mcp-app`.
816
818
  Hosts that render MCP Apps show the card inside the conversation. Other hosts ignore it and keep the Markdown panel.
817
819
 
@@ -835,15 +837,25 @@ The result includes `installation`, a local self-check of Ennodia's loaded code
835
837
  It accepts `harnessIds`, `model`, `cwd`, `refresh`, `verify`, and `upgrade`.
836
838
  Native status checks are the default. `verify: true` makes a bounded real model request.
837
839
 
838
- `unverified` means unchecked. `ennodia_run` verifies its selected routes automatically.
840
+ `unverified` means model access is unknown. Normal runs use native status checks and let the task verify model access.
841
+ An inconclusive check does not block the task or mark the agent as needing setup.
839
842
  Use targeted `harnessIds` for recovery checks. Unrelated blockers do not prevent work on usable requested routes.
840
843
 
841
- Rechecks, `ennodia_setup`, and blocked preflight responses include an embedded `ennodia://setup` resource with `text/markdown` content.
844
+ Only `ennodia_setup` includes the embedded `ennodia://setup` resource with `text/markdown` content.
845
+ Rechecks and blocked preflight responses return compact JSON diagnostics without creating another setup card.
842
846
  The JSON text response keeps its existing shape.
843
847
 
844
848
  The panel is a table of agents, with agents that need the user first. Each row gives the status in plain words and the next step.
845
849
  Sign-in commands follow the table, for login blockers only. Key expiry and quota blockers name their own recovery.
846
- Show the panel when the host displays no setup card. Recheck the same harness and model after recovery, then resume the original task.
850
+
851
+ Show the full panel only for an explicit setup or status request.
852
+ For a blocked task, report its cause and native recovery action briefly.
853
+ Do not repeat unchanged warnings or automatically retry checks.
854
+ Recheck the same harness and model after recovery, then resume the original task.
855
+
856
+ When the installed package differs from the running process, `installation.restartRequired` is true.
857
+ This is a reload notice, not a broken installation. Reconnect the MCP connection to load the installed version.
858
+ Missing runtime files or an unsupported runtime still report a broken installation.
847
859
 
848
860
  `upgrade: true` runs supported updaters before rediscovery and recheck.
849
861
  This tool can change installed software and call providers, so it does not declare `readOnlyHint`.
@@ -45,12 +45,14 @@ It does not guarantee media support, authentication, or access to a requested mo
45
45
  Plan Advisor receives this guidance in its harness inventory.
46
46
 
47
47
  Discovery checks installation and native login status through supported commands.
48
- Model access remains unverified until a small real request or a recent successful task returns an answer.
49
- A stalled check stops at its deadline and reports a blocker.
48
+ Model access remains unverified until an explicit verification request or a successful task returns an answer.
49
+ A stalled check stops at its deadline and leaves access unverified.
50
50
  Ennodia checks selected worker, Judge, and Advisor routes before execution.
51
51
  Known blockers prevent requested work from starting.
52
52
  Automatic routing excludes blocked routes and reports missing coverage.
53
53
 
54
+ Normal runs check native status without making a separate model request.
55
+
54
56
  ## Media inputs
55
57
 
56
58
  Ennodia sends a text prompt through the selected CLI. Include local file paths and requested ranges in that prompt.
@@ -138,7 +140,7 @@ Call `ennodia_check_harnesses` after sign-in:
138
140
  ```
139
141
 
140
142
  Pass the same `model` and `cwd` when rechecking an explicit route.
141
- The check returns `actionRequired` for login, setup, model, capacity, and connectivity blockers.
143
+ The check returns `actionRequired` for confirmed login, setup, model, and capacity blockers.
142
144
  A missing CLI requires installation. A capacity failure requires capacity recovery.
143
145
  Neither is fixed by repeated sign-in.
144
146
 
@@ -147,7 +149,9 @@ Replace the key through the native credential flow for the intended provider.
147
149
  Signing in again does not renew an expired API key.
148
150
  `quota-exhausted` includes depleted credits.
149
151
  Restore credits or quota through the intended provider before rechecking.
152
+
150
153
  Ennodia reads terminal-formatted native errors and returns the matching recovery action.
154
+ A worker's plain-text answer does not trigger a sign-in or capacity recovery action.
151
155
 
152
156
  Hermes uses the full `hermes chat` CLI. The legacy `hermes-agent` executable is not compatible.
153
157
 
@@ -155,8 +159,8 @@ Native checks cache for 30 seconds. Verified routes cache for five minutes.
155
159
  A native authentication failure invalidates the route immediately.
156
160
  Concurrent checks of the same route share one request.
157
161
 
158
- `unverified` means unchecked. It does not mean a failed harness.
159
- `ennodia_run` verifies its selected routes before launching workers.
162
+ `unverified` means model access is unknown. It does not mean a failed harness.
163
+ `ennodia_run` lets the requested task verify model access after native status checks.
160
164
  Checking every supported harness is not a prerequisite for a run.
161
165
 
162
166
  Unrelated harness blockers do not prevent work on a usable requested route.
@@ -167,9 +171,9 @@ Ennodia does not require an additional approval step.
167
171
  Model probes run in an empty temporary directory with normal native permissions.
168
172
  A temporary directory is not a security sandbox.
169
173
 
170
- Probes use provider capacity and reserve 256 input tokens per route for budget checks.
171
- The reserve is an estimate, not a measurement of provider usage.
172
- Probe calls count toward `maxChildTasks`. Usage remains unknown when the provider reports no measurement.
174
+ Explicit verification uses provider capacity independently of a run's budget.
175
+ Normal runs add no model probe calls or input-token reserves.
176
+ A verification timeout after 25 seconds does not block the requested task or imply a setup problem.
173
177
 
174
178
  Before ordinary execution, Ennodia attempts supported upgrades once per day while no Ennodia task is active.
175
179
  Homebrew installations use their installed formula or cask.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ennodia",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "mcpName": "io.github.cherninlab/ennodia",
5
5
  "description": "Connects the AI agents you use: your agent hands a task to another and gets the answer back",
6
6
  "license": "MIT",
package/src/core.ts CHANGED
@@ -416,7 +416,7 @@ export class EnnodiaCore {
416
416
  for (const h of harnesses) { const fresh = refreshed.find(f => f.id === h.id); if (fresh) Object.assign(h, fresh); }
417
417
  }
418
418
  const unique = [...new Map(routes.map(r => [JSON.stringify(r), r])).values()];
419
- // Check every native login before any model probe or reviewer launch.
419
+ // Native checks catch known blockers; the requested task verifies model access.
420
420
  const checked = await Promise.all(unique.map(async route => {
421
421
  const h = harnesses.find(h => h.id === route.harnessId);
422
422
  if (!h) throw new Error(`Unknown harness: ${route.harnessId}`);
@@ -425,23 +425,10 @@ export class EnnodiaCore {
425
425
  const initialBlocked = checked.filter(c => readinessBlocked(c.readiness)).map(c => ({ harnessId: c.h.id, harnessName: c.h.name, readiness: c.readiness }));
426
426
  if (initialBlocked.length) throw new HarnessReadinessError(initialBlocked);
427
427
  for (const { h, readiness } of checked) {
428
- if (readiness.state !== "ready") {
429
- // Probes are real provider calls. Reserve them before starting, and
430
- // expose their estimate separately rather than implying free checks.
431
- report.modelProbes++; report.estimatedInputTokens += 256;
432
- }
433
428
  report.checks.push({ harnessId: h.id, harnessName: h.name, readiness });
434
- if (budget?.maxChildTasks !== undefined && routes.length + report.modelProbes > budget.maxChildTasks) throw new Error("Budget limit exceeded: readiness probes also count as child calls. Verify the routes separately or increase maxChildTasks.");
435
- if (budget?.maxEstimatedInputTokens !== undefined && report.estimatedInputTokens > budget.maxEstimatedInputTokens) throw new Error("Budget limit exceeded: readiness probe input reserve.");
436
- }
437
- const verified = await Promise.all(report.checks.map(async c => ({ ...c, readiness: await this.readinessManager!.check(harnesses.find(h => h.id === c.harnessId)!, this.findHarnessAdapter(c.harnessId), { model: c.readiness.model, cwd, verify: true }) })));
438
- report.checks = verified;
439
- const blocked = verified.filter(c => readinessBlocked(c.readiness));
440
- if (blocked.length) throw new HarnessReadinessError(blocked);
441
- for (const check of verified) {
442
- const h = harnesses.find(h => h.id === check.harnessId);
443
- if (h) h.readiness = check.readiness;
429
+ h.readiness = readiness;
444
430
  }
431
+ if (budget?.maxChildTasks !== undefined && routes.length > budget.maxChildTasks) throw new Error("Budget limit exceeded: selected routes exceed maxChildTasks.");
445
432
  return report;
446
433
  }
447
434
 
@@ -882,9 +869,9 @@ export class EnnodiaCore {
882
869
  }
883
870
 
884
871
  async startRun(input: RunStartWithSkillIdsInput): Promise<RunView> {
885
- if (input.continueTaskId && !input.cwd) {
872
+ if (input.continueTaskId) {
886
873
  const previous = this.taskManager.get(input.continueTaskId, { includeOutput: false, includeEvents: false });
887
- if (previous) input = { ...input, cwd: previous.cwd };
874
+ if (previous) input = { ...input, cwd: input.cwd ?? previous.cwd, model: input.model ?? previous.model };
888
875
  }
889
876
  const { skillIds, ...runInput } = input;
890
877
  const skills = runInput.skills ??
@@ -13,6 +13,7 @@ export type InstallationReport = {
13
13
  status: "healthy" | "limited" | "broken";
14
14
  version: string;
15
15
  packageVersion?: string;
16
+ restartRequired?: boolean;
16
17
  distribution: "source" | "compiled";
17
18
  scope: InstallationScope;
18
19
  evidence: string;
@@ -59,7 +60,8 @@ export function inspectInstallation(options: InstallationOptions = {}): Installa
59
60
  const jsr = readMetadata(join(codeRoot, "jsr.json"));
60
61
  const metadata = npm?.name === "ennodia" ? npm : jsr?.name === "@cherninlab/ennodia" ? jsr : undefined;
61
62
  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
+ const restartRequired = Boolean(packageVersion && packageVersion !== ENNODIA_VERSION);
64
+ add("package-version", packageVersion === ENNODIA_VERSION ? "pass" : "warning", restartRequired ? `Installed ${packageVersion}; running ${ENNODIA_VERSION}. Reconnect the Ennodia MCP connection to load the installed version.` : packageVersion ? `Metadata ${packageVersion}; loaded code ${ENNODIA_VERSION}.` : "Package metadata is unavailable; only the loaded code version is known.");
63
65
 
64
66
  // JSR does not ship the npm launcher. Development tooling is not required.
65
67
  const declaredModules = Array.isArray(npm?.files) ? npm.files.filter((p): p is string => typeof p === "string" && /^src\/[^/*]+\.ts$/.test(p)) : [];
@@ -99,7 +101,7 @@ export function inspectInstallation(options: InstallationOptions = {}): Installa
99
101
  const launcher = launcherPath && isAbsolute(launcherPath) && canonical(launcherPath) === canonical(join(codeRoot, "bin/ennodia")) ? launcherPath : undefined;
100
102
  return {
101
103
  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,
104
+ version: ENNODIA_VERSION, packageVersion, ...(restartRequired ? { restartRequired } : {}), distribution: compiled ? "compiled" : "source", scope, evidence, codeRoot, serverCwd, launcher,
103
105
  runtime: { name: bunVersion ? "bun" : "unsupported", version: bunVersion, executable: process.execPath, minimumVersion: MIN_BUN_VERSION, platform: process.platform, arch: process.arch },
104
106
  checks, storage: { temporaryDirectory, history, harnessUpdates: updates }, limitations,
105
107
  };
package/src/readiness.ts CHANGED
@@ -28,7 +28,7 @@ export type HarnessReadinessOptions = {
28
28
  isBusy?: () => boolean;
29
29
  };
30
30
 
31
- const BLOCKED = new Set<ReadinessState>(["login-required", "credentials-expired", "setup-required", "model-unavailable", "quota-exhausted", "unreachable", "updating"]);
31
+ const BLOCKED = new Set<ReadinessState>(["login-required", "credentials-expired", "setup-required", "model-unavailable", "quota-exhausted", "updating"]);
32
32
  export function readinessBlocked(readiness?: HarnessReadiness): boolean { return Boolean(readiness && BLOCKED.has(readiness.state)); }
33
33
  export function harnessAttention(harnesses: HarnessDiscovery[]): HarnessAttention[] {
34
34
  return harnesses.filter(h => readinessBlocked(h.readiness)).map(h => ({ harnessId: h.id, harnessName: h.name, readiness: h.readiness! }));
@@ -41,20 +41,24 @@ export class HarnessReadinessError extends Error {
41
41
  }
42
42
 
43
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) {
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()) {
47
48
  let message = line.trim();
48
49
  try {
49
50
  const event = JSON.parse(line);
50
51
  if (event.type === "error") message = typeof event.message === "string" ? event.message : event.error?.message ?? "";
51
52
  else if (event.type === "turn.failed") message = event.error?.message ?? "";
52
53
  else continue;
53
- } catch { /* native text CLIs have no JSON envelope */ }
54
+ } catch {
55
+ // Only status/nonce checks can distinguish plain stdout from worker prose.
56
+ if (index >= stderrLines.length && !allowPlainStdout) continue;
57
+ }
54
58
  message = stripVTControlCharacters(message).trim();
55
59
  if (/^(?:Error:\s*)?(?:This |The |Your )?API key (?:has expired|is expired)\b/i.test(message)) return "credentials-expired";
56
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";
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";
61
+ if (/^(?:Error:\s*)?(?:You've hit your (?:usage|\d+[ -]hour|weekly) 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
62
  if (/^(?:Error:\s*)?(?:model .* (?:not found|not available|not supported)|unknown model\b|ModelNotFoundError\b)/i.test(message)) return "model-unavailable";
59
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";
60
64
  }
@@ -94,7 +98,7 @@ export function harnessUpgradeCommand(h: HarnessDiscovery): string[] | undefined
94
98
  }
95
99
 
96
100
  const messages: Record<ReadinessState, string> = {
97
- unverified: "Model access is unverified; a small request is required before review.",
101
+ unverified: "Model access is unverified. A task can run without a separate model check.",
98
102
  ready: "The selected route returned a verified response.",
99
103
  "login-required": "Sign-in is required before review.",
100
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.",
@@ -133,10 +137,9 @@ export class HarnessReadinessManager {
133
137
  private value(h: HarnessDiscovery, state: ReadinessState, model?: string): HarnessReadiness {
134
138
  const r = recipe(h.id);
135
139
  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"])] : [],
140
+ if (state !== "ready" && state !== "unverified") result.recovery = {
141
+ command: h.commandPath ? [h.commandPath, ...(state === "login-required" || state === "credentials-expired" ? r.login : ["--help"])] : [],
138
142
  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
143
  : state === "login-required" ? "Run the native command in an interactive terminal using the intended account/provider, then recheck."
141
144
  : 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
145
  : 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."
@@ -177,7 +180,7 @@ export class HarnessReadinessManager {
177
180
  const flightKey = `${key}:${Boolean(input.verify)}`;
178
181
  const flight = this.flights.get(flightKey);
179
182
  if (flight) return flight;
180
- const pending = this.performCheck(h, adapter, input).catch(() => this.value(h, "unreachable", input.model)).then(value => {
183
+ const pending = this.performCheck(h, adapter, input).catch(() => ({ ...this.value(h, "unverified", input.model), message: "The native check could not complete. Model access remains unverified; a task can still run." })).then(value => {
181
184
  this.cache.set(key, { at: this.now(), value });
182
185
  return value;
183
186
  }).finally(() => this.flights.delete(flightKey));
@@ -192,13 +195,13 @@ export class HarnessReadinessManager {
192
195
  const auth = recipe(h.id).auth;
193
196
  if (auth) {
194
197
  const result = await this.runner({ command: h.commandPath, args: auth }, 5_000);
195
- const state = nativeReadinessFailure(result.stdout, result.stderr);
198
+ const state = nativeReadinessFailure(result.stdout, result.stderr, true);
196
199
  if (state) return this.value(h, state, input.model);
197
200
  if (h.id === "claude-code") {
198
201
  try { if (JSON.parse(result.stdout).loggedIn === false) return this.value(h, "login-required", input.model); } catch { /* verification below */ }
199
202
  }
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);
203
+ if (result.timedOut) return { ...this.value(h, "unverified", input.model), message: "The native sign-in check timed out. No sign-in or setup failure was confirmed." };
204
+ if (result.exitCode !== 0) return { ...this.value(h, "unverified", input.model), message: "The native check exited without a recognized blocker. A task can still run." };
202
205
  }
203
206
  if (!input.verify) return this.value(h, "unverified", input.model);
204
207
  const dir = mkdtempSync(join(tmpdir(), "ennodia-readiness-"));
@@ -206,13 +209,14 @@ export class HarnessReadinessManager {
206
209
  try {
207
210
  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
211
  const result = await this.runner(spec, 25_000);
209
- const failure = nativeReadinessFailure(result.stdout, result.stderr);
212
+ const failure = nativeReadinessFailure(result.stdout, result.stderr, true);
210
213
  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);
214
+ if (result.timedOut) return { ...this.value(h, "unverified", input.model), message: "Model verification timed out after 25 seconds. A task can still run with its own time limit." };
215
+ if (result.exitCode !== 0 || adapter.failureReason?.(result.stdout, result.stderr)) return { ...this.value(h, "unverified", input.model), message: "Model verification did not complete. A task can still run." };
212
216
  let answer = adapter.readinessAnswer ? adapter.readinessAnswer(result.stdout) ?? "" : result.stdout;
213
217
  try { answer = readFileSync(join(dir, "answer.txt"), "utf8"); } catch { /* native text output */ }
214
218
  // 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);
219
+ if (!stripVTControlCharacters(answer).split(/\r?\n/).some(line => line.trim() === nonce)) return { ...this.value(h, "unverified", input.model), message: "Model verification did not return the expected response. A task can still run." };
216
220
  return this.value(h, "ready", input.model);
217
221
  } finally { rmSync(dir, { recursive: true, force: true }); }
218
222
  }
@@ -238,7 +242,8 @@ export class HarnessReadinessManager {
238
242
  const command = harnessUpgradeCommand(h);
239
243
  const checkedAt = new Date(this.now()).toISOString();
240
244
  const save = (status: NonNullable<HarnessReadiness["upgrade"]>["status"], message: string) => {
241
- this.updates.set(h.id, { status, checkedAt, command, message }); this.invalidate(h.id);
245
+ this.updates.set(h.id, { status, checkedAt, command, message });
246
+ if (status === "succeeded" || status === "failed") this.invalidate(h.id);
242
247
  };
243
248
  if (!command) { save("unsupported", "No supported updater was found for this installation. Use its original installer."); return; }
244
249
  if (this.options.isBusy?.() || this.flights.size > 0) { save("deferred", "Upgrade deferred while Ennodia work is active."); return; }
package/src/server.ts CHANGED
@@ -108,7 +108,7 @@ export function createEnnodiaServer(
108
108
  catch (error) {
109
109
  if (error instanceof HarnessReadinessError) {
110
110
  const installation = core.getInstallation?.();
111
- return { ...setupResult({ error: error.message, installation, actionRequired: error.actionRequired, coverage: { started: 0 } }, error.actionRequired, installation), isError: true };
111
+ return { ...jsonResult({ error: error.message, installation, actionRequired: error.actionRequired, coverage: { started: 0 } }), isError: true };
112
112
  }
113
113
  throw error;
114
114
  }
@@ -149,8 +149,7 @@ export function createEnnodiaServer(
149
149
 
150
150
  server.registerTool("ennodia_check_harnesses", {
151
151
  title: "Recheck or upgrade Ennodia harnesses",
152
- description: "Recheck Ennodia's installation and recover affected harnesses. Return installation scope, runtime/package/storage limitations, native recovery commands and actionRequired. Target affected harnessIds; unrelated blockers do not prevent authorized work on usable routes. verify makes a small real model call; upgrade runs supported updaters before rechecking. Never switch account/provider/model silently.",
153
- _meta: SETUP_APP_TOOL_META,
152
+ description: "Recheck selected harnesses and return compact diagnostics. Target affected harnessIds; unrelated blockers do not prevent work. verify makes an optional real model call; a timeout leaves access unverified and does not block tasks. upgrade runs supported updaters before rechecking. Never switch account/provider/model silently. Show a brief changed result; use ennodia_setup only when the user asks for the full setup view.",
154
153
  inputSchema: {
155
154
  harnessIds: z.array(z.string().min(1)).min(1).optional().describe("Harnesses to recheck. Omit for all installed harnesses."),
156
155
  model: z.string().min(1).optional().describe("Exact model to verify on the selected route."),
@@ -161,7 +160,7 @@ export function createEnnodiaServer(
161
160
  },
162
161
  }, async input => {
163
162
  const result = await core.checkHarnesses(input);
164
- return setupResult(result, result.harnesses, result.installation);
163
+ return jsonResult(result);
165
164
  });
166
165
 
167
166
  // Hosts that render MCP Apps show this card for the two tools above.
@@ -1337,9 +1336,9 @@ export async function shutdownEnnodia(
1337
1336
  export function serverInstructions(installation?: InstallationReport): string {
1338
1337
  const lines = [
1339
1338
  "Ennodia hands work to the user's other local agents. Do not open with Ennodia's status or list agents the user did not ask about: ennodia_run checks the agents it selects before starting.",
1340
- "If a requested agent is blocked, the result carries an ennodia://setup panel: show the user its fix as written. After the user fixes it, recheck that agent and model with ennodia_check_harnesses (verify: true), then continue the task. If a run skipped agents (actionRequired), say so in one line. Unrelated blockers must not hold up work on usable agents.",
1339
+ "For a confirmed blocker on a requested agent, state its cause and native recovery action in one short line. Do not repeat unchanged warnings or automatically retry checks. After recovery, recheck that harness and model. If agents were skipped (actionRequired), say so in one line. Unrelated blockers must not hold up usable agents.",
1341
1340
  "For setup or status requests, call ennodia_setup and show its panel. Hosts that render MCP Apps show it as a card; then add one line at most.",
1342
- "Unverified means not checked yet, not failed. A verified default model does not prove access to other models. Never collect credentials in chat, never switch account, provider or model silently, and never claim a check ran when it did not. Follow the user's existing authorization and the host's approval policy; add no approval step of your own.",
1341
+ "Unverified means model access is unknown, not failed; the requested task can verify it. Use a separate model probe only when needed. A reload notice after an update is not a broken installation; mention it once when relevant. Never collect credentials in chat, switch account/provider/model silently, or claim a check ran when it did not. Follow existing authorization; add no approval step of your own.",
1343
1342
  ];
1344
1343
  if (installation?.status === "broken") lines.push(`Ennodia's installation is broken: ${installation.limitations.join(" ")} Tell the user before starting work and link ${INSTALL_GUIDE_URL}.`);
1345
1344
  else if (installation?.status === "limited") lines.push(`Installation notes, to mention only when they affect the request: ${installation.limitations.join(" ")}`);
@@ -1357,16 +1356,17 @@ function setupResult(value: unknown, routes: (HarnessDiscovery | HarnessAttentio
1357
1356
  const cell = (text: string) => text.replaceAll("|", "\\|").replace(/[\r\n]+/g, " ");
1358
1357
  const lines = ["## Ennodia agents"];
1359
1358
  if (installation && installation.status !== "healthy") {
1360
- lines.push("", `Installation ${installation.status}:`, ...installation.limitations.map(limit => `- ${limit}`), "", `Repair it with the [installation guide](${INSTALL_GUIDE_URL}), then restart the MCP connection.`);
1359
+ lines.push("", installation.status !== "broken" && installation.restartRequired ? "Ennodia update ready:" : `Installation ${installation.status}:`, ...installation.limitations.map(limit => `- ${limit}`));
1360
+ if (!installation.restartRequired || installation.status === "broken") lines.push("", `[Installation guide](${INSTALL_GUIDE_URL})`);
1361
1361
  }
1362
1362
  const rows = routes.map(route => {
1363
1363
  const key = accessKey(route);
1364
- return { name: "id" in route ? route.name : route.harnessName, model: route.readiness?.model, key, command: key === "login-required" ? route.readiness?.recovery?.command ?? [] : [] };
1364
+ return { name: "id" in route ? route.name : route.harnessName, model: route.readiness?.model, message: route.readiness?.message, key, command: key === "login-required" ? route.readiness?.recovery?.command ?? [] : [] };
1365
1365
  }).sort((a, b) => accessRank(a.key) - accessRank(b.key));
1366
1366
  if (rows.length) lines.push("", "| Agent | Status | Next step |", "| --- | --- | --- |");
1367
1367
  else lines.push("", `No supported agents were found. Install one with the [harness guide](${HARNESS_GUIDE_URL}).`);
1368
1368
  for (const row of rows) {
1369
- const next = row.key === "missing" ? `${ACCESS_TEXT.missing.next} [Install guide](${HARNESS_GUIDE_URL})` : ACCESS_TEXT[row.key].next;
1369
+ const next = row.key === "missing" ? `${ACCESS_TEXT.missing.next} [Install guide](${HARNESS_GUIDE_URL})` : row.key === "unverified" && row.message ? row.message : ACCESS_TEXT[row.key].next;
1370
1370
  lines.push(`| ${cell(row.name + (row.model ? ` · ${row.model}` : ""))} | ${ACCESS_TEXT[row.key].label} | ${cell(next)} |`);
1371
1371
  }
1372
1372
  // Only a login blocker calls for a command. Other blockers need credits or a new key.
package/src/setup-app.ts CHANGED
@@ -21,16 +21,16 @@ export const ACCESS_TEXT: Record<AccessKey, { label: string; next: string }> = {
21
21
  "quota-exhausted": { label: "Out of quota", next: "Restore credits or quota with the provider, then check again. Signing in does not help." },
22
22
  "model-unavailable": { label: "Model unavailable", next: "This agent cannot use this model. Pick another from its model list." },
23
23
  "setup-required": { label: "Needs setup", next: "Finish the agent's own setup, then check again." },
24
- unreachable: { label: "Check failed", next: "The check did not finish. Look at the connection or the agent's setup, then check again." },
24
+ unreachable: { label: "Could not verify", next: "Model access is unknown. This does not confirm a sign-in or setup problem." },
25
25
  updating: { label: "Updating", next: "An update is running. Check again when it finishes." },
26
- unverified: { label: "Not checked yet", next: "Ennodia checks it on first use." },
26
+ unverified: { label: "Not verified", next: "A task can run without a separate model check." },
27
27
  ready: { label: "Ready", next: "" },
28
28
  missing: { label: "Not available", next: "Not installed, or it cannot run without a terminal." },
29
29
  };
30
30
 
31
31
  /** Agents that need the user come first, unavailable ones last. */
32
32
  export function accessRank(key: AccessKey): number {
33
- return key === "unverified" ? 1 : key === "ready" ? 2 : key === "missing" ? 3 : 0;
33
+ return key === "unverified" || key === "unreachable" ? 1 : key === "ready" ? 2 : key === "missing" ? 3 : 0;
34
34
  }
35
35
 
36
36
  export const HARNESS_GUIDE_URL = "https://ennodia.cherninlab.com/docs/reference/supported-harnesses/";
@@ -88,7 +88,7 @@ const SETUP_APP_SCRIPT = String.raw`
88
88
  function normalize(h) {
89
89
  var r = h.readiness || {};
90
90
  var key = h.runnable === false ? "missing" : (ACCESS[r.state] ? r.state : "unverified");
91
- return { id: h.id, name: h.name || h.id, model: r.model || "", key: key,
91
+ return { id: h.id, name: h.name || h.id, model: r.model || "", message: r.message || "", key: key,
92
92
  command: key === "login-required" && r.recovery && Array.isArray(r.recovery.command) ? r.recovery.command : [] };
93
93
  }
94
94
  function find(id) { for (var i = 0; i < state.agents.length; i++) if (state.agents[i].id === id) return state.agents[i]; return null; }
@@ -125,7 +125,7 @@ const SETUP_APP_SCRIPT = String.raw`
125
125
  if (data.installation) state.installation = data.installation;
126
126
  merge(data.harnesses);
127
127
  var now = find(agent.id);
128
- state.notes[agent.id] = now && now.key === "ready" ? "Ready now." : now && now.key === before ? "No change yet." : "";
128
+ state.notes[agent.id] = now && now.key === "ready" ? "Ready now." : "";
129
129
  if (now && now.key === "ready" && before !== "ready") state.newlyReady[agent.id] = true;
130
130
  }, function (error) { state.notes[agent.id] = error.message || "The check failed."; })
131
131
  .then(function () { delete state.busy[agent.id]; render(); });
@@ -178,12 +178,12 @@ const SETUP_APP_SCRIPT = String.raw`
178
178
  var head = el("div", "head");
179
179
  head.appendChild(el("span", "dot"));
180
180
  var name = el("span", "name", agent.name);
181
- if (agent.model) name.appendChild(el("span", "model", agent.model));
181
+ if (agent.model) name.appendChild(el("span", "model", " · " + agent.model));
182
182
  head.appendChild(name);
183
183
  head.appendChild(el("span", "status", ACCESS[agent.key].label));
184
184
  item.appendChild(head);
185
- // An unchecked agent needs nothing from the user; its button is enough.
186
- if (ACCESS[agent.key].next && agent.key !== "unverified") item.appendChild(el("p", "next", ACCESS[agent.key].next));
185
+ var next = agent.key === "unverified" && agent.message ? agent.message : ACCESS[agent.key].next;
186
+ if (next) item.appendChild(el("p", "next", next));
187
187
  if (agent.command.length) {
188
188
  var row = el("div", "command");
189
189
  var code = el("code", "", shellQuote(agent.command));
@@ -216,11 +216,11 @@ const SETUP_APP_SCRIPT = String.raw`
216
216
  install.textContent = "";
217
217
  install.hidden = !report || report.status === "healthy";
218
218
  if (!install.hidden) {
219
- install.appendChild(el("p", "", "Ennodia installation: " + report.status + "."));
219
+ install.appendChild(el("p", "", report.status !== "broken" && report.restartRequired ? "Ennodia update ready." : "Ennodia installation: " + report.status + "."));
220
220
  var notes = el("ul");
221
221
  (report.limitations || []).forEach(function (text) { notes.appendChild(el("li", "", text)); });
222
222
  install.appendChild(notes);
223
- install.appendChild(button("Installation guide", "", function () { openLink(__INSTALL__); }));
223
+ if (!report.restartRequired || report.status === "broken") install.appendChild(button("Installation guide", "", function () { openLink(__INSTALL__); }));
224
224
  }
225
225
  document.getElementById("cost").hidden = !state.host.serverTools || !state.agents.some(function (a) { return a.key !== "ready" && a.key !== "missing"; });
226
226
  resize();
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const ENNODIA_VERSION = "0.5.0";
1
+ export const ENNODIA_VERSION = "0.5.2";