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/CHANGELOG.md +33 -3
- package/README.md +119 -140
- package/dist/api.js +21 -0
- package/dist/cli.js +2 -2
- package/dist/commands/doctor.js +68 -16
- package/dist/commands/init.js +94 -22
- package/dist/commands/start.js +15 -8
- package/dist/commands/status.js +29 -0
- package/dist/commands/uninstall.js +92 -15
- package/dist/commands/watch.js +32 -6
- package/dist/config.js +55 -2
- package/dist/envelope.js +83 -0
- package/dist/hosts.js +266 -93
- package/dist/listener.js +355 -194
- package/dist/normalize.js +31 -16
- package/dist/plugins.js +266 -0
- package/dist/probes.js +220 -0
- package/dist/skill.js +61 -23
- package/dist/socket.js +110 -18
- package/dist/spawn.js +185 -38
- package/dist/version.js +4 -0
- package/package.json +5 -4
- package/skill/agent-rooms/AGENTS.md +62 -43
- package/skill/agent-rooms/SKILL.md +201 -115
- package/skill/agent-rooms/references/etiquette.md +31 -20
- package/skill/agent-rooms/references/tools.md +780 -745
- package/skill/agent-rooms/references/troubleshooting.md +35 -25
- package/dist/bridge.js +0 -75
package/dist/commands/init.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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:
|
|
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
|
-
|
|
61
|
+
if (!(await installHostIntegration(host, apiBase)))
|
|
62
|
+
return 1;
|
|
55
63
|
}
|
|
56
64
|
else if (!args["no-connector"]) {
|
|
57
65
|
out("");
|
|
58
|
-
out(
|
|
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 = {
|
|
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(
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
+
log.error(result.message);
|
|
254
|
+
return false;
|
|
184
255
|
}
|
|
185
256
|
}
|
|
186
257
|
catch (err) {
|
|
187
|
-
log.
|
|
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) {
|
package/dist/commands/start.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
57
|
-
if (!noSkill)
|
|
58
|
-
|
|
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, {
|
|
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}
|
|
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) {
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
}
|
package/dist/commands/watch.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// `agent-rooms watch` - run the listener so idle local agents wake on mentions.
|
|
2
|
-
import { clearWatchPid, loadConfig
|
|
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
|
|
19
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/envelope.js
ADDED
|
@@ -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
|
+
}
|