agent-rooms 0.11.0 → 0.12.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/dist/normalize.js CHANGED
@@ -40,6 +40,21 @@ export function createNormalizer(host, runId) {
40
40
  }
41
41
  return [ev];
42
42
  };
43
+ const finalize = (exitCode, forcedOutcome) => {
44
+ if (state.finished)
45
+ return null;
46
+ state.finished = true;
47
+ const outcome = forcedOutcome ?? (state.sawTurnFailed || exitCode !== 0 ? "failed" : "ok");
48
+ return next({
49
+ kind: "run_finished",
50
+ outcome,
51
+ summary: { actions: state.actions, files: state.files, commands: state.commands }
52
+ });
53
+ };
54
+ // The vendors' own terminal events end the RUN, not the process: codex exec can
55
+ // linger long after `turn.completed` (its card must not sit on "Working"), and
56
+ // claude emits a final `result` before exit. Process exit is only the fallback.
57
+ const done = (outcome) => finalize(outcome === "ok" ? 0 : 1, outcome);
43
58
  const mapLine = host === "codex" ? codexLine : claudeLine;
44
59
  return {
45
60
  start() {
@@ -57,7 +72,7 @@ export function createNormalizer(host, runId) {
57
72
  return [];
58
73
  }
59
74
  try {
60
- return mapLine(obj, state, next).flatMap((e) => emit(e));
75
+ return mapLine(obj, state, next, done).flatMap((e) => emit(e));
61
76
  }
62
77
  catch (error) {
63
78
  // Never crash the run on a weird event; surface it generically.
@@ -66,15 +81,9 @@ export function createNormalizer(host, runId) {
66
81
  }
67
82
  },
68
83
  finish(exitCode, forcedOutcome) {
69
- if (state.finished)
70
- return null;
71
- state.finished = true;
72
- const outcome = forcedOutcome ?? (state.sawTurnFailed || exitCode !== 0 ? "failed" : "ok");
73
- return next({
74
- kind: "run_finished",
75
- outcome,
76
- summary: { actions: state.actions, files: state.files, commands: state.commands }
77
- });
84
+ // Vendor terminal event already closed the run -> the process-exit call
85
+ // no-ops (finished guard) and never overwrites the vendor's outcome.
86
+ return finalize(exitCode, forcedOutcome);
78
87
  }
79
88
  };
80
89
  }
@@ -85,7 +94,7 @@ export function createNormalizer(host, runId) {
85
94
  // {"type":"assistant","message":{"content":[{"type":"text","text":…}|{"type":
86
95
  // "tool_use","id":…,"name":…,"input":{…}}]}} and {"type":"user","message":
87
96
  // {"content":[{"type":"tool_result","tool_use_id":…,"is_error":…}]}}.
88
- function claudeLine(obj, state, next) {
97
+ function claudeLine(obj, state, next, done) {
89
98
  const type = obj.type;
90
99
  if (type === "assistant" || type === "user") {
91
100
  const message = obj.message;
@@ -153,8 +162,9 @@ function claudeLine(obj, state, next) {
153
162
  return out;
154
163
  }
155
164
  if (type === "result") {
156
- // handled by finish(); the final text already streamed as assistant text
157
- return [];
165
+ // Claude's own terminal event — freeze the card now; the final text already
166
+ // streamed as assistant text. subtype "success" = ok, anything else failed.
167
+ return [done(obj.subtype === "success" ? "ok" : "failed")];
158
168
  }
159
169
  if (type === "system" || type === "rate_limit_event")
160
170
  return [];
@@ -164,13 +174,18 @@ function claudeLine(obj, state, next) {
164
174
  // item.* events with item.type ∈ agent_message | reasoning | command_execution |
165
175
  // file_change | todo_list | mcp_tool_call | web_search | error. Transient
166
176
  // "Reconnecting…" errors are non-fatal. turn.failed → failed outcome.
167
- function codexLine(obj, state, next) {
177
+ function codexLine(obj, state, next, done) {
168
178
  const type = String(obj.type ?? "");
169
- if (type === "thread.started" || type === "turn.started" || type === "turn.completed")
179
+ if (type === "thread.started" || type === "turn.started")
170
180
  return [];
181
+ if (type === "turn.completed") {
182
+ // codex's terminal event. The exec process can LINGER long after this (MCP
183
+ // keep-alive) — the card must freeze here, not at process exit.
184
+ return [done("ok")];
185
+ }
171
186
  if (type === "turn.failed") {
172
187
  state.sawTurnFailed = true;
173
- return [];
188
+ return [done("failed")];
174
189
  }
175
190
  if (type === "error") {
176
191
  const message = String(obj.message ?? "");
@@ -0,0 +1,266 @@
1
+ // Spec 35 §13 — four thin native integrations. The shared manifest is the
2
+ // compatibility/source-of-truth layer; host-native wrappers contain only MCP
3
+ // wiring, auth mode, and the hosted skill pointer. No coordination logic and no
4
+ // bundled skill copy live in any plugin.
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+ import { configHome } from "./config.js";
10
+ import { getAdapter, hostVersionGate, MIN_HOST_VERSIONS, readHostVersion, versionAtLeast } from "./hosts.js";
11
+ import { resolveSpawnCommand } from "./spawn.js";
12
+ import { DEFAULT_SKILL_BASE_URL } from "./skill.js";
13
+ export const NATIVE_PLUGIN_VERSION = "0.1.0";
14
+ export const NATIVE_PLUGIN_MARKETPLACE = "agent-rooms";
15
+ export const NATIVE_PLUGIN_HOSTS = ["claude_code", "codex", "openclaw", "hermes"];
16
+ const TESTED_HOST_VERSIONS = {
17
+ claude_code: "2.1.214",
18
+ codex: "0.144.5",
19
+ openclaw: "2026.7.1",
20
+ hermes: "0.18.2"
21
+ };
22
+ const MAX_EXCLUSIVE = {
23
+ claude_code: "2.2.0",
24
+ codex: "0.145.0",
25
+ openclaw: "2026.8.0",
26
+ hermes: "0.19.0"
27
+ };
28
+ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
29
+ function writeOwned(path, body) {
30
+ mkdirSync(dirname(path), { recursive: true });
31
+ writeFileSync(path, body, { encoding: "utf8", mode: 0o600 });
32
+ }
33
+ function resourceHost(host) {
34
+ return host === "claude_code" ? "claude" : host;
35
+ }
36
+ function normalizedBase(apiBase) {
37
+ return apiBase.replace(/\/$/, "");
38
+ }
39
+ export function pluginCompatibility(host, installed = readHostVersion(getAdapter(host).bin)) {
40
+ const min = MIN_HOST_VERSIONS[host] ?? "0.0.0";
41
+ const maxExclusive = MAX_EXCLUSIVE[host];
42
+ if (!installed) {
43
+ return { ok: false, installed: null, min, maxExclusive, message: `Could not read ${getAdapter(host).bin} --version. Install ${min}+ and re-run agent-rooms init.` };
44
+ }
45
+ if (!versionAtLeast(installed, min) || versionAtLeast(installed, maxExclusive)) {
46
+ return {
47
+ ok: false, installed, min, maxExclusive,
48
+ message: `${host} ${installed} is outside the tested plugin range ${min} <= version < ${maxExclusive}. Only this integration is disabled; update agent-rooms before enabling it.`
49
+ };
50
+ }
51
+ return { ok: true, installed, min, maxExclusive, message: null };
52
+ }
53
+ export function nativePluginRoot(host, home = configHome()) {
54
+ return join(home, "native-plugins", host);
55
+ }
56
+ export function nativePluginManifest(host, apiBase) {
57
+ const auth = host === "codex" ? "bearer_env" : "oauth";
58
+ return {
59
+ schema_version: 1,
60
+ id: "agent-rooms",
61
+ version: NATIVE_PLUGIN_VERSION,
62
+ host,
63
+ mcp: {
64
+ name: "agent-rooms",
65
+ url: `${normalizedBase(apiBase)}/mcp/${resourceHost(host)}`,
66
+ auth,
67
+ ...(auth === "bearer_env" ? { bearer_env: "AGENT_ROOMS_TOKEN" } : {})
68
+ },
69
+ hosted_skill_url: `${DEFAULT_SKILL_BASE_URL}/SKILL.md`,
70
+ compatibility: {
71
+ min: MIN_HOST_VERSIONS[host] ?? "0.0.0",
72
+ max_exclusive: MAX_EXCLUSIVE[host],
73
+ tested: TESTED_HOST_VERSIONS[host]
74
+ }
75
+ };
76
+ }
77
+ /** Render one host-native plugin into Agent Rooms-owned state. Returns the path
78
+ * passed to that host's native installer. */
79
+ export function stageNativePlugin(host, apiBase, home = configHome()) {
80
+ const root = nativePluginRoot(host, home);
81
+ const manifest = nativePluginManifest(host, apiBase);
82
+ mkdirSync(root, { recursive: true });
83
+ writeOwned(join(root, "agent-rooms.integration.json"), json(manifest));
84
+ const mcpServer = {
85
+ type: "http",
86
+ url: manifest.mcp.url,
87
+ ...(manifest.mcp.auth === "bearer_env"
88
+ ? { bearer_token_env_var: manifest.mcp.bearer_env }
89
+ : { oauth_resource: manifest.mcp.url })
90
+ };
91
+ const readme = `# Agent Rooms\n\nMCP resource: ${manifest.mcp.url}\n\nHosted skill: ${manifest.hosted_skill_url}\n`;
92
+ if (host === "claude_code") {
93
+ const plugin = join(root, "plugins", "agent-rooms");
94
+ writeOwned(join(root, ".claude-plugin", "marketplace.json"), json({
95
+ name: NATIVE_PLUGIN_MARKETPLACE,
96
+ description: "Exact Agent Rooms MCP and hosted skill wiring",
97
+ owner: { name: "Beronel" },
98
+ plugins: [{ name: "agent-rooms", source: "./plugins/agent-rooms", description: "Agent Rooms MCP wiring", version: NATIVE_PLUGIN_VERSION }]
99
+ }));
100
+ writeOwned(join(plugin, ".claude-plugin", "plugin.json"), json({
101
+ name: "agent-rooms", version: NATIVE_PLUGIN_VERSION,
102
+ description: "Exact Agent Rooms MCP and hosted skill wiring", author: { name: "Beronel" }
103
+ }));
104
+ writeOwned(join(plugin, ".mcp.json"), json({ mcpServers: { "agent-rooms": mcpServer } }));
105
+ writeOwned(join(plugin, "README.md"), readme);
106
+ return { root, installPath: root, manifest };
107
+ }
108
+ if (host === "codex") {
109
+ const plugin = join(root, "plugins", "agent-rooms");
110
+ writeOwned(join(root, ".agents", "plugins", "marketplace.json"), json({
111
+ name: NATIVE_PLUGIN_MARKETPLACE,
112
+ interface: { displayName: "Agent Rooms" },
113
+ plugins: [{
114
+ name: "agent-rooms", source: { source: "local", path: "./plugins/agent-rooms" },
115
+ policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Communication"
116
+ }]
117
+ }));
118
+ writeOwned(join(plugin, ".codex-plugin", "plugin.json"), json({
119
+ name: "agent-rooms", version: NATIVE_PLUGIN_VERSION,
120
+ description: "Exact Agent Rooms MCP and hosted skill wiring",
121
+ author: { name: "Beronel" }, homepage: "https://tryagentroom.com", license: "Proprietary",
122
+ keywords: ["agent-rooms", "mcp", "collaboration"], mcpServers: "./.mcp.json",
123
+ interface: {
124
+ displayName: "Agent Rooms", shortDescription: "Collaborate with other agents",
125
+ longDescription: "Connect Codex to private Agent Rooms through the exact host-bound MCP resource.",
126
+ developerName: "Beronel", category: "Communication", capabilities: ["Interactive", "Read", "Write"],
127
+ websiteURL: "https://tryagentroom.com", defaultPrompt: ["Check my Agent Rooms"]
128
+ }
129
+ }));
130
+ writeOwned(join(plugin, ".mcp.json"), json({ mcpServers: { "agent-rooms": mcpServer } }));
131
+ writeOwned(join(plugin, "README.md"), readme);
132
+ return { root, installPath: root, manifest };
133
+ }
134
+ if (host === "openclaw") {
135
+ writeOwned(join(root, "package.json"), json({
136
+ name: "openclaw-plugin-agent-rooms", version: NATIVE_PLUGIN_VERSION, type: "module", private: true,
137
+ files: ["index.js", "openclaw.plugin.json", "agent-rooms.integration.json", "README.md"],
138
+ peerDependencies: { openclaw: ">=2026.3.0 <2027.0.0" }, openclaw: { extensions: ["./index.js"] }
139
+ }));
140
+ writeOwned(join(root, "openclaw.plugin.json"), json({
141
+ id: "agent-rooms", name: "Agent Rooms", description: "Exact Agent Rooms MCP and hosted skill wiring",
142
+ version: NATIVE_PLUGIN_VERSION, activation: { onStartup: true },
143
+ configSchema: { type: "object", additionalProperties: false, properties: {} }
144
+ }));
145
+ writeOwned(join(root, "index.js"), `export default { id: "agent-rooms", name: "Agent Rooms", register() {} };\n`);
146
+ writeOwned(join(root, "README.md"), readme);
147
+ return { root, installPath: root, manifest };
148
+ }
149
+ // Hermes' installed source requires plugin.yaml + __init__.py/register(ctx).
150
+ // It has no plugin API version field, so compatibility stays in our shared
151
+ // manifest and doctor disables only Hermes when the tested range is crossed.
152
+ writeOwned(join(root, "plugin.yaml"), [
153
+ "manifest_version: 1", "name: agent-rooms", `version: "${NATIVE_PLUGIN_VERSION}"`,
154
+ 'description: "Exact Agent Rooms MCP and hosted skill wiring"', 'author: "Beronel"', "hooks: []", ""
155
+ ].join("\n"));
156
+ writeOwned(join(root, "__init__.py"), `"""Agent Rooms wiring marker; coordination stays server-side."""\n\ndef register(ctx):\n return None\n`);
157
+ writeOwned(join(root, "README.md"), readme);
158
+ return { root, installPath: root, manifest };
159
+ }
160
+ function defaultRunner(command, args) {
161
+ const resolved = resolveSpawnCommand(command, args);
162
+ return spawnSync(resolved.command, resolved.args, { encoding: "utf8", stdio: "pipe", shell: false, windowsHide: true });
163
+ }
164
+ function commandText(result) {
165
+ return `${result.stdout ?? ""}\n${result.stderr ?? ""}\n${result.error?.message ?? ""}`.trim();
166
+ }
167
+ function runRequired(runner, command, args, tolerate) {
168
+ const result = runner(command, args);
169
+ const output = commandText(result);
170
+ if (result.status === 0 || (tolerate && tolerate.test(output)))
171
+ return;
172
+ throw new Error(`${command} ${args.join(" ")} failed${output ? `: ${output}` : ""}`);
173
+ }
174
+ /** Install/update the host-native wrapper. Connector auth is reconciled by the
175
+ * host CLI immediately afterward in init/start; a plugin failure prevents wakes. */
176
+ export async function installNativePlugin(host, apiBase, opts = {}) {
177
+ const compatibility = pluginCompatibility(host, opts.installedVersion === undefined ? readHostVersion(getAdapter(host).bin) : opts.installedVersion);
178
+ const home = opts.home ?? configHome();
179
+ if (!compatibility.ok) {
180
+ return { ok: false, host, root: nativePluginRoot(host, home), manifest: nativePluginManifest(host, apiBase), message: compatibility.message };
181
+ }
182
+ const staged = stageNativePlugin(host, apiBase, home);
183
+ const runner = opts.runner ?? defaultRunner;
184
+ try {
185
+ if (host === "claude_code") {
186
+ runRequired(runner, "claude", ["plugin", "marketplace", "add", staged.installPath, "--scope", "user"], /already/i);
187
+ runRequired(runner, "claude", ["plugin", "install", "agent-rooms@agent-rooms", "--scope", "user"], /already installed/i);
188
+ }
189
+ else if (host === "codex") {
190
+ runRequired(runner, "codex", ["plugin", "marketplace", "add", staged.installPath], /already/i);
191
+ runRequired(runner, "codex", ["plugin", "add", "agent-rooms@agent-rooms"], /already installed/i);
192
+ }
193
+ else if (host === "openclaw") {
194
+ runRequired(runner, "openclaw", ["plugins", "install", staged.installPath, "--force"]);
195
+ }
196
+ else {
197
+ const userHome = opts.userHome ?? homedir();
198
+ const destination = join(userHome, ".hermes", "plugins", "agent-rooms");
199
+ if (existsSync(destination)) {
200
+ const marker = join(destination, "agent-rooms.integration.json");
201
+ if (!existsSync(marker))
202
+ throw new Error(`Refusing to overwrite non-Agent-Rooms Hermes plugin at ${destination}.`);
203
+ rmSync(destination, { recursive: true, force: true });
204
+ }
205
+ mkdirSync(destination, { recursive: true });
206
+ for (const file of ["agent-rooms.integration.json", "plugin.yaml", "__init__.py", "README.md"]) {
207
+ writeOwned(join(destination, file), readFileSync(join(staged.root, file), "utf8"));
208
+ }
209
+ runRequired(runner, "hermes", ["plugins", "enable", "agent-rooms", "--no-allow-tool-override"]);
210
+ }
211
+ return { ok: true, host, root: staged.root, manifest: staged.manifest, message: `${host} plugin ${NATIVE_PLUGIN_VERSION} installed.` };
212
+ }
213
+ catch (error) {
214
+ return { ok: false, host, root: staged.root, manifest: staged.manifest, message: error.message };
215
+ }
216
+ }
217
+ export function readNativePluginManifest(host, home = configHome()) {
218
+ try {
219
+ return JSON.parse(readFileSync(join(nativePluginRoot(host, home), "agent-rooms.integration.json"), "utf8"));
220
+ }
221
+ catch {
222
+ return null;
223
+ }
224
+ }
225
+ export function removeOwnedNativePluginState(host, home = configHome()) {
226
+ const root = nativePluginRoot(host, home);
227
+ if (!existsSync(join(root, "agent-rooms.integration.json")))
228
+ return false;
229
+ rmSync(root, { recursive: true, force: true });
230
+ return true;
231
+ }
232
+ export function isNativePluginHost(host) {
233
+ return NATIVE_PLUGIN_HOSTS.includes(host);
234
+ }
235
+ /** Existing host gate plus the narrower plugin API range. */
236
+ export function nativePluginGate(host) {
237
+ const base = hostVersionGate(host);
238
+ if (!base.ok)
239
+ return pluginCompatibility(host, base.installed);
240
+ return pluginCompatibility(host, base.installed);
241
+ }
242
+ /** Fail-closed runtime gate used by direct `watch` as well as setup flows.
243
+ * A watcher must not spawn through a missing/stale wrapper after a host update. */
244
+ export function nativePluginRuntimeGate(host, apiBase, opts = {}) {
245
+ const compatibility = pluginCompatibility(host, opts.installedVersion === undefined
246
+ ? readHostVersion(getAdapter(host).bin)
247
+ : opts.installedVersion);
248
+ if (!compatibility.ok)
249
+ return compatibility;
250
+ const actual = readNativePluginManifest(host, opts.home ?? configHome());
251
+ const expected = nativePluginManifest(host, apiBase);
252
+ if (!actual) {
253
+ return { ...compatibility, ok: false, message: `${host} integration is missing. Run agent-rooms init before watch.` };
254
+ }
255
+ if (actual.schema_version !== expected.schema_version
256
+ || actual.id !== expected.id
257
+ || actual.version !== expected.version
258
+ || actual.host !== expected.host
259
+ || actual.mcp.url !== expected.mcp.url
260
+ || actual.mcp.auth !== expected.mcp.auth
261
+ || actual.mcp.bearer_env !== expected.mcp.bearer_env
262
+ || actual.hosted_skill_url !== expected.hosted_skill_url) {
263
+ return { ...compatibility, ok: false, message: `${host} integration is stale or points at a different server. Re-run agent-rooms init before watch.` };
264
+ }
265
+ return compatibility;
266
+ }
package/dist/probes.js ADDED
@@ -0,0 +1,220 @@
1
+ // Spec 35 §14 — doctor probes. Every probe is token-free: it checks setup
2
+ // (versions, endpoints, the pinned server key, spawn-command construction)
3
+ // WITHOUT ever starting a harness or making a model/provider call. Each returns
4
+ // a named, actionable result so a human or an agent reading `doctor` output
5
+ // sees exactly what to run.
6
+ import { existsSync } from "node:fs";
7
+ import { spawnSync } from "node:child_process";
8
+ import { join } from "node:path";
9
+ import { getAdapter, hostVersionGate } from "./hosts.js";
10
+ import { publicKeyFromJwk, verifyWakeFrame } from "./envelope.js";
11
+ import { isNativePluginHost, nativePluginManifest, pluginCompatibility, readNativePluginManifest } from "./plugins.js";
12
+ import { HOSTED_SKILL_MARKER } from "./skill.js";
13
+ import { resolveSpawnCommand } from "./spawn.js";
14
+ function defaultProbeRunner(command, args) {
15
+ try {
16
+ const resolved = resolveSpawnCommand(command, args);
17
+ return spawnSync(resolved.command, resolved.args, { encoding: "utf8", stdio: "pipe", shell: false, windowsHide: true });
18
+ }
19
+ catch (error) {
20
+ return { status: null, error: error instanceof Error ? error : new Error(String(error)) };
21
+ }
22
+ }
23
+ /** The exact host-bound MCP resource. The host segment is part of auth policy,
24
+ * not cosmetic routing, so doctor must never probe a shared fallback. */
25
+ export function mcpEndpointFor(apiBase, host) {
26
+ const resourceHost = host === "claude_code" ? "claude" : host;
27
+ return `${apiBase}/mcp/${resourceHost}`;
28
+ }
29
+ /** §11/§14 version gate: is each bound host new enough? Token-free (`--version`). */
30
+ export function probeHostVersions(bindings, gate = hostVersionGate) {
31
+ const hosts = [...new Set(bindings.map((b) => b.host))];
32
+ return hosts.map((host) => {
33
+ const result = gate(host);
34
+ if (result.ok) {
35
+ return { level: "pass", label: `Version (${host})`, detail: result.installed ? `${result.installed} ≥ ${result.min}` : "no minimum" };
36
+ }
37
+ return { level: "fail", label: `Version (${host})`, detail: result.installed ? `${result.installed} < ${result.min}` : "unreadable", hint: result.message ?? undefined };
38
+ });
39
+ }
40
+ /** §3/§14: is the server's message-verification key pinned and parseable? A
41
+ * listener can't act on wakes without it. Token-free. */
42
+ export function probeServerKeys(serverKeys) {
43
+ if (!serverKeys?.length) {
44
+ return { level: "fail", label: "Server keys", detail: "not pinned", hint: "Re-run `agent-rooms start` while online to fetch the verification keyset from /.well-known/jwks.json." };
45
+ }
46
+ try {
47
+ const kids = new Set();
48
+ for (const key of serverKeys) {
49
+ if (!key.kid || kids.has(key.kid))
50
+ throw new Error("missing or duplicate kid");
51
+ kids.add(key.kid);
52
+ publicKeyFromJwk(key);
53
+ }
54
+ return { level: "pass", label: "Server keys", detail: `${serverKeys.length} pinned (${[...kids].join(", ")})` };
55
+ }
56
+ catch {
57
+ return { level: "fail", label: "Server keys", detail: "keyset has malformed, duplicate, or kid-less entries", hint: "Re-run `agent-rooms start` to refresh the verification keyset." };
58
+ }
59
+ }
60
+ /** Legacy single-key probe retained for API compatibility in downstream tests. */
61
+ export function probeServerKey(serverKey) {
62
+ return probeServerKeys(serverKey ? [serverKey] : undefined);
63
+ }
64
+ /** §13/§14: validate the Agent Rooms-owned native manifest before asking a host
65
+ * to load it. This detects a stale URL/version without patching user config. */
66
+ export function probePluginManifest(host, apiBase, opts = {}) {
67
+ if (!isNativePluginHost(host)) {
68
+ return { level: "warn", label: `Plugin (${host})`, detail: "legacy host uses direct MCP wiring" };
69
+ }
70
+ const actual = opts.manifest === undefined ? readNativePluginManifest(host) : opts.manifest;
71
+ if (!actual) {
72
+ return { level: "fail", label: `Plugin (${host})`, detail: "manifest missing", hint: `Run \`agent-rooms init --host ${host} ...\` to install it.` };
73
+ }
74
+ const expected = nativePluginManifest(host, apiBase);
75
+ if (actual.schema_version !== 1 || actual.id !== "agent-rooms" || actual.host !== host || actual.version !== expected.version) {
76
+ return { level: "fail", label: `Plugin (${host})`, detail: "manifest identity/schema mismatch", hint: `Re-run agent-rooms init for ${host}.` };
77
+ }
78
+ if (actual.mcp.url !== expected.mcp.url || actual.mcp.auth !== expected.mcp.auth || actual.hosted_skill_url !== expected.hosted_skill_url) {
79
+ return { level: "fail", label: `Plugin (${host})`, detail: "MCP/auth/hosted-skill wiring is stale", hint: `Re-run agent-rooms init for ${host}.` };
80
+ }
81
+ const compatibility = pluginCompatibility(host, opts.installedVersion === undefined ? undefined : opts.installedVersion);
82
+ if (!compatibility.ok) {
83
+ return { level: "fail", label: `Plugin (${host})`, detail: compatibility.message, hint: "Update agent-rooms, then re-run init." };
84
+ }
85
+ return { level: "pass", label: `Plugin (${host})`, detail: `v${actual.version}; exact MCP + hosted skill pointer loaded` };
86
+ }
87
+ /** Ask the host itself whether the installed plugin is loaded. This is a
88
+ * read-only native registry query, never a model/session command. */
89
+ export function probeLoadedPlugin(host, runner = defaultProbeRunner) {
90
+ if (!isNativePluginHost(host))
91
+ return { level: "warn", label: `Plugin load (${host})`, detail: "legacy host has no native plugin" };
92
+ const command = getAdapter(host).bin;
93
+ const args = host === "claude_code" || host === "codex"
94
+ ? ["plugin", "list", "--json"]
95
+ : host === "openclaw"
96
+ ? ["plugins", "list", "--enabled", "--json"]
97
+ : ["plugins", "list", "--enabled", "--user", "--json"];
98
+ let result;
99
+ try {
100
+ result = runner(command, args);
101
+ }
102
+ catch (error) {
103
+ result = { status: null, error: error instanceof Error ? error : new Error(String(error)) };
104
+ }
105
+ const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}\n${result.error?.message ?? ""}`.trim();
106
+ if (result.status !== 0) {
107
+ return { level: "fail", label: `Plugin load (${host})`, detail: `native list command failed${output ? `: ${output}` : ""}`, hint: `Run agent-rooms init --host ${host} ..., then re-run doctor.` };
108
+ }
109
+ if (!/agent-rooms/i.test(output)) {
110
+ return { level: "fail", label: `Plugin load (${host})`, detail: "host registry does not show agent-rooms as loaded", hint: `Run agent-rooms init --host ${host} ..., then re-run doctor.` };
111
+ }
112
+ return { level: "pass", label: `Plugin load (${host})`, detail: "host registry reports agent-rooms loaded" };
113
+ }
114
+ export function probeHostedSkill(host, adapter = getAdapter(host), fileExists = existsSync) {
115
+ if (!adapter?.skillDir)
116
+ return { level: "warn", label: `Skill (${host})`, detail: "host has no skill directory" };
117
+ const root = join(adapter.skillDir, "agent-rooms");
118
+ return fileExists(join(root, "SKILL.md")) && fileExists(join(root, HOSTED_SKILL_MARKER))
119
+ ? { level: "pass", label: `Skill (${host})`, detail: "hosted copy present" }
120
+ : { level: "fail", label: `Skill (${host})`, detail: "hosted copy or ownership marker missing", hint: `Re-run agent-rooms init for ${host} while online.` };
121
+ }
122
+ /** §14 server-side read-only probes plus a real server-signed envelope. */
123
+ export function probeListenerDiagnostics(diagnostics, serverKeys) {
124
+ const results = [
125
+ diagnostics.handshake.reachable
126
+ ? { level: "pass", label: "Handshake", detail: `protocol v${diagnostics.protocol_version}; minimum listener ${diagnostics.min_listener_version}` }
127
+ : { level: "fail", label: "Handshake", detail: "listener handshake unavailable", hint: "Check /mcp/listener/diagnostics." },
128
+ diagnostics.registry.reachable
129
+ ? { level: "pass", label: "Registry", detail: `reachable; ${diagnostics.registry.active} active session(s)` }
130
+ : { level: "fail", label: "Registry", detail: "unreachable", hint: "Check the D1 session_registry migration and service health." },
131
+ diagnostics.queue.reachable
132
+ ? { level: "pass", label: "Queue", detail: `reachable; ${diagnostics.queue.pending} pending, ${diagnostics.queue.in_flight} in flight` }
133
+ : { level: "fail", label: "Queue", detail: "unreachable", hint: "Check the wakes migration and PresenceHub health." }
134
+ ];
135
+ const frame = diagnostics.envelope_probe;
136
+ const jwk = serverKeys.find((key) => key.kid === frame.kid);
137
+ const verified = jwk ? verifyWakeFrame(publicKeyFromJwk(jwk), frame) : null;
138
+ results.push(verified?.wake_id === frame.id
139
+ ? { level: "pass", label: "Envelope", detail: `server-signed probe verified with kid ${frame.kid}; not spawned` }
140
+ : { level: "fail", label: "Envelope", detail: `signed probe failed verification (kid ${frame.kid})`, hint: "Re-run agent-rooms start to refresh the server keyset." });
141
+ return results;
142
+ }
143
+ /** §14: probe the exact /mcp/<host> endpoint each bound host connects to. A
144
+ * healthy MCP endpoint answers 401/400 (needs auth) — NOT 500 and NOT a
145
+ * connection error. Token-free: an unauthenticated HEAD/GET, no model call. */
146
+ export async function probeMcpEndpoint(apiBase, host, fetchImpl = fetch) {
147
+ const url = mcpEndpointFor(apiBase, host);
148
+ try {
149
+ const res = await fetchImpl(url, { method: "GET" });
150
+ if (res.status >= 500) {
151
+ return { level: "fail", label: `MCP (${host})`, detail: `HTTP ${res.status} from ${url}`, hint: "The service may be degraded — retry shortly." };
152
+ }
153
+ if (res.status === 404) {
154
+ return { level: "fail", label: `MCP (${host})`, detail: `wrong resource (HTTP 404) at ${url}`, hint: `Reinstall the ${host} integration so it uses ${url}.` };
155
+ }
156
+ if ([400, 401, 403, 405, 406].includes(res.status)) {
157
+ return { level: "pass", label: `MCP (${host})`, detail: `${url} reachable and enforcing MCP/auth (HTTP ${res.status})` };
158
+ }
159
+ if (res.ok) {
160
+ const contentType = res.headers.get("content-type") ?? "";
161
+ if (contentType.includes("text/event-stream") || contentType.includes("application/json")) {
162
+ return { level: "pass", label: `MCP (${host})`, detail: `${url} returned MCP-compatible ${contentType.split(";")[0]}` };
163
+ }
164
+ return { level: "fail", label: `MCP (${host})`, detail: `unexpected HTTP ${res.status} content-type '${contentType || "missing"}'`, hint: "Check that the URL points to the MCP route, not a web page or proxy fallback." };
165
+ }
166
+ return { level: "fail", label: `MCP (${host})`, detail: `unexpected HTTP ${res.status} from ${url}`, hint: "Check the exact host integration resource and service route." };
167
+ }
168
+ catch (error) {
169
+ return { level: "fail", label: `MCP (${host})`, detail: `unreachable at ${url}`, hint: `Check your network/firewall: ${error.message}` };
170
+ }
171
+ }
172
+ /** §14: confirm the wake command CONSTRUCTS cleanly for a host (the "envelope
173
+ * injection" dry check) without spawning it — the prompt rides stdin/argv, the
174
+ * resume flag is well-formed. Token-free by construction: buildWake only builds
175
+ * a command, it never runs one. */
176
+ export function probeSpawnCommand(host) {
177
+ const adapter = getAdapter(host);
178
+ if (!adapter) {
179
+ return { level: "fail", label: `Spawn (${host})`, detail: "no adapter", hint: `No wake adapter for "${host}".` };
180
+ }
181
+ try {
182
+ const fresh = adapter.buildWake({ prompt: "doctor probe", workspace: process.cwd() });
183
+ const resumed = adapter.buildWake({ prompt: "doctor probe", workspace: process.cwd(), sessionId: "probe-session" });
184
+ const carriesPrompt = fresh.stdin === "doctor probe" || fresh.args.includes("doctor probe") || fresh.promptFile?.contents === "doctor probe";
185
+ const carriesResume = JSON.stringify(resumed.args).includes("probe-session");
186
+ if (!carriesPrompt) {
187
+ return { level: "fail", label: `Spawn (${host})`, detail: "prompt not carried", hint: `${host} buildWake dropped the prompt.` };
188
+ }
189
+ if (!carriesResume) {
190
+ return { level: "warn", label: `Spawn (${host})`, detail: "resume not encoded (fresh-session host)" };
191
+ }
192
+ return { level: "pass", label: `Spawn (${host})`, detail: `${fresh.command} command builds; resume encoded` };
193
+ }
194
+ catch (error) {
195
+ return { level: "fail", label: `Spawn (${host})`, detail: `build failed: ${error.message}` };
196
+ }
197
+ }
198
+ /** Named create/resume construction probes. These build argv only and never
199
+ * execute a harness, preserving doctor's zero-provider-call guarantee. */
200
+ export function probeSessionCommands(host, adapter = getAdapter(host)) {
201
+ if (!adapter)
202
+ return [{ level: "fail", label: `Session create (${host})`, detail: "no adapter" }];
203
+ try {
204
+ const fresh = adapter.buildWake({ prompt: "doctor probe", workspace: process.cwd() });
205
+ const resumed = adapter.buildWake({ prompt: "doctor probe", workspace: process.cwd(), sessionId: "probe-session", sessionKey: "doctor-session-key" });
206
+ const carriesPrompt = fresh.stdin === "doctor probe" || fresh.args.includes("doctor probe") || fresh.promptFile?.contents === "doctor probe";
207
+ const carriesResume = JSON.stringify(resumed.args).includes("probe-session") || JSON.stringify(resumed.args).includes("doctor-session-key");
208
+ return [
209
+ carriesPrompt
210
+ ? { level: "pass", label: `Session create (${host})`, detail: "fresh native command constructed; not executed" }
211
+ : { level: "fail", label: `Session create (${host})`, detail: "prompt transport missing", hint: `Update the ${host} adapter.` },
212
+ carriesResume || !adapter.requiresSessionId
213
+ ? { level: "pass", label: `Session resume (${host})`, detail: carriesResume ? "resume identity encoded; not executed" : "host uses deterministic session key" }
214
+ : { level: "fail", label: `Session resume (${host})`, detail: "resume identity missing", hint: `Update the ${host} adapter.` }
215
+ ];
216
+ }
217
+ catch (error) {
218
+ return [{ level: "fail", label: `Session create (${host})`, detail: error.message }];
219
+ }
220
+ }