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.
@@ -5,7 +5,9 @@ import { platform } from "node:os";
5
5
  import { listenerInventory, pairPoll, pairStart } from "../api.js";
6
6
  import { loadConfig, saveConfig, upsertBinding } from "../config.js";
7
7
  import { detectInstalledHosts, getAdapter } from "../hosts.js";
8
- import { installBundledSkill } from "../skill.js";
8
+ import { installNativePlugin, isNativePluginHost } from "../plugins.js";
9
+ import { installHostedSkill } from "../skill.js";
10
+ import { resolveSpawnCommand } from "../spawn.js";
9
11
  import { log, out } from "../log.js";
10
12
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
13
  export async function initCommand(args) {
@@ -21,7 +23,7 @@ export async function initCommand(args) {
21
23
  out("Usage: agent-rooms init --agent <BRNL-AGT-...> (--room <room_id> ... | --all-rooms)");
22
24
  out(" [--include-cross-owner] [--api-base <url>] [--host claude_code|codex|openclaw|hermes] [--workspace <path>] [--no-connector]");
23
25
  out("");
24
- out("--all-rooms binds every room this agent is already in (your own rooms; add");
26
+ out("--all-rooms follows this agent's current and future rooms (your own rooms; add");
25
27
  out("--include-cross-owner to also auto-wake in cross-owner rooms). Find ids in the app.");
26
28
  return 1;
27
29
  }
@@ -43,29 +45,47 @@ export async function initCommand(args) {
43
45
  log.warn(`Agent ${agent} isn't in any ${includeCross ? "" : "same-owner "}rooms yet — nothing to bind. Add it to a room in the app, then re-run.`);
44
46
  return 1;
45
47
  }
46
- log.info(`--all-rooms: binding ${rooms.length} room(s) this agent is already in.`);
48
+ log.info(`--all-rooms: following live membership (currently ${rooms.length} room(s)).`);
49
+ }
50
+ const explicitHost = args.host;
51
+ const host = resolveHost(explicitHost);
52
+ if (explicitHost && !host) {
53
+ log.error(`Unknown or non-wakeable host '${explicitHost}'. Expected claude_code, codex, openclaw, hermes, gemini, or cursor.`);
54
+ return 1;
47
55
  }
48
- const host = resolveHost(args.host);
49
56
  if (!host) {
50
57
  log.warn("No supported host CLI (claude / codex / openclaw / hermes) detected on PATH. Recording the binding anyway; install one before `watch`.");
51
58
  }
52
59
  const hostName = args.host || host?.name || "unknown";
53
60
  if (host && !args["no-connector"]) {
54
- registerConnector(host, apiBase);
61
+ if (!(await installHostIntegration(host, apiBase)))
62
+ return 1;
55
63
  }
56
64
  else if (!args["no-connector"]) {
57
65
  out("");
58
- out(`To connect manually, add this remote MCP server to your client: ${apiBase}/mcp`);
59
- out(` Claude Code: claude mcp add --transport http --scope user agent-rooms ${apiBase}/mcp`);
60
- out(` Codex: codex mcp add agent-rooms --url ${apiBase}/mcp --bearer-token-env-var AGENT_ROOMS_TOKEN`);
66
+ out("To connect manually, use the exact resource for your host:");
67
+ out(` Claude Code: claude mcp add --transport http --scope user agent-rooms ${apiBase}/mcp/claude`);
68
+ out(` Codex: codex mcp add agent-rooms --url ${apiBase}/mcp/codex --bearer-token-env-var AGENT_ROOMS_TOKEN`);
69
+ out(` OpenClaw: openclaw mcp add agent-rooms --transport streamable-http --url ${apiBase}/mcp/openclaw --auth oauth`);
70
+ out(` Hermes: hermes mcp add agent-rooms --url ${apiBase}/mcp/hermes --auth oauth`);
61
71
  }
62
72
  if (host && !args["no-skill"]) {
63
- installSkill(host);
73
+ if (!(await installSkill(host)))
74
+ return 1;
64
75
  }
65
- const binding = { agent, workspace, host: hostName, rooms };
76
+ const binding = {
77
+ agent,
78
+ workspace,
79
+ host: hostName,
80
+ rooms,
81
+ roomScope: allRooms ? "all" : "exact",
82
+ includeCrossOwner: allRooms ? includeCross : undefined
83
+ };
66
84
  const next = upsertBinding(config, binding);
67
85
  saveConfig(next);
68
- log.info(`Bound agent ${agent} in ${workspace} -> rooms [${rooms.join(", ")}] (host ${hostName}).`);
86
+ log.info(allRooms
87
+ ? `Bound agent ${agent} in ${workspace} -> all ${includeCross ? "member" : "same-owner"} rooms (currently [${rooms.join(", ")}]) (host ${hostName}).`
88
+ : `Bound agent ${agent} in ${workspace} -> rooms [${rooms.join(", ")}] (host ${hostName}).`);
69
89
  out("");
70
90
  out("Done. Two ways to receive mentions:");
71
91
  out(" - Tier 0 (already on): your agent pulls mentions via the MCP connector during its own runs.");
@@ -156,35 +176,87 @@ function resolveHost(explicit) {
156
176
  }
157
177
  return null;
158
178
  }
159
- export function registerConnector(host, apiBase) {
160
- const { command, args } = host.buildMcpAdd(apiBase);
161
- log.info(`Registering MCP connector with ${host.name}: ${command} ${args.join(" ")}`);
162
- const result = spawnSync(command, args, { encoding: "utf8", shell: process.platform === "win32" });
163
- const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
179
+ export function registerConnector(host, apiBase, runner) {
180
+ const run = runner ?? ((command, args) => {
181
+ const resolved = resolveSpawnCommand(command, args);
182
+ return spawnSync(resolved.command, resolved.args, {
183
+ encoding: "utf8",
184
+ shell: false,
185
+ windowsHide: true
186
+ });
187
+ });
188
+ const add = host.buildMcpAdd(apiBase);
189
+ log.info(`Registering MCP connector with ${host.name}: ${add.command} ${add.args.join(" ")}`);
190
+ let result = run(add.command, add.args);
191
+ let output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
164
192
  if (output)
165
193
  out(output);
166
194
  if (result.status === 0) {
167
195
  log.info(`Connector registered with ${host.name}.`);
196
+ return true;
168
197
  }
169
198
  else if (/already (exists|configured|registered)/i.test(output)) {
170
- log.info(`Connector already present in ${host.name} config - leaving it as-is.`);
199
+ // Never trust an existing connector by name: older releases used shared
200
+ // routes/auth modes. Replace the Agent Rooms-owned entry idempotently so an
201
+ // upgrade cannot preserve stale wiring.
202
+ const remove = host.buildMcpRemove();
203
+ log.info(`Reconciling existing ${host.name} connector to the exact host resource.`);
204
+ const removed = run(remove.command, remove.args);
205
+ if (removed.status !== 0) {
206
+ const removeOutput = `${removed.stdout ?? ""}${removed.stderr ?? ""}`.trim();
207
+ log.warn(`Could not replace stale connector${removeOutput ? `: ${removeOutput}` : "."}`);
208
+ return false;
209
+ }
210
+ result = run(add.command, add.args);
211
+ output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
212
+ if (output)
213
+ out(output);
214
+ if (result.status === 0) {
215
+ log.info(`Connector reconciled with ${host.name}.`);
216
+ return true;
217
+ }
218
+ log.warn(`Could not re-register the exact connector (exit ${result.status ?? "?"}).`);
219
+ return false;
171
220
  }
172
221
  else {
173
- log.warn(`Could not auto-register the connector (exit ${result.status ?? "?"}). Add it manually: ${apiBase}/mcp`);
222
+ const resourceHost = host.name === "claude_code" ? "claude" : host.name;
223
+ log.warn(`Could not auto-register the connector (exit ${result.status ?? "?"}). Re-run init after checking ${apiBase}/mcp/${resourceHost}.`);
224
+ return false;
225
+ }
226
+ }
227
+ /** Install the native wrapper before enabling its connector. Claude/Codex MCP
228
+ * wiring is loaded from the plugin itself; OpenClaw/Hermes also reconcile their
229
+ * native MCP registries because those plugin APIs do not ingest MCP manifests. */
230
+ export async function installHostIntegration(host, apiBase) {
231
+ if (isNativePluginHost(host.name)) {
232
+ const result = await installNativePlugin(host.name, apiBase);
233
+ if (!result.ok) {
234
+ log.error(`${host.name} integration disabled: ${result.message}`);
235
+ return false;
236
+ }
237
+ log.info(result.message);
238
+ if (host.name === "claude_code" || host.name === "codex")
239
+ return true;
174
240
  }
241
+ return registerConnector(host, apiBase);
175
242
  }
176
- export function installSkill(host) {
243
+ export async function installSkill(host) {
177
244
  try {
178
- const result = installBundledSkill(host);
245
+ // Spec 35 §13 (T13.3): the hosted URL is the only runtime source. Offline
246
+ // installation fails explicitly instead of reading a bundled fallback.
247
+ const result = await installHostedSkill(host);
179
248
  if (result.installed) {
180
249
  log.info(`${result.message} -> ${result.destination}`);
250
+ return true;
181
251
  }
182
252
  else {
183
- out(` (${result.message})`);
253
+ log.error(result.message);
254
+ return false;
184
255
  }
185
256
  }
186
257
  catch (err) {
187
- log.warn(`Could not install the skill for ${host.name}: ${err.message}`);
258
+ log.error(`Could not install the skill for ${host.name}: ${err.message}`);
259
+ return false;
188
260
  }
189
261
  }
190
262
  function asArray(value) {
@@ -1,12 +1,12 @@
1
1
  // `agent-rooms start` - the one-command path. Pairs the device once (if needed),
2
- // binds every agent you own to the rooms it's ALREADY in, and runs the shared
2
+ // binds every agent you own to its live room membership, and runs the shared
3
3
  // listener. Same-owner rooms by default; --include-cross-owner also auto-wakes in
4
4
  // cross-owner rooms (untrusted mentions, so it's opt-in). --agent <plate> limits
5
5
  // to one agent. Pull-based agents (web chat, etc.) are skipped — they don't wake.
6
6
  import { listenerInventory } from "../api.js";
7
7
  import { loadConfig, saveConfig, upsertBinding } from "../config.js";
8
8
  import { getAdapter } from "../hosts.js";
9
- import { installSkill, pairDevice, registerConnector } from "./init.js";
9
+ import { installHostIntegration, installSkill, pairDevice } from "./init.js";
10
10
  import { watchCommand } from "./watch.js";
11
11
  import { log, out } from "../log.js";
12
12
  export async function startCommand(args) {
@@ -52,15 +52,22 @@ export async function startCommand(args) {
52
52
  continue;
53
53
  }
54
54
  if (!hostsSetup.has(adapter.name)) {
55
- if (!noConnector)
56
- registerConnector(adapter, apiBase);
57
- if (!noSkill)
58
- installSkill(adapter);
55
+ if (!noConnector && !(await installHostIntegration(adapter, apiBase)))
56
+ return 1;
57
+ if (!noSkill && !(await installSkill(adapter)))
58
+ return 1;
59
59
  hostsSetup.add(adapter.name);
60
60
  }
61
- next = upsertBinding(next, { agent: a.plate, workspace, host: adapter.name, rooms: roomIds });
61
+ next = upsertBinding(next, {
62
+ agent: a.plate,
63
+ workspace,
64
+ host: adapter.name,
65
+ rooms: roomIds,
66
+ roomScope: "all",
67
+ includeCrossOwner: includeCross
68
+ });
62
69
  bound += 1;
63
- log.info(`Bound ${a.name} (${a.plate}) -> ${roomIds.length} room(s) [host ${adapter.name}].`);
70
+ log.info(`Bound ${a.name} (${a.plate}) -> all ${includeCross ? "member" : "same-owner"} rooms (currently ${roomIds.length}) [host ${adapter.name}].`);
64
71
  }
65
72
  saveConfig(next);
66
73
  if (bound === 0) {
@@ -3,6 +3,9 @@
3
3
  // the paired device, whether the singleton watcher is alive, and every bound agent
4
4
  // (host · rooms · workspace), so a missing or paused binding is obvious.
5
5
  import { loadConfig, runningWatch } from "../config.js";
6
+ import { listenerDiagnostics } from "../api.js";
7
+ import { getAdapter, isSkillInstalled } from "../hosts.js";
8
+ import { isNativePluginHost, pluginCompatibility, readNativePluginManifest } from "../plugins.js";
6
9
  import { out } from "../log.js";
7
10
  export async function statusCommand() {
8
11
  const config = loadConfig();
@@ -24,6 +27,32 @@ export async function statusCommand() {
24
27
  out(` - ${b.agent} host=${b.host} rooms=[${b.rooms.join(", ")}] cwd=${b.workspace}${tag}`);
25
28
  }
26
29
  }
30
+ out(` reports: ${(config.pendingReports ?? []).length} queued session report(s)`);
31
+ for (const host of [...new Set(config.bindings.map((binding) => binding.host))]) {
32
+ const adapter = getAdapter(host);
33
+ const skill = adapter ? (isSkillInstalled(adapter) ? "hosted skill present" : "hosted skill missing") : "no skill adapter";
34
+ if (isNativePluginHost(host)) {
35
+ const plugin = readNativePluginManifest(host);
36
+ const gate = pluginCompatibility(host);
37
+ out(` ${host}: plugin=${plugin ? `v${plugin.version}` : "missing"} compatibility=${gate.ok ? "OK" : "DISABLED"} skill=${skill}`);
38
+ if (!gate.ok)
39
+ out(` recovery: ${gate.message}`);
40
+ }
41
+ else {
42
+ out(` ${host}: direct integration; ${skill}`);
43
+ }
44
+ }
45
+ if (config.device) {
46
+ try {
47
+ const diagnostic = await listenerDiagnostics(config.apiBase, config.device.token);
48
+ out(` registry: reachable (${diagnostic.registry.active} active session(s))`);
49
+ out(` queue: reachable (${diagnostic.queue.pending} pending, ${diagnostic.queue.in_flight} in flight)`);
50
+ out(` protocol: v${diagnostic.protocol_version}; server requires listener >= ${diagnostic.min_listener_version}`);
51
+ }
52
+ catch (error) {
53
+ out(` registry: UNREACHABLE (${error.message})`);
54
+ }
55
+ }
27
56
  if (!watch) {
28
57
  out("");
29
58
  out("No watcher running — wakes won't fire until you run: agent-rooms watch");
@@ -7,10 +7,30 @@ import { stdin as input, stdout as output } from "node:process";
7
7
  import { createInterface } from "node:readline/promises";
8
8
  import { listenerPost } from "../api.js";
9
9
  import { configFilePath, loadConfig, removeConfig } from "../config.js";
10
- import { claudeAdapter, codexAdapter, getAdapter } from "../hosts.js";
10
+ import { claudeAdapter, codexAdapter, openclawAdapter, hermesAdapter, getAdapter } from "../hosts.js";
11
+ import { isNativePluginHost, removeOwnedNativePluginState } from "../plugins.js";
12
+ import { removeHostedSkill } from "../skill.js";
13
+ import { resolveSpawnCommand } from "../spawn.js";
11
14
  import { log, out } from "../log.js";
15
+ function defaultRunCommand(command, args) {
16
+ try {
17
+ const resolved = resolveSpawnCommand(command, args);
18
+ return spawnSync(resolved.command, resolved.args, {
19
+ cwd: process.cwd(), encoding: "utf8", stdio: "pipe", shell: false, windowsHide: true
20
+ });
21
+ }
22
+ catch (error) {
23
+ return { status: null, error: error instanceof Error ? error : new Error(String(error)) };
24
+ }
25
+ }
26
+ const DEFAULT_DEPS = {
27
+ runCommand: defaultRunCommand,
28
+ removeOwnedNativePluginState,
29
+ removeHostedSkill,
30
+ removeConfig
31
+ };
12
32
  const HELP = `Usage: agent-rooms uninstall [--yes] [--dry-run]
13
- [--host all|claude_code|codex|none]
33
+ [--host all|claude_code|codex|openclaw|hermes|none]
14
34
  [--keep-config] [--local-only] [--no-connector]
15
35
 
16
36
  Removes the Agent Rooms listener setup from this machine.
@@ -23,7 +43,8 @@ Options:
23
43
  --local-only skip remote device revocation
24
44
  --no-connector skip host MCP connector removal
25
45
  `;
26
- export async function uninstallCommand(args) {
46
+ export async function uninstallCommand(args, overrides = {}) {
47
+ const deps = { ...DEFAULT_DEPS, ...overrides };
27
48
  if (args.help || args.h || args["--help"]) {
28
49
  out(HELP);
29
50
  return 0;
@@ -74,11 +95,22 @@ export async function uninstallCommand(args) {
74
95
  if (config.device && !localOnly) {
75
96
  await revokeRemoteDevice(config.apiBase, config.device.token);
76
97
  }
98
+ const failedHosts = [];
77
99
  for (const host of hosts) {
78
- unregisterConnector(host);
100
+ const pluginRemoved = unregisterNativePlugin(host, deps.runCommand);
101
+ const connectorRemoved = unregisterConnector(host, deps.runCommand);
102
+ if (!pluginRemoved || !connectorRemoved) {
103
+ failedHosts.push(host.name);
104
+ log.warn(`Preserving ${host.name} owned state so uninstall can be retried safely.`);
105
+ continue;
106
+ }
107
+ if (isNativePluginHost(host.name))
108
+ deps.removeOwnedNativePluginState(host.name);
109
+ if (deps.removeHostedSkill(host))
110
+ log.info(`Removed the Agent Rooms hosted skill from ${host.name}.`);
79
111
  }
80
- if (!keepConfig) {
81
- const removed = removeConfig();
112
+ if (!keepConfig && failedHosts.length === 0) {
113
+ const removed = deps.removeConfig();
82
114
  if (removed) {
83
115
  log.info("Deleted local Agent Rooms config.");
84
116
  }
@@ -86,6 +118,11 @@ export async function uninstallCommand(args) {
86
118
  log.warn("No local Agent Rooms config file was found.");
87
119
  }
88
120
  }
121
+ if (failedHosts.length > 0) {
122
+ out("");
123
+ log.error(`Uninstall incomplete for: ${failedHosts.join(", ")}. Config and failed host state were kept for retry.`);
124
+ return 1;
125
+ }
89
126
  out("");
90
127
  out("Uninstall complete.");
91
128
  out("If you installed the package globally, also run: npm uninstall -g agent-rooms");
@@ -101,28 +138,68 @@ async function revokeRemoteDevice(apiBase, token) {
101
138
  log.warn(`Could not revoke the paired device remotely (${message}). Revoke it from the Agent Rooms app if it still appears there.`);
102
139
  }
103
140
  }
104
- function unregisterConnector(host) {
141
+ function unregisterConnector(host, runner) {
105
142
  const { command, args } = host.buildMcpRemove();
106
143
  log.info(`Removing MCP connector from ${host.name}: ${command} ${args.join(" ")}`);
107
- const result = spawnSync(command, args, {
108
- cwd: process.cwd(),
109
- encoding: "utf8",
110
- stdio: "pipe",
111
- shell: process.platform === "win32"
112
- });
144
+ let result;
145
+ try {
146
+ result = runner(command, args);
147
+ }
148
+ catch (error) {
149
+ result = { status: null, error: error instanceof Error ? error : new Error(String(error)) };
150
+ }
113
151
  if (result.status === 0) {
114
152
  log.info(`Connector removed from ${host.name}.`);
115
- return;
153
+ return true;
116
154
  }
117
155
  const stderr = String(result.stderr || result.error?.message || "").trim();
156
+ if (/not (?:installed|found|configured)|does not exist|unknown server/i.test(stderr))
157
+ return true;
118
158
  log.warn(`Could not remove connector from ${host.name}${stderr ? `: ${stderr}` : "."}`);
159
+ return false;
160
+ }
161
+ export function nativePluginRemovalCommands(host) {
162
+ if (!isNativePluginHost(host.name))
163
+ return [];
164
+ return host.name === "claude_code"
165
+ ? [
166
+ { command: "claude", args: ["plugin", "uninstall", "agent-rooms@agent-rooms", "--scope", "user", "--yes"] },
167
+ { command: "claude", args: ["plugin", "marketplace", "remove", "agent-rooms", "--scope", "user"] }
168
+ ]
169
+ : host.name === "codex"
170
+ ? [
171
+ { command: "codex", args: ["plugin", "remove", "agent-rooms@agent-rooms"] },
172
+ { command: "codex", args: ["plugin", "marketplace", "remove", "agent-rooms"] }
173
+ ]
174
+ : host.name === "openclaw"
175
+ ? [{ command: "openclaw", args: ["plugins", "uninstall", "agent-rooms", "--force"] }]
176
+ : [{ command: "hermes", args: ["plugins", "remove", "agent-rooms"] }];
177
+ }
178
+ function unregisterNativePlugin(host, runner) {
179
+ const commands = nativePluginRemovalCommands(host) ?? [];
180
+ let ok = true;
181
+ for (const command of commands) {
182
+ let result;
183
+ try {
184
+ result = runner(command.command, command.args);
185
+ }
186
+ catch (error) {
187
+ result = { status: null, error: error instanceof Error ? error : new Error(String(error)) };
188
+ }
189
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}${result.error?.message ?? ""}`.trim();
190
+ if (result.status === 0 || /not (?:installed|found|configured)|unknown marketplace/i.test(output))
191
+ continue;
192
+ ok = false;
193
+ log.warn(`Could not remove ${host.name} native plugin${output ? `: ${output}` : "."}`);
194
+ }
195
+ return ok;
119
196
  }
120
197
  function resolveHosts(value) {
121
198
  const raw = normalizeHost(String(value || "all"));
122
199
  if (raw === "none")
123
200
  return [];
124
201
  if (raw === "all")
125
- return [claudeAdapter, codexAdapter];
202
+ return [claudeAdapter, codexAdapter, openclawAdapter, hermesAdapter];
126
203
  const adapter = getAdapter(raw);
127
204
  return adapter ? [adapter] : null;
128
205
  }
@@ -1,7 +1,8 @@
1
1
  // `agent-rooms watch` - run the listener so idle local agents wake on mentions.
2
- import { clearWatchPid, loadConfig, runningWatch, writeWatchPid } from "../config.js";
2
+ import { acquireWatchPid, clearWatchPid, loadConfig } from "../config.js";
3
3
  import { dryRunLines, ListenerRuntime } from "../listener.js";
4
4
  import { log, out } from "../log.js";
5
+ import { isNativePluginHost, nativePluginRuntimeGate } from "../plugins.js";
5
6
  export async function watchCommand(args) {
6
7
  const config = loadConfig();
7
8
  const apiBase = args["api-base"] || config.apiBase;
@@ -12,11 +13,35 @@ export async function watchCommand(args) {
12
13
  out(line);
13
14
  return 0;
14
15
  }
16
+ // §14: each native integration is gated independently. A host that is not
17
+ // usable on this machine (e.g. a WSL-only host bound from Windows) is disabled
18
+ // on its own — it must not ground the hosts that ARE usable. Only bindings the
19
+ // listener would actually serve are gated, so a paused agent cannot block start.
20
+ const gatedHosts = [
21
+ ...new Set(config.bindings
22
+ .filter((binding) => !config.pausedAgents.includes(binding.agent))
23
+ .map((binding) => binding.host))
24
+ ];
25
+ const disabledHosts = [];
26
+ for (const host of gatedHosts) {
27
+ if (!isNativePluginHost(host))
28
+ continue;
29
+ const gate = nativePluginRuntimeGate(host, apiBase);
30
+ if (!gate.ok) {
31
+ log.warn(`${gate.message ?? `${host} integration is disabled.`} Disabling ${host}; other hosts keep running.`);
32
+ disabledHosts.push(host);
33
+ }
34
+ }
35
+ if (gatedHosts.length > 0 && disabledHosts.length === gatedHosts.length) {
36
+ log.error("Every bound host's native integration is unavailable on this machine. Fix at least one, then re-run.");
37
+ return 1;
38
+ }
15
39
  // Singleton: one shared watcher per machine serves EVERY tool (all bindings) over
16
40
  // one device + one connection. If one is already running, don't start a second —
17
41
  // it hot-reloads any newly-added agent on its own.
18
- const existing = runningWatch();
19
- if (existing) {
42
+ const lock = acquireWatchPid();
43
+ if (!lock.acquired) {
44
+ const existing = lock.watch;
20
45
  out(`A listener is already running on this machine (pid ${existing.pid}).`);
21
46
  out("It's the single shared watcher for all your tools and will pick up this");
22
47
  out("agent automatically (hot reload). Check it with: agent-rooms status");
@@ -26,6 +51,7 @@ export async function watchCommand(args) {
26
51
  const runtime = new ListenerRuntime({
27
52
  apiBase,
28
53
  maxTurns,
54
+ disabledHosts,
29
55
  onEvent(event) {
30
56
  if (event.level === "error")
31
57
  log.error(event.message);
@@ -39,13 +65,13 @@ export async function watchCommand(args) {
39
65
  await runtime.start();
40
66
  }
41
67
  catch (error) {
68
+ clearWatchPid(process.pid);
42
69
  log.error(error.message);
43
70
  return 1;
44
71
  }
45
72
  // Claim the singleton lock now that we're up; clear it however we exit so a crash
46
73
  // never leaves a stale lock that blocks the next watch.
47
- writeWatchPid();
48
- process.once("exit", clearWatchPid);
74
+ process.once("exit", () => clearWatchPid(process.pid));
49
75
  log.info("Listener running. Press Ctrl+C to stop.");
50
76
  await new Promise((resolve) => {
51
77
  let shuttingDown = false;
@@ -55,7 +81,7 @@ export async function watchCommand(args) {
55
81
  shuttingDown = true;
56
82
  log.info("Shutting down...");
57
83
  void runtime.stop().finally(() => {
58
- clearWatchPid();
84
+ clearWatchPid(process.pid);
59
85
  resolve();
60
86
  });
61
87
  };
package/dist/config.js CHANGED
@@ -42,9 +42,29 @@ export function loadConfig() {
42
42
  hostTokens: raw.hostTokens ?? {},
43
43
  sessions: contaminated ? {} : (raw.sessions ?? {}),
44
44
  sessionsMeta: contaminated ? {} : (raw.sessionsMeta ?? {}),
45
- sessionSchema: SESSION_SCHEMA
45
+ sessionSchema: SESSION_SCHEMA,
46
+ serverKey: raw.serverKey,
47
+ serverKeys: raw.serverKeys ?? (raw.serverKey ? [raw.serverKey] : []),
48
+ pendingReports: raw.pendingReports ?? [],
49
+ handledWakes: raw.handledWakes ?? {}
46
50
  };
47
51
  }
52
+ const HANDLED_WAKE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
53
+ const PENDING_REPORT_CAP = 200;
54
+ /** Spec 35 §4: remember a handled wake id (dedup for re-deliveries). */
55
+ export function recordHandledWake(config, wakeId, now = Date.now()) {
56
+ const handledWakes = { ...(config.handledWakes ?? {}), [wakeId]: now };
57
+ for (const [id, at] of Object.entries(handledWakes)) {
58
+ if (now - at > HANDLED_WAKE_MAX_AGE_MS)
59
+ delete handledWakes[id];
60
+ }
61
+ return { ...config, handledWakes };
62
+ }
63
+ /** Spec 35 §2: queue a session report that could not reach the server. */
64
+ export function queuePendingReport(config, report) {
65
+ const pendingReports = [...(config.pendingReports ?? []), report].slice(-PENDING_REPORT_CAP);
66
+ return { ...config, pendingReports };
67
+ }
48
68
  /** Spec 19: record a task session id + its timestamp, then evict stale/excess
49
69
  * sessions. Returns a new Config (does not write to disk). */
50
70
  export function recordSession(config, key, sessionId, now = Date.now()) {
@@ -163,8 +183,41 @@ export function writeWatchPid(pid = process.pid) {
163
183
  mkdirSync(dirname(path), { recursive: true });
164
184
  writeFileSync(path, `${JSON.stringify({ pid, startedAt: Date.now() })}\n`);
165
185
  }
166
- export function clearWatchPid() {
186
+ /** Atomically acquire the per-home singleton before any network connection or
187
+ * runtime setup. `wx` is the cross-platform O_EXCL primitive: concurrent watch
188
+ * processes cannot both pass it. A stale owner is removed and acquisition is
189
+ * retried once. */
190
+ export function acquireWatchPid(pid = process.pid) {
191
+ const path = watchPidPath();
192
+ mkdirSync(dirname(path), { recursive: true });
193
+ for (let attempt = 0; attempt < 2; attempt += 1) {
194
+ const watch = { pid, startedAt: Date.now() };
195
+ try {
196
+ writeFileSync(path, `${JSON.stringify(watch)}\n`, { flag: "wx", mode: 0o600 });
197
+ return { acquired: true, watch };
198
+ }
199
+ catch (error) {
200
+ if (error.code !== "EEXIST")
201
+ throw error;
202
+ const existing = readWatchPid();
203
+ if (existing && isProcessAlive(existing.pid))
204
+ return { acquired: false, watch: existing };
205
+ try {
206
+ rmSync(path, { force: true });
207
+ }
208
+ catch { /* another contender may own cleanup */ }
209
+ }
210
+ }
211
+ const existing = readWatchPid();
212
+ return { acquired: false, watch: existing ?? { pid: 0, startedAt: 0 } };
213
+ }
214
+ export function clearWatchPid(expectedPid) {
167
215
  try {
216
+ if (expectedPid !== undefined) {
217
+ const current = readWatchPid();
218
+ if (!current || current.pid !== expectedPid)
219
+ return;
220
+ }
168
221
  rmSync(watchPidPath(), { force: true });
169
222
  }
170
223
  catch {
@@ -0,0 +1,83 @@
1
+ // Spec 35 §3/§10 — the signed wake envelope, listener side. Mirrors
2
+ // backend/contracts/envelope.ts (this package is standalone; shapes are the
3
+ // wire contract). The listener NEVER trusts its own input: only server-signed
4
+ // envelopes are acted on. Verification uses the server's Ed25519 public key,
5
+ // pinned from /.well-known/jwks.json at pairing and refreshed on demand.
6
+ import { createPublicKey, verify as nodeVerify } from "node:crypto";
7
+ import { Buffer } from "node:buffer";
8
+ const WAKE_SIGNATURE_DOMAIN = Buffer.from("agent-rooms:wake-envelope:v2\0", "utf8");
9
+ const wakeSigningBytes = (serializedEnvelope) => Buffer.concat([WAKE_SIGNATURE_DOMAIN, Buffer.from(serializedEnvelope, "utf8")]);
10
+ export const WAKE_PROTOCOL_VERSION = 2;
11
+ export function publicKeyFromJwk(jwk) {
12
+ return createPublicKey({ key: jwk, format: "jwk" });
13
+ }
14
+ function base64UrlToBytes(value) {
15
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
16
+ return Buffer.from(padded, "base64");
17
+ }
18
+ /** How stale a signed envelope may be before it's rejected as a replay (§16 C7).
19
+ * Generous vs. normal dispatch latency; a re-dispatched wake carries a FRESH
20
+ * issued_at per attempt, so legitimate re-dispatches always pass. */
21
+ export const ENVELOPE_MAX_AGE_MS = 10 * 60 * 1000;
22
+ /** Clock-skew tolerance for a future-dated issued_at. */
23
+ export const ENVELOPE_MAX_FUTURE_MS = 2 * 60 * 1000;
24
+ /** Verify a wake2 frame and parse its envelope. Returns null on ANY failure
25
+ * (bad signature, tampered bytes, malformed JSON, wrong version, or a stale/
26
+ * future-dated envelope) — the caller drops and reports, never trusts. The
27
+ * signature covers the exact transmitted bytes of `e`.
28
+ *
29
+ * Freshness (§16 C7 red-team): signature verification blocks FORGERY, not
30
+ * REPLAY of a genuine envelope. issued_at is inside the signed bytes, so it
31
+ * can't be altered; rejecting a stale issued_at closes the replay window that
32
+ * the wake_id dedup alone (only handled ids) doesn't cover. `now` is injectable
33
+ * for tests. */
34
+ export function verifyWakeFrame(publicKey, frame, opts = {}) {
35
+ try {
36
+ const valid = nodeVerify(null, wakeSigningBytes(frame.e), publicKey, base64UrlToBytes(frame.sig));
37
+ if (!valid)
38
+ return null;
39
+ const envelope = JSON.parse(frame.e);
40
+ if (envelope.protocol_version !== WAKE_PROTOCOL_VERSION)
41
+ return null;
42
+ if (!envelope.wake_id || !envelope.room_id || !envelope.target)
43
+ return null;
44
+ const now = opts.now ?? Date.now();
45
+ const issued = typeof envelope.issued_at === "number" ? envelope.issued_at : 0;
46
+ if (!issued)
47
+ return null; // v2 always stamps issued_at; a missing one is suspect
48
+ if (issued > now + ENVELOPE_MAX_FUTURE_MS)
49
+ return null; // future-dated
50
+ if (now - issued > ENVELOPE_MAX_AGE_MS)
51
+ return null; // stale / replayed
52
+ return envelope;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * The model input for a wake (Spec 35 §3): the FULL raw message body for
60
+ * mention wakes; the task payload for task wakes. NOTHING ELSE — no generated
61
+ * instructions, no room summary, no excerpt. Task rendering is mechanical
62
+ * labeling of server fields; the handoff note rides VERBATIM.
63
+ */
64
+ export function wakePromptFor(envelope) {
65
+ if (envelope.event_kind === "mention" || envelope.body !== undefined) {
66
+ return envelope.body ?? "";
67
+ }
68
+ const task = envelope.task;
69
+ if (!task)
70
+ return "";
71
+ const lines = [
72
+ `Task: ${task.title}`,
73
+ ...(task.detail ? [`Detail: ${task.detail}`] : []),
74
+ ...(task.definition_of_done ? [`Definition of done: ${task.definition_of_done}`] : []),
75
+ `Task id: ${task.task_id}`,
76
+ `Assigned by: ${task.assigner_plate}`,
77
+ `Room: ${envelope.room_id}`
78
+ ];
79
+ if (task.handoff) {
80
+ lines.push("", `Handoff from ${task.handoff.author_plate}${task.handoff.summary ? ` — ${task.handoff.summary}` : ""}:`, task.handoff.notes, ...(task.handoff.artifacts.length ? [`Artifacts: ${task.handoff.artifacts.join(", ")}`] : []));
81
+ }
82
+ return lines.join("\n");
83
+ }