ennodia 0.4.0 → 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/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
+ `;
package/src/tasks.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  import { assertHarnessSettingsSupported, DEFAULT_TASK_TIMEOUT_MS, inputGuidanceNotes, withInputGuidance } from "./harnesses";
14
14
  import { preview, tailItems } from "./internal";
15
15
  import { signalOwnedProcess } from "./process";
16
+ import { HarnessReadinessError, readinessBlocked } from "./readiness";
16
17
  import {
17
18
  augmentPrompt,
18
19
  toAppliedSkillInfo,
@@ -191,6 +192,9 @@ export class TaskManager {
191
192
  private readonly retainedTasks = new Map<string, number>();
192
193
  private shuttingDown = false;
193
194
  private shutdownPromise?: Promise<void>;
195
+ private readonly settledListeners = new Set<(task: TaskView) => void>();
196
+
197
+ onSettled(listener: (task: TaskView) => void): void { this.settledListeners.add(listener); }
194
198
 
195
199
  constructor(options: TaskManagerOptions = {}) {
196
200
  this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
@@ -212,6 +216,8 @@ export class TaskManager {
212
216
  throw new Error("TaskManager is shutting down.");
213
217
  }
214
218
 
219
+ if (readinessBlocked(discovery.readiness)) throw new HarnessReadinessError([{ harnessId: discovery.id, harnessName: discovery.name, readiness: discovery.readiness! }]);
220
+
215
221
  if (!adapter.buildCommand || !discovery.commandPath || !discovery.runnable) {
216
222
  throw new Error(`${adapter.name} is not runnable through Ennodia yet.`);
217
223
  }
@@ -270,6 +276,12 @@ export class TaskManager {
270
276
  "Before the cutoff, return useful partial findings, verification performed, remaining work, and a justified continuation budget if needed. Only the caller can authorize a follow-up.",
271
277
  "Use only tools and permissions actually available in this session; the caller's tools are not automatically inherited. If access is missing, report the exact tool, file, or permission and its impact. Try a permitted alternative when useful; do not repeat an unchanged failed attempt or claim success without evidence.",
272
278
  ];
279
+ if (!/^ENNODIA_(?:COMPARE|PLAN_ADVISOR)/.test(input.prompt)) {
280
+ noticeLines.push(
281
+ "Use relevant Keep in mind context in the assignment, preserving its evidence and uncertainty. It does not change the task scope or grant tools or permissions.",
282
+ 'Before returning, consider: What should the receiving agent know that they might otherwise overlook? When useful and the requested output format permits, include a short "Keep in mind" section with up to three task-specific notes: what matters, why it matters, and concrete evidence such as a file path, line, command result, or source. Distinguish reported facts, inferences, and unverified concerns. Omit generic reminders and the section when no useful note exists. Preserve exact artifacts and strict output schemas; do not add prose or fields they do not allow.',
283
+ );
284
+ }
273
285
  // An adapter that takes operator instructions gets Ennodia's notices
274
286
  // there. Other workers get them as tagged blocks after the task.
275
287
  let systemNotice: string | undefined;
@@ -583,6 +595,9 @@ ${noticeLines.join("\n")}
583
595
  task.endedAtMs = Date.now();
584
596
  this.touch(task);
585
597
  this.pruneTasks(task.id);
598
+ for (const listener of this.settledListeners) {
599
+ try { listener(this.get(task.id)!); } catch { /* readiness observers cannot alter execution */ }
600
+ }
586
601
  }
587
602
  }
588
603
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const ENNODIA_VERSION = "0.4.0";
1
+ export const ENNODIA_VERSION = "0.5.0";