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/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";
package/src/server.ts CHANGED
@@ -10,7 +10,10 @@ import {
10
10
  import { renderPlanMermaid } from "./planner";
11
11
  import { formatHarnessPriorityList } from "./priority";
12
12
  import { ENNODIA_VERSION } from "./version";
13
- import { REASONING_EFFORTS } from "./harnesses";
13
+ import { REASONING_EFFORTS, type HarnessDiscovery } from "./harnesses";
14
+ import { HarnessReadinessError, harnessAttention, type HarnessAttention } from "./readiness";
15
+ import type { InstallationReport } from "./installation";
16
+ import { ACCESS_TEXT, HARNESS_GUIDE_URL, INSTALL_GUIDE_URL, MCP_APP_MIME_TYPE, SETUP_APP_HTML, SETUP_APP_TOOL_META, SETUP_APP_URI, accessRank, type AccessKey } from "./setup-app";
14
17
 
15
18
  const defaultCore = createDefaultEnnodiaCore();
16
19
  const comparePriorityText = formatHarnessPriorityList();
@@ -60,6 +63,8 @@ export const CORE_TOOL_NAMES: readonly string[] = [
60
63
  "ennodia_get_run",
61
64
  "ennodia_cancel_run",
62
65
  "ennodia_list_harnesses",
66
+ "ennodia_setup",
67
+ "ennodia_check_harnesses",
63
68
  "ennodia_list_skills",
64
69
  "ennodia_history",
65
70
  ];
@@ -69,7 +74,7 @@ export const CORE_TOOL_NAMES: readonly string[] = [
69
74
  * model providers, and their workers can change files when the caller allows
70
75
  * it, so they claim neither read-only nor harmless. */
71
76
  export function toolAnnotations(name: string): { readOnlyHint: boolean; openWorldHint: boolean; destructiveHint?: boolean; idempotentHint?: boolean } {
72
- if (/^ennodia_(?:list_|get_|history$|plan$|estimate_)/.test(name)) return { readOnlyHint: true, openWorldHint: false };
77
+ if (/^ennodia_(?:list_|get_|history$|plan$|estimate_|setup$)/.test(name)) return { readOnlyHint: true, openWorldHint: false };
73
78
  if (name.startsWith("ennodia_cancel_")) return { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
74
79
  if (name === "ennodia_install_skills") return { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
75
80
  return { readOnlyHint: false, openWorldHint: true };
@@ -90,13 +95,24 @@ export function createEnnodiaServer(
90
95
  const server = new McpServer({
91
96
  name: "ennodia",
92
97
  version: ENNODIA_VERSION,
93
- });
98
+ }, { instructions: serverInstructions(core.getInstallation?.()) });
94
99
 
95
100
  // Keep each registration, so the core set can drop the others below, and
96
101
  // add the annotations that tell clients which calls only read.
97
102
  const registered = new Map<string, RegisteredTool>();
98
103
  const registerTool = server.registerTool.bind(server) as (name: string, config: { annotations?: object }, ...rest: unknown[]) => RegisteredTool;
99
104
  server.registerTool = ((name: string, config: { annotations?: object }, ...rest: unknown[]) => {
105
+ const handler = rest[rest.length - 1] as (...args: unknown[]) => Promise<unknown>;
106
+ rest[rest.length - 1] = async (...args: unknown[]) => {
107
+ try { return await handler(...args); }
108
+ catch (error) {
109
+ if (error instanceof HarnessReadinessError) {
110
+ const installation = core.getInstallation?.();
111
+ return { ...setupResult({ error: error.message, installation, actionRequired: error.actionRequired, coverage: { started: 0 } }, error.actionRequired, installation), isError: true };
112
+ }
113
+ throw error;
114
+ }
115
+ };
100
116
  const tool = registerTool(name, { ...config, annotations: { ...toolAnnotations(name), ...config.annotations } }, ...rest);
101
117
  registered.set(name, tool);
102
118
  return tool;
@@ -107,7 +123,7 @@ export function createEnnodiaServer(
107
123
  {
108
124
  title: "List Ennodia harnesses",
109
125
  description:
110
- "Discover local harnesses, availability, versions, capabilities, adapter notes, and inputGuidance. Before audio/image/video work, read inputGuidance: model capabilities do not guarantee native input through a harness. Use first when setup or routing fails.",
126
+ "Discover supported agents and cached readiness for routing. Do not summarize it for the user; for setup or status requests call ennodia_setup. Read inputGuidance before media work.",
111
127
  inputSchema: {
112
128
  refresh: z
113
129
  .boolean()
@@ -120,6 +136,42 @@ export function createEnnodiaServer(
120
136
  async ({ refresh }) => jsonResult(await core.listHarnesses({ refresh })),
121
137
  );
122
138
 
139
+ // Read-only, so clients such as Codex show setup without an approval prompt.
140
+ server.registerTool("ennodia_setup", {
141
+ title: "Show Ennodia setup",
142
+ description: "Show the user Ennodia's agents: each agent's status and the exact fix for blocked ones. Refreshes native sign-in status without model requests. Hosts that render MCP Apps show a card with check buttons; others get a Markdown panel to show the user.",
143
+ _meta: SETUP_APP_TOOL_META,
144
+ }, async () => {
145
+ const harnesses = await core.listHarnesses({ refresh: true });
146
+ const installation = core.getInstallation?.(true);
147
+ return setupResult({ installation, harnesses, actionRequired: harnessAttention(harnesses) }, harnesses, installation);
148
+ });
149
+
150
+ server.registerTool("ennodia_check_harnesses", {
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,
154
+ inputSchema: {
155
+ harnessIds: z.array(z.string().min(1)).min(1).optional().describe("Harnesses to recheck. Omit for all installed harnesses."),
156
+ model: z.string().min(1).optional().describe("Exact model to verify on the selected route."),
157
+ cwd: z.string().optional().describe("Workspace identity for the readiness receipt; probes run in an empty temporary directory."),
158
+ refresh: z.boolean().default(true).describe("Invalidate cached readiness and recheck native status."),
159
+ verify: z.boolean().default(false).describe("Verify each selected route with a bounded real model request. Uses provider capacity."),
160
+ upgrade: z.boolean().default(false).describe("Run supported updaters now, then rediscover and recheck. Deferred while Ennodia tasks are active."),
161
+ },
162
+ }, async input => {
163
+ const result = await core.checkHarnesses(input);
164
+ return setupResult(result, result.harnesses, result.installation);
165
+ });
166
+
167
+ // Hosts that render MCP Apps show this card for the two tools above.
168
+ // Other hosts ignore the resource and keep the Markdown panel.
169
+ server.registerResource("ennodia-setup", SETUP_APP_URI, {
170
+ title: "Ennodia setup",
171
+ description: "Setup card for hosts that render MCP Apps.",
172
+ mimeType: MCP_APP_MIME_TYPE,
173
+ }, async () => ({ contents: [{ uri: SETUP_APP_URI, mimeType: MCP_APP_MIME_TYPE, text: SETUP_APP_HTML, _meta: { ui: { prefersBorder: true, permissions: { clipboardWrite: {} } } } }] }));
174
+
123
175
  server.registerTool(
124
176
  "ennodia_list_skills",
125
177
  {
@@ -690,7 +742,7 @@ export function createEnnodiaServer(
690
742
  prompt: z
691
743
  .string()
692
744
  .min(1)
693
- .describe("The user task to route to local AI harnesses. For native media, first read ennodia_list_harnesses inputGuidance, specify local paths and a harness/model, then verify one small sample with mode single and compare false. Ennodia sends text, not media attachments."),
745
+ .describe("The user task to route to local AI harnesses. Include relevant Keep in mind constraints, evidence, and unresolved assumptions in this prompt. For native media, first read ennodia_list_harnesses inputGuidance, specify local paths and a harness/model, then verify one small sample with mode single and compare false. Ennodia sends text, not media attachments."),
694
746
  category: categorySchema.optional(),
695
747
  harnessId: z
696
748
  .string()
@@ -850,7 +902,7 @@ export function createEnnodiaServer(
850
902
  {
851
903
  title: "Get Ennodia run",
852
904
  description:
853
- "Inspect a run or wait up to waitMs for completion. Prefer waitMs: 30000, compact: true and includeEvents: false while awaiting results. A nonterminal response means work continues; do not claim its findings were consumed.",
905
+ "Inspect a run or wait up to waitMs for completion, including task-specific keepInMind notes when Compare returns them. Prefer waitMs: 30000, compact: true and includeEvents: false while awaiting results. A nonterminal response means work continues; do not claim its findings were consumed.",
854
906
  inputSchema: {
855
907
  runId: z.string().min(1).describe("Run ID returned by ennodia_run."),
856
908
  compact: z.boolean().default(false).describe("Omit repeated routing, budget, prompt preview and skill metadata. Preserve status, timing, task IDs, answers and errors. Answer and event limits still apply. Use false for full diagnostics."),
@@ -1141,7 +1193,7 @@ export function createEnnodiaServer(
1141
1193
  {
1142
1194
  title: "Get Ennodia Judge + Result Advisor comparison",
1143
1195
  description:
1144
- "Inspect comparison status, candidate inputs, Judge analysis, Result Advisor answer, child task IDs, degradation state, and ETA.",
1196
+ "Inspect comparison status, candidate inputs, Judge analysis, Result Advisor answer and keepInMind notes with evidence and uncertainty, child task IDs, degradation state, and ETA.",
1145
1197
  inputSchema: {
1146
1198
  compareId: z
1147
1199
  .string()
@@ -1280,6 +1332,53 @@ export async function shutdownEnnodia(
1280
1332
  await defaultCore.shutdown(options);
1281
1333
  }
1282
1334
 
1335
+ /** Startup instructions load into every session that has Ennodia configured,
1336
+ * so they stay short and keep Ennodia quiet until there is something to fix. */
1337
+ export function serverInstructions(installation?: InstallationReport): string {
1338
+ const lines = [
1339
+ "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.",
1341
+ "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.",
1343
+ ];
1344
+ 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
+ else if (installation?.status === "limited") lines.push(`Installation notes, to mention only when they affect the request: ${installation.limitations.join(" ")}`);
1346
+ return lines.join("\n");
1347
+ }
1348
+
1349
+ function accessKey(route: HarnessDiscovery | HarnessAttention): AccessKey {
1350
+ return "id" in route && !route.runnable ? "missing" : route.readiness?.state ?? "unverified";
1351
+ }
1352
+
1353
+ /** Keep the JSON text contract intact for clients that parse tool text, and
1354
+ * add the panel an agent shows when the host has no card: each agent's status
1355
+ * in plain words, then the exact sign-in commands. */
1356
+ function setupResult(value: unknown, routes: (HarnessDiscovery | HarnessAttention)[], installation?: InstallationReport) {
1357
+ const cell = (text: string) => text.replaceAll("|", "\\|").replace(/[\r\n]+/g, " ");
1358
+ const lines = ["## Ennodia agents"];
1359
+ 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.`);
1361
+ }
1362
+ const rows = routes.map(route => {
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 ?? [] : [] };
1365
+ }).sort((a, b) => accessRank(a.key) - accessRank(b.key));
1366
+ if (rows.length) lines.push("", "| Agent | Status | Next step |", "| --- | --- | --- |");
1367
+ else lines.push("", `No supported agents were found. Install one with the [harness guide](${HARNESS_GUIDE_URL}).`);
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;
1370
+ lines.push(`| ${cell(row.name + (row.model ? ` · ${row.model}` : ""))} | ${ACCESS_TEXT[row.key].label} | ${cell(next)} |`);
1371
+ }
1372
+ // Only a login blocker calls for a command. Other blockers need credits or a new key.
1373
+ for (const row of rows.filter(row => row.command.length)) {
1374
+ const command = row.command.map(arg => /^[A-Za-z0-9_./:@=+-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`).join(" ");
1375
+ const fence = "`".repeat(Math.max(3, ...[...command.matchAll(/`+/g)].map(match => match[0].length + 1)));
1376
+ lines.push("", `Sign in to ${row.name} in a terminal:`, "", `${fence}sh\n${command}\n${fence}`);
1377
+ }
1378
+ if (rows.some(row => accessRank(row.key) === 0)) lines.push("", "Keep sign-in and API keys in each agent's own CLI, never in this chat.");
1379
+ return { content: [...jsonResult(value).content, { type: "resource" as const, resource: { uri: "ennodia://setup", mimeType: "text/markdown", text: lines.join("\n") } }] };
1380
+ }
1381
+
1283
1382
  function jsonResult(value: unknown) {
1284
1383
  return {
1285
1384
  content: [
@@ -0,0 +1,338 @@
1
+ import type { ReadinessState } from "./readiness";
2
+ import { ENNODIA_VERSION } from "./version";
3
+
4
+ /** MCP Apps (`io.modelcontextprotocol/ui`, stable 2026-01-26) lets a host show
5
+ * a server's HTML view inside the conversation. Ennodia uses one view: a card
6
+ * of agents and their fixes. Other hosts show the Markdown panel instead.
7
+ * The card calls only `ennodia_check_harnesses` and never collects credentials. */
8
+ export const MCP_APPS_EXTENSION = "io.modelcontextprotocol/ui";
9
+ export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
10
+ export const SETUP_APP_URI = "ui://ennodia/setup.html";
11
+
12
+ /** The flat key serves hosts that predate the nested `ui.resourceUri` form. */
13
+ export const SETUP_APP_TOOL_META = { ui: { resourceUri: SETUP_APP_URI }, "ui/resourceUri": SETUP_APP_URI };
14
+
15
+ export type AccessKey = ReadinessState | "missing";
16
+
17
+ /** Plain words for each agent state, shared by the Markdown panel and the card. */
18
+ export const ACCESS_TEXT: Record<AccessKey, { label: string; next: string }> = {
19
+ "login-required": { label: "Needs sign-in", next: "Sign in with the command below, then check again." },
20
+ "credentials-expired": { label: "API key expired", next: "Replace the key in the agent's own settings, then check again. Signing in does not renew it." },
21
+ "quota-exhausted": { label: "Out of quota", next: "Restore credits or quota with the provider, then check again. Signing in does not help." },
22
+ "model-unavailable": { label: "Model unavailable", next: "This agent cannot use this model. Pick another from its model list." },
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." },
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." },
27
+ ready: { label: "Ready", next: "" },
28
+ missing: { label: "Not available", next: "Not installed, or it cannot run without a terminal." },
29
+ };
30
+
31
+ /** Agents that need the user come first, unavailable ones last. */
32
+ export function accessRank(key: AccessKey): number {
33
+ return key === "unverified" ? 1 : key === "ready" ? 2 : key === "missing" ? 3 : 0;
34
+ }
35
+
36
+ export const HARNESS_GUIDE_URL = "https://ennodia.cherninlab.com/docs/reference/supported-harnesses/";
37
+ export const INSTALL_GUIDE_URL = "https://ennodia.cherninlab.com/docs/install/";
38
+
39
+ /** Plain ES5 so it runs in any host webview without a build step. */
40
+ const SETUP_APP_SCRIPT = String.raw`
41
+ (function () {
42
+ "use strict";
43
+ var ACCESS = __ACCESS__, RANK = __RANK__;
44
+ var state = { agents: [], installation: null, input: {}, host: {}, busy: {}, notes: {}, newlyReady: {}, error: "", loaded: false };
45
+ var nextId = 1, pending = {}, lastHeight = 0;
46
+
47
+ function post(message) { window.parent.postMessage(message, "*"); }
48
+ function request(method, params) {
49
+ var id = nextId++;
50
+ post({ jsonrpc: "2.0", id: id, method: method, params: params || {} });
51
+ return new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; });
52
+ }
53
+ function notify(method, params) { post({ jsonrpc: "2.0", method: method, params: params || {} }); }
54
+ function reply(id, result) { post({ jsonrpc: "2.0", id: id, result: result }); }
55
+
56
+ window.addEventListener("message", function (event) {
57
+ if (event.source !== window.parent) return;
58
+ var m = event.data;
59
+ if (!m || m.jsonrpc !== "2.0") return;
60
+ if (m.method === undefined) {
61
+ var waiting = pending[m.id];
62
+ if (!waiting) return;
63
+ delete pending[m.id];
64
+ if (m.error) waiting.reject(new Error(m.error.message || "The host rejected the request."));
65
+ else waiting.resolve(m.result);
66
+ return;
67
+ }
68
+ switch (m.method) {
69
+ case "ui/notifications/tool-input": state.input = (m.params && m.params.arguments) || {}; break;
70
+ case "ui/notifications/tool-result": showResult(m.params); break;
71
+ case "ui/notifications/tool-cancelled": state.loaded = true; state.error = "The check was cancelled."; render(); break;
72
+ case "ui/notifications/host-context-changed": applyContext(m.params); break;
73
+ case "ui/resource-teardown": case "ping": if (m.id !== undefined) reply(m.id, {}); break;
74
+ default: if (m.id !== undefined) post({ jsonrpc: "2.0", id: m.id, error: { code: -32601, message: "Method not found" } });
75
+ }
76
+ });
77
+
78
+ function parse(result) {
79
+ var blocks = (result && result.content) || [];
80
+ for (var i = 0; i < blocks.length; i++) {
81
+ if (blocks[i].type !== "text") continue;
82
+ try { return JSON.parse(blocks[i].text); } catch (error) { return { error: blocks[i].text }; }
83
+ }
84
+ return { error: "Ennodia returned no agent data." };
85
+ }
86
+ function failed(result, data) { return Boolean(result && result.isError || data.error); }
87
+
88
+ function normalize(h) {
89
+ var r = h.readiness || {};
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,
92
+ command: key === "login-required" && r.recovery && Array.isArray(r.recovery.command) ? r.recovery.command : [] };
93
+ }
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; }
95
+ function merge(list) {
96
+ (list || []).forEach(function (h) {
97
+ var agent = normalize(h), current = find(agent.id);
98
+ if (current) state.agents[state.agents.indexOf(current)] = agent; else state.agents.push(agent);
99
+ });
100
+ }
101
+
102
+ function showResult(result) {
103
+ var data = parse(result);
104
+ state.loaded = true;
105
+ state.error = failed(result, data) ? (data.error || "Ennodia reported an error.") : "";
106
+ if (data.installation) state.installation = data.installation;
107
+ merge(data.harnesses);
108
+ // Sort once: rows keep their place after a check, so nothing moves under the pointer.
109
+ state.agents.sort(function (a, b) { return RANK[a.key] - RANK[b.key]; });
110
+ render();
111
+ }
112
+
113
+ function check(agent) {
114
+ var args = { harnessIds: [agent.id], refresh: true, verify: true };
115
+ var model = agent.model || state.input.model;
116
+ if (model) args.model = model;
117
+ if (state.input.cwd) args.cwd = state.input.cwd;
118
+ var before = agent.key;
119
+ state.busy[agent.id] = true;
120
+ state.notes[agent.id] = "Checking…";
121
+ render();
122
+ request("tools/call", { name: "ennodia_check_harnesses", arguments: args }).then(function (result) {
123
+ var data = parse(result);
124
+ if (failed(result, data)) { state.notes[agent.id] = data.error || "The check failed."; return; }
125
+ if (data.installation) state.installation = data.installation;
126
+ merge(data.harnesses);
127
+ var now = find(agent.id);
128
+ state.notes[agent.id] = now && now.key === "ready" ? "Ready now." : now && now.key === before ? "No change yet." : "";
129
+ if (now && now.key === "ready" && before !== "ready") state.newlyReady[agent.id] = true;
130
+ }, function (error) { state.notes[agent.id] = error.message || "The check failed."; })
131
+ .then(function () { delete state.busy[agent.id]; render(); });
132
+ }
133
+
134
+ function tellAgent(agent, node) {
135
+ node.disabled = true;
136
+ var text = agent.name + (agent.model ? " (" + agent.model + ")" : "") + " is ready in Ennodia now.";
137
+ request("ui/message", { role: "user", content: [{ type: "text", text: text }] }).then(function () {
138
+ delete state.newlyReady[agent.id]; state.notes[agent.id] = "Sent to the chat."; render();
139
+ }, function () { node.disabled = false; state.notes[agent.id] = "The host did not accept the message."; render(); });
140
+ }
141
+
142
+ function openLink(url) {
143
+ if (state.host.openLinks) request("ui/open-link", { url: url }).catch(function () { window.open(url, "_blank", "noopener"); });
144
+ else window.open(url, "_blank", "noopener");
145
+ }
146
+
147
+ function shellQuote(args) {
148
+ return args.map(function (arg) { return /^[A-Za-z0-9_.\/:@=+-]+$/.test(arg) ? arg : "'" + arg.replace(/'/g, "'\\''") + "'"; }).join(" ");
149
+ }
150
+
151
+ function copy(text, node, code) {
152
+ var reset = function () { setTimeout(function () { node.textContent = "Copy"; }, 1600); };
153
+ var selected = function () {
154
+ var range = document.createRange(); range.selectNodeContents(code);
155
+ var selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range);
156
+ node.textContent = "Selected"; reset();
157
+ };
158
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { node.textContent = "Copied"; reset(); }, selected);
159
+ else selected();
160
+ }
161
+
162
+ function el(tag, className, text) {
163
+ var node = document.createElement(tag);
164
+ if (className) node.className = className;
165
+ if (text !== undefined) node.textContent = text;
166
+ return node;
167
+ }
168
+ function button(label, className, onClick) {
169
+ var node = el("button", className, label);
170
+ node.type = "button";
171
+ node.addEventListener("click", function () { onClick(node); });
172
+ return node;
173
+ }
174
+
175
+ function renderAgent(agent) {
176
+ var needsUser = RANK[agent.key] === 0;
177
+ var item = el("li", "agent " + (needsUser ? "action" : agent.key === "missing" ? "unavailable" : agent.key));
178
+ var head = el("div", "head");
179
+ head.appendChild(el("span", "dot"));
180
+ var name = el("span", "name", agent.name);
181
+ if (agent.model) name.appendChild(el("span", "model", agent.model));
182
+ head.appendChild(name);
183
+ head.appendChild(el("span", "status", ACCESS[agent.key].label));
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));
187
+ if (agent.command.length) {
188
+ var row = el("div", "command");
189
+ var code = el("code", "", shellQuote(agent.command));
190
+ row.appendChild(code);
191
+ row.appendChild(button("Copy", "ghost", function (node) { copy(code.textContent, node, code); }));
192
+ item.appendChild(row);
193
+ }
194
+ if (state.notes[agent.id]) item.appendChild(el("p", "note", state.notes[agent.id]));
195
+ var actions = el("div", "actions");
196
+ if (state.host.serverTools && agent.key !== "ready" && agent.key !== "missing") {
197
+ var checkButton = button(agent.key === "unverified" ? "Verify" : "Check again", needsUser ? "primary" : "", function () { check(agent); });
198
+ checkButton.disabled = Boolean(state.busy[agent.id]);
199
+ actions.appendChild(checkButton);
200
+ }
201
+ if (agent.key === "missing") actions.appendChild(button("Install guide", "", function () { openLink(__GUIDE__); }));
202
+ if (state.newlyReady[agent.id] && state.host.message) actions.appendChild(button("Tell the agent", "primary", function (node) { tellAgent(agent, node); }));
203
+ if (actions.childNodes.length) item.appendChild(actions);
204
+ return item;
205
+ }
206
+
207
+ function render() {
208
+ document.getElementById("loading").hidden = state.loaded;
209
+ var list = document.getElementById("agents");
210
+ list.textContent = "";
211
+ state.agents.forEach(function (a) { list.appendChild(renderAgent(a)); });
212
+ if (state.loaded && !state.agents.length && !state.error) list.appendChild(el("li", "agent", "No supported agents were found on this machine."));
213
+ var error = document.getElementById("error");
214
+ error.textContent = state.error; error.hidden = !state.error;
215
+ var install = document.getElementById("install"), report = state.installation;
216
+ install.textContent = "";
217
+ install.hidden = !report || report.status === "healthy";
218
+ if (!install.hidden) {
219
+ install.appendChild(el("p", "", "Ennodia installation: " + report.status + "."));
220
+ var notes = el("ul");
221
+ (report.limitations || []).forEach(function (text) { notes.appendChild(el("li", "", text)); });
222
+ install.appendChild(notes);
223
+ install.appendChild(button("Installation guide", "", function () { openLink(__INSTALL__); }));
224
+ }
225
+ document.getElementById("cost").hidden = !state.host.serverTools || !state.agents.some(function (a) { return a.key !== "ready" && a.key !== "missing"; });
226
+ resize();
227
+ }
228
+
229
+ function resize() {
230
+ var height = Math.ceil(document.documentElement.getBoundingClientRect().height);
231
+ if (height === lastHeight) return;
232
+ lastHeight = height;
233
+ notify("ui/notifications/size-changed", { height: height });
234
+ }
235
+
236
+ function applyContext(context) {
237
+ if (!context) return;
238
+ var root = document.documentElement;
239
+ if (context.theme === "light" || context.theme === "dark") { root.setAttribute("data-theme", context.theme); root.style.colorScheme = context.theme; }
240
+ var variables = context.styles && context.styles.variables;
241
+ if (variables) Object.keys(variables).forEach(function (key) { if (variables[key]) root.style.setProperty(key, variables[key]); });
242
+ var fonts = context.styles && context.styles.css && context.styles.css.fonts;
243
+ if (fonts && !document.getElementById("host-fonts")) { var style = el("style", "", fonts); style.id = "host-fonts"; document.head.appendChild(style); }
244
+ }
245
+
246
+ if (window.ResizeObserver) new ResizeObserver(resize).observe(document.documentElement);
247
+ render();
248
+ request("ui/initialize", { appInfo: { name: "ennodia-setup", version: __VERSION__ }, appCapabilities: { availableDisplayModes: ["inline"] }, protocolVersion: "2026-01-26" })
249
+ .then(function (result) {
250
+ state.host = (result && result.hostCapabilities) || {};
251
+ applyContext(result && result.hostContext);
252
+ notify("ui/notifications/initialized", {});
253
+ render();
254
+ }, function () { state.loaded = true; state.error = "This host did not start the Ennodia card."; render(); });
255
+ })();
256
+ `;
257
+
258
+ const SETUP_APP_STYLE = `
259
+ :root {
260
+ color-scheme: light dark;
261
+ --fb-bg: #ffffff; --fb-soft: #f4f4f2; --fb-text: #1c1b19; --fb-muted: #5f5d58; --fb-border: #e4e2dc;
262
+ --fb-ok: #1f7a3d; --fb-warn: #a65b00; --fb-bad: #b42318;
263
+ }
264
+ @media (prefers-color-scheme: dark) {
265
+ :root:not([data-theme="light"]) { --fb-bg: #1b1b1a; --fb-soft: #262624; --fb-text: #f2f1ee; --fb-muted: #a9a69f; --fb-border: #3a3936; --fb-ok: #5fd08a; --fb-warn: #f0b04d; --fb-bad: #ff8a7a; }
266
+ }
267
+ :root[data-theme="dark"] { --fb-bg: #1b1b1a; --fb-soft: #262624; --fb-text: #f2f1ee; --fb-muted: #a9a69f; --fb-border: #3a3936; --fb-ok: #5fd08a; --fb-warn: #f0b04d; --fb-bad: #ff8a7a; }
268
+ * { box-sizing: border-box; }
269
+ html, body { margin: 0; }
270
+ body {
271
+ background: var(--color-background-primary, var(--fb-bg)); color: var(--color-text-primary, var(--fb-text));
272
+ font: var(--font-text-sm-size, 14px)/var(--font-text-sm-line-height, 1.45) var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
273
+ }
274
+ main { padding: 14px 16px 12px; }
275
+ h1 { font-size: var(--font-heading-xs-size, 15px); font-weight: var(--font-weight-semibold, 600); margin: 0; }
276
+ ul { list-style: none; margin: 0; padding: 0; }
277
+ #agents { margin-top: 10px; border-top: 1px solid var(--color-border-primary, var(--fb-border)); }
278
+ .agent { padding: 10px 0; border-bottom: 1px solid var(--color-border-primary, var(--fb-border)); }
279
+ .head { display: flex; align-items: center; gap: 8px; }
280
+ .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--color-text-tertiary, var(--fb-muted)); }
281
+ .ready .dot { background: var(--color-text-success, var(--fb-ok)); }
282
+ .action .dot { background: var(--color-text-warning, var(--fb-warn)); }
283
+ .unavailable .dot { background: transparent; border: 1px solid var(--color-text-tertiary, var(--fb-muted)); }
284
+ .name { font-weight: var(--font-weight-medium, 500); min-width: 0; overflow-wrap: anywhere; }
285
+ .model { margin-left: 6px; font-weight: var(--font-weight-normal, 400); color: var(--color-text-secondary, var(--fb-muted)); font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 0.92em; }
286
+ .status { margin-left: auto; white-space: nowrap; color: var(--color-text-secondary, var(--fb-muted)); }
287
+ .ready .status { color: var(--color-text-success, var(--fb-ok)); }
288
+ .action .status { color: var(--color-text-warning, var(--fb-warn)); }
289
+ .next, .note { margin: 4px 0 0 16px; color: var(--color-text-secondary, var(--fb-muted)); }
290
+ .note { color: var(--color-text-primary, var(--fb-text)); }
291
+ .command { display: flex; align-items: center; gap: 8px; margin: 8px 0 0 16px; padding: 6px 6px 6px 10px; border-radius: var(--border-radius-md, 8px); background: var(--color-background-secondary, var(--fb-soft)); }
292
+ .command code { flex: 1 1 auto; min-width: 0; overflow-x: auto; white-space: nowrap; font: 0.92em/1.4 var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); }
293
+ .actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 8px 0 0 16px; }
294
+ button {
295
+ font: inherit; color: inherit; cursor: pointer; padding: 4px 10px; border-radius: var(--border-radius-sm, 6px);
296
+ border: 1px solid var(--color-border-primary, var(--fb-border)); background: var(--color-background-primary, var(--fb-bg));
297
+ }
298
+ button:hover { background: var(--color-background-secondary, var(--fb-soft)); }
299
+ button:focus-visible { outline: 2px solid var(--color-ring-primary, var(--fb-text)); outline-offset: 1px; }
300
+ button:disabled { cursor: default; opacity: 0.55; }
301
+ button.primary { background: var(--color-background-inverse, var(--fb-text)); color: var(--color-text-inverse, var(--fb-bg)); border-color: transparent; }
302
+ button.ghost { border-color: transparent; background: transparent; padding: 2px 8px; }
303
+ #install { margin-top: 10px; padding: 8px 10px; border-radius: var(--border-radius-md, 8px); background: var(--color-background-secondary, var(--fb-soft)); }
304
+ #install p { margin: 0 0 4px; font-weight: var(--font-weight-medium, 500); }
305
+ #install ul { margin: 0 0 8px 16px; list-style: disc; color: var(--color-text-secondary, var(--fb-muted)); }
306
+ #loading, .cost { margin: 10px 0 0; color: var(--color-text-secondary, var(--fb-muted)); }
307
+ .cost { font-size: 0.92em; }
308
+ .error { margin: 10px 0 0; color: var(--color-text-danger, var(--fb-bad)); }
309
+ `;
310
+
311
+ const ACCESS_KEYS = Object.keys(ACCESS_TEXT) as AccessKey[];
312
+
313
+ export const SETUP_APP_HTML = `<!doctype html>
314
+ <html lang="en">
315
+ <head>
316
+ <meta charset="utf-8">
317
+ <meta name="viewport" content="width=device-width, initial-scale=1">
318
+ <title>Ennodia agents</title>
319
+ <style>${SETUP_APP_STYLE}</style>
320
+ </head>
321
+ <body>
322
+ <main>
323
+ <h1>Ennodia agents</h1>
324
+ <p id="loading">Checking agents…</p>
325
+ <p id="error" class="error" role="alert" hidden></p>
326
+ <ul id="agents" aria-live="polite"></ul>
327
+ <section id="install" hidden></section>
328
+ <p id="cost" class="cost" hidden>A check sends at most one small model request.</p>
329
+ </main>
330
+ <script>${SETUP_APP_SCRIPT
331
+ .replace("__ACCESS__", () => JSON.stringify(ACCESS_TEXT))
332
+ .replace("__RANK__", () => JSON.stringify(Object.fromEntries(ACCESS_KEYS.map(key => [key, accessRank(key)]))))
333
+ .replace("__GUIDE__", () => JSON.stringify(HARNESS_GUIDE_URL))
334
+ .replace("__INSTALL__", () => JSON.stringify(INSTALL_GUIDE_URL))
335
+ .replace("__VERSION__", () => JSON.stringify(ENNODIA_VERSION))}</script>
336
+ </body>
337
+ </html>
338
+ `;