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/hosts.js
CHANGED
|
@@ -3,21 +3,20 @@
|
|
|
3
3
|
// - build a NON-INTERACTIVE headless wake command (`watch`)
|
|
4
4
|
// - extract the host session id from the run's stream output (for resume)
|
|
5
5
|
//
|
|
6
|
-
// Safety (
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// active grant (only approved collaborators can wake you), (3) the connect docs warn
|
|
13
|
-
// loudly, and (4) the broker scrubs the spawn env (§3.2) + owner-stop. For the
|
|
14
|
-
// cautious: AGENT_ROOMS_WAKE_CONTAIN=1 forces Claude back to a read-only allowlist
|
|
15
|
-
// (--permission-mode dontAsk + room MCP tools + Read/Grep/Glob, no Write/Edit/Bash);
|
|
16
|
-
// Codex/OpenClaw have no per-tool gate so containment doesn't apply to them.
|
|
6
|
+
// Safety (Spec 35 §3/§10): the signed envelope carries the server decision.
|
|
7
|
+
// Same-owner wakes may use the host's full native capability. Cross-owner wakes
|
|
8
|
+
// fail closed unless the host has a tested non-interactive constrained mode;
|
|
9
|
+
// Claude and Codex use their native read-only controls. OpenClaw and Hermes are
|
|
10
|
+
// not admitted for cross-owner wakes until such a mode exists. Every spawn also
|
|
11
|
+
// receives a scrubbed environment and remains owner-stoppable.
|
|
17
12
|
import { spawnSync } from "node:child_process";
|
|
18
13
|
import { existsSync } from "node:fs";
|
|
19
14
|
import { homedir } from "node:os";
|
|
20
15
|
import { join } from "node:path";
|
|
16
|
+
export const emptyUsage = () => ({
|
|
17
|
+
uncached_input: 0, cache_read: 0, cache_write: 0, cache_write_5m: 0, cache_write_1h: 0, reasoning: 0, output: 0, attempts: 0
|
|
18
|
+
});
|
|
19
|
+
const num = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
21
20
|
/** The last value of `field` across all JSON lines in a stream buffer (vs
|
|
22
21
|
* firstJsonField, which takes the first). Used to grab the FINAL output event. */
|
|
23
22
|
function lastJsonField(stdout, predicate) {
|
|
@@ -65,19 +64,18 @@ const ROOM_TOOLS = [
|
|
|
65
64
|
"mcp__agent-rooms__ack_mentions",
|
|
66
65
|
// messaging
|
|
67
66
|
"mcp__agent-rooms__send_message",
|
|
68
|
-
// task board —
|
|
67
|
+
// task board — assigned pushes are locked by the running receipt; pull/manual
|
|
68
|
+
// clients may claim unassigned work. Completion + handoff is one atomic call.
|
|
69
69
|
"mcp__agent-rooms__read_board",
|
|
70
|
+
"mcp__agent-rooms__get_room_context",
|
|
70
71
|
"mcp__agent-rooms__create_task",
|
|
71
|
-
"mcp__agent-rooms__assign_task",
|
|
72
72
|
"mcp__agent-rooms__update_task",
|
|
73
73
|
"mcp__agent-rooms__claim_task",
|
|
74
74
|
"mcp__agent-rooms__renew_lease",
|
|
75
75
|
"mcp__agent-rooms__release_task",
|
|
76
76
|
"mcp__agent-rooms__set_status",
|
|
77
|
+
"mcp__agent-rooms__complete_task",
|
|
77
78
|
"mcp__agent-rooms__archive_task",
|
|
78
|
-
"mcp__agent-rooms__accept_task",
|
|
79
|
-
"mcp__agent-rooms__reject_task",
|
|
80
|
-
"mcp__agent-rooms__list_pending_consents",
|
|
81
79
|
// files
|
|
82
80
|
"mcp__agent-rooms__share_file",
|
|
83
81
|
"mcp__agent-rooms__complete_file_upload",
|
|
@@ -86,6 +84,12 @@ const ROOM_TOOLS = [
|
|
|
86
84
|
"mcp__agent-rooms__write_file",
|
|
87
85
|
"mcp__agent-rooms__list_files",
|
|
88
86
|
"mcp__agent-rooms__delete_file",
|
|
87
|
+
// diagnostics and already-consented feedback channels
|
|
88
|
+
"mcp__agent-rooms__get_feedback_consent",
|
|
89
|
+
"mcp__agent-rooms__submit_task_feedback",
|
|
90
|
+
"mcp__agent-rooms__submit_product_feedback",
|
|
91
|
+
"mcp__agent-rooms__diagnose_error",
|
|
92
|
+
"mcp__agent-rooms__report_error",
|
|
89
93
|
// read-only workspace tools (the actual containment boundary — no Write/Bash)
|
|
90
94
|
"Read",
|
|
91
95
|
"Grep",
|
|
@@ -129,6 +133,8 @@ export const claudeAdapter = {
|
|
|
129
133
|
bin: "claude",
|
|
130
134
|
allowedTools: ROOM_TOOLS,
|
|
131
135
|
skillDir: join(homedir(), ".claude", "skills"),
|
|
136
|
+
supportsConstrainedWake: true,
|
|
137
|
+
requiresSessionId: true,
|
|
132
138
|
buildWake(spec) {
|
|
133
139
|
// The prompt is passed via STDIN (see SpawnCommand.stdin), never argv — a
|
|
134
140
|
// multi-line argv is destroyed by the Windows shell. argv carries only
|
|
@@ -164,7 +170,7 @@ export const claudeAdapter = {
|
|
|
164
170
|
return { command: this.bin, args, cwd: spec.workspace, stdin: spec.prompt, env: spec.env };
|
|
165
171
|
},
|
|
166
172
|
buildMcpAdd(apiBase) {
|
|
167
|
-
return { command: this.bin, args: ["mcp", "add", "--transport", "http", "--scope", "user", "agent-rooms", `${apiBase}/mcp`], cwd: process.cwd() };
|
|
173
|
+
return { command: this.bin, args: ["mcp", "add", "--transport", "http", "--scope", "user", "agent-rooms", `${apiBase}/mcp/claude`], cwd: process.cwd() };
|
|
168
174
|
},
|
|
169
175
|
buildMcpRemove() {
|
|
170
176
|
return { command: this.bin, args: ["mcp", "remove", "agent-rooms"], cwd: process.cwd() };
|
|
@@ -176,35 +182,48 @@ export const claudeAdapter = {
|
|
|
176
182
|
// Claude stream-json ends with a `{"type":"result","subtype":"success",
|
|
177
183
|
// "result":"<final text>"}` event. Take the last one's `result`.
|
|
178
184
|
return lastJsonField(stdout, (obj) => obj.type === "result" && typeof obj.result === "string" ? obj.result : null);
|
|
179
|
-
}
|
|
185
|
+
},
|
|
186
|
+
// Spec 35 §12 — count each provider attempt. Claude can retry a model call
|
|
187
|
+
// inside one turn and surfaces every attempt under `usage.iterations[]`; the
|
|
188
|
+
// final `usage` object alone undercounts. Sum across iterations when present,
|
|
189
|
+
// else read the single object. The terminal `{type:"result", usage}` carries
|
|
190
|
+
// the same numbers, so guard against double-counting by preferring iterations.
|
|
191
|
+
parseUsageLine(obj, acc) {
|
|
192
|
+
const usage = (obj.usage ?? obj.message?.usage);
|
|
193
|
+
if (!usage)
|
|
194
|
+
return;
|
|
195
|
+
const iterations = usage.iterations;
|
|
196
|
+
if (Array.isArray(iterations) && iterations.length > 0) {
|
|
197
|
+
for (const it of iterations)
|
|
198
|
+
foldClaudeUsageObject(it, acc);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Only the terminal result object carries the whole-turn totals; message
|
|
202
|
+
// deltas repeat, so accept usage totals solely from the result event.
|
|
203
|
+
if (obj.type === "result")
|
|
204
|
+
foldClaudeUsageObject(usage, acc);
|
|
205
|
+
},
|
|
206
|
+
// A --resume against a session id the local store no longer has.
|
|
207
|
+
resumeFailurePattern: /no conversation found|session .*not found|could not (?:find|resume)/i
|
|
180
208
|
};
|
|
181
209
|
export const codexAdapter = {
|
|
182
210
|
name: "codex",
|
|
183
211
|
bin: "codex",
|
|
184
212
|
allowedTools: ROOM_TOOLS,
|
|
185
|
-
// Codex
|
|
186
|
-
//
|
|
187
|
-
// woken Codex agent room-fluent and shortens its wake prompt.
|
|
213
|
+
// Codex loads Agent Skills from ~/.codex/skills. `init` fetches the canonical
|
|
214
|
+
// hosted Agent Rooms skill into that owned location.
|
|
188
215
|
skillDir: join(homedir(), ".codex", "skills"),
|
|
216
|
+
supportsConstrainedWake: true,
|
|
217
|
+
requiresSessionId: true,
|
|
189
218
|
buildWake(spec) {
|
|
190
219
|
// `codex exec -` reads the prompt from stdin. Keep the multi-line wake
|
|
191
220
|
// prompt out of argv so Windows shell resolution cannot split it.
|
|
192
221
|
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// or replied (a silent no-op that looks like "listening but mute"). Bypassing
|
|
199
|
-
// approvals+sandbox is the only mode where headless MCP calls actually run
|
|
200
|
-
// (verified: whoami + check_mentions succeed). The broker still constrains the
|
|
201
|
-
// run elsewhere — the spawn env is scrubbed to an allowlist (§3.2) and the run
|
|
202
|
-
// is bounded by --max-turns and the owner-stop kill path. Set
|
|
203
|
-
// AGENT_ROOMS_CODEX_SANDBOX (e.g. "workspace-write") to opt back into a codex
|
|
204
|
-
// sandbox if your codex build approves MCP calls non-interactively.
|
|
205
|
-
const strict = process.env.AGENT_ROOMS_CODEX_SANDBOX?.trim();
|
|
206
|
-
const sandboxArgs = strict ? ["--sandbox", strict] : ["--dangerously-bypass-approvals-and-sandbox"];
|
|
207
|
-
const base = ["exec", "--json", "--skip-git-repo-check", ...sandboxArgs];
|
|
222
|
+
// The signed server policy is authoritative: same-owner work may use full
|
|
223
|
+
// native capability, while cross-owner work is non-interactive read-only.
|
|
224
|
+
const base = spec.fullAccess
|
|
225
|
+
? ["exec", "--json", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox"]
|
|
226
|
+
: ["-a", "never", "exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only"];
|
|
208
227
|
if (spec.sessionId) {
|
|
209
228
|
base.push("resume", spec.sessionId);
|
|
210
229
|
}
|
|
@@ -214,7 +233,7 @@ export const codexAdapter = {
|
|
|
214
233
|
buildMcpAdd(apiBase) {
|
|
215
234
|
return {
|
|
216
235
|
command: this.bin,
|
|
217
|
-
args: ["mcp", "add", "agent-rooms", "--url", `${apiBase}/mcp`, "--bearer-token-env-var", "AGENT_ROOMS_TOKEN"],
|
|
236
|
+
args: ["mcp", "add", "agent-rooms", "--url", `${apiBase}/mcp/codex`, "--bearer-token-env-var", "AGENT_ROOMS_TOKEN"],
|
|
218
237
|
cwd: process.cwd()
|
|
219
238
|
};
|
|
220
239
|
},
|
|
@@ -245,8 +264,54 @@ export const codexAdapter = {
|
|
|
245
264
|
return obj.text;
|
|
246
265
|
return null;
|
|
247
266
|
});
|
|
248
|
-
}
|
|
267
|
+
},
|
|
268
|
+
// Spec 35 §12 — Codex reports BOTH a running session total (turn.completed's
|
|
269
|
+
// cumulative `usage`) and the per-turn delta (`last_token_usage`). On a
|
|
270
|
+
// resumed thread the cumulative total includes every prior turn, so reading it
|
|
271
|
+
// over-reports badly. Take last_token_usage only; it is the honest per-turn
|
|
272
|
+
// number. Each such event is one attempt.
|
|
273
|
+
parseUsageLine: foldCodexUsage,
|
|
274
|
+
// A `codex exec resume <id>` against a rollout that no longer exists.
|
|
275
|
+
resumeFailurePattern: /no (?:such )?(?:session|rollout|thread)|conversation .*not found|failed to (?:load|resume)/i
|
|
249
276
|
};
|
|
277
|
+
/** Spec 35 §12 — fold one Claude usage object (a `usage` total or one
|
|
278
|
+
* `iterations[]` attempt) into the accumulator. Claude's `input_tokens` is
|
|
279
|
+
* already cache-exclusive; `cache_creation_input_tokens` is the flat write
|
|
280
|
+
* total, and `cache_creation.{ephemeral_5m,ephemeral_1h}_input_tokens` split it
|
|
281
|
+
* by TTL (5m bills 1.25×, 1h 2×) where exposed — captured so price-weighting
|
|
282
|
+
* has the raw numbers. */
|
|
283
|
+
function foldClaudeUsageObject(u, acc) {
|
|
284
|
+
acc.uncached_input += num(u.input_tokens);
|
|
285
|
+
acc.cache_read += num(u.cache_read_input_tokens);
|
|
286
|
+
const write = num(u.cache_creation_input_tokens);
|
|
287
|
+
acc.cache_write += write;
|
|
288
|
+
const creation = u.cache_creation;
|
|
289
|
+
const w5 = num(creation?.ephemeral_5m_input_tokens);
|
|
290
|
+
const w1 = num(creation?.ephemeral_1h_input_tokens);
|
|
291
|
+
acc.cache_write_5m += w5;
|
|
292
|
+
acc.cache_write_1h += w1;
|
|
293
|
+
acc.output += num(u.output_tokens);
|
|
294
|
+
acc.attempts += 1;
|
|
295
|
+
}
|
|
296
|
+
/** Spec 35 §12 — per-turn usage from a Codex-style stream (used by codex AND by
|
|
297
|
+
* OpenClaw, which runs an embedded Codex turn). Reads only `last_token_usage`
|
|
298
|
+
* (the honest per-turn delta), NEVER the cumulative `turn.completed.usage`
|
|
299
|
+
* total. Codex `input_tokens` is the TOTAL prompt and `cached_input_tokens` is
|
|
300
|
+
* a SUBSET, so uncached is the difference — matching Claude's cache-exclusive
|
|
301
|
+
* `input_tokens` field and never double-counting the cached portion. */
|
|
302
|
+
function foldCodexUsage(obj, acc) {
|
|
303
|
+
const info = (obj.info ?? obj.msg?.info);
|
|
304
|
+
const last = (obj.last_token_usage ?? info?.last_token_usage);
|
|
305
|
+
if (!last)
|
|
306
|
+
return;
|
|
307
|
+
const totalInput = num(last.input_tokens);
|
|
308
|
+
const cached = num(last.cached_input_tokens);
|
|
309
|
+
acc.uncached_input += Math.max(0, totalInput - cached);
|
|
310
|
+
acc.cache_read += cached;
|
|
311
|
+
acc.reasoning += num(last.reasoning_output_tokens);
|
|
312
|
+
acc.output += num(last.output_tokens);
|
|
313
|
+
acc.attempts += 1;
|
|
314
|
+
}
|
|
250
315
|
// ── Additional wakeable hosts (native spec §4.3–4.5) ────────────────────────
|
|
251
316
|
// These follow the documented per-host commands from Spec 1. Exact flag names
|
|
252
317
|
// drift across CLI versions (the spec flags this explicitly); where a behavior
|
|
@@ -259,6 +324,8 @@ export const geminiAdapter = {
|
|
|
259
324
|
allowedTools: ROOM_TOOLS,
|
|
260
325
|
// Gemini CLI adopted Agent Skills; loads from ~/.gemini/skills.
|
|
261
326
|
skillDir: join(homedir(), ".gemini", "skills"),
|
|
327
|
+
supportsConstrainedWake: false,
|
|
328
|
+
requiresSessionId: true,
|
|
262
329
|
buildWake(spec) {
|
|
263
330
|
// Piping the prompt on stdin makes the run non-TTY → headless, and keeps the
|
|
264
331
|
// multi-line prompt out of argv. `--yolo` is Gemini's auto-approve (verify the
|
|
@@ -294,6 +361,8 @@ export const cursorAdapter = {
|
|
|
294
361
|
bin: "cursor-agent",
|
|
295
362
|
allowedTools: ROOM_TOOLS,
|
|
296
363
|
skillDir: join(homedir(), ".cursor", "skills"),
|
|
364
|
+
supportsConstrainedWake: false,
|
|
365
|
+
requiresSessionId: true,
|
|
297
366
|
buildWake(spec) {
|
|
298
367
|
// `-p` is print/headless. `--force` is REQUIRED: without it, `-p` hangs
|
|
299
368
|
// indefinitely when workspace trust is required (spec §4.4 gotcha). Prompt on
|
|
@@ -331,43 +400,47 @@ export const openclawAdapter = {
|
|
|
331
400
|
allowedTools: ROOM_TOOLS,
|
|
332
401
|
// OpenClaw skills come from ClawHub; on-disk dir ~/.openclaw/skills.
|
|
333
402
|
skillDir: join(homedir(), ".openclaw", "skills"),
|
|
403
|
+
supportsConstrainedWake: false,
|
|
404
|
+
requiresSessionId: false,
|
|
405
|
+
// OpenClaw 2026.7.1's CLI falls back to an embedded run after a Gateway
|
|
406
|
+
// transport failure. Spec 35 requires one Gateway path. The CLI emits this
|
|
407
|
+
// marker before loading the embedded runner; runWake kills the process tree
|
|
408
|
+
// on sight so a transient Gateway failure fails closed.
|
|
409
|
+
forbiddenOutputPattern: /EMBEDDED FALLBACK:|["']fallbackFrom["']\s*:\s*["']gateway["']/i,
|
|
334
410
|
buildWake(spec) {
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
// agent NEVER receives the gateway's agent-rooms MCP server — it has no room
|
|
339
|
-
// tools and improvises (`sessions_send`, shelling out to `agent-rooms mcp`).
|
|
340
|
-
// `openclaw agent` runs an embedded Codex app-server turn that reads the
|
|
341
|
-
// agent's own codex config, where the room tools actually live.
|
|
342
|
-
//
|
|
343
|
-
// ⚠️ OpenClaw #80814 (open): the codex APP-SERVER runtime does NOT inject
|
|
344
|
-
// openclaw.json's `mcp.servers` into the agent's codex-home/config.toml — only
|
|
345
|
-
// the codex-CLI runtime does. So agent-rooms is authorized at the gateway
|
|
346
|
-
// (`openclaw mcp probe` shows it) yet invisible to the woken agent. ONE-TIME
|
|
347
|
-
// WORKAROUND (the maintainer's documented sidecar — see the OpenClaw setup
|
|
348
|
-
// guide): add the server to ~/.openclaw/agents/<id>/agent/codex-home/config.toml
|
|
349
|
-
// [mcp_servers.agent-rooms]
|
|
350
|
-
// url = "<api>/mcp/openclaw"
|
|
351
|
-
// bearer_token_env_var = "AGENT_ROOMS_TOKEN"
|
|
352
|
-
// (then clear cache/codex_apps_tools). Also trust the wake cwd in that config
|
|
353
|
-
// (`[projects."<dir>"] trust_level = "trusted"`) or codex disables exec policies.
|
|
354
|
-
//
|
|
355
|
-
// `--local` runs the codex turn EMBEDDED in this listener process, so it
|
|
356
|
-
// inherits our scrubbed env (the listener provides AGENT_ROOMS_TOKEN; the
|
|
357
|
-
// gateway daemon wouldn't). `--json` for parseable output. The OpenClaw agent
|
|
358
|
-
// id defaults to "main"; override with AGENT_ROOMS_OPENCLAW_AGENT. The prompt
|
|
359
|
-
// rides `-m` (argv) — `openclaw agent` takes no stdin prompt; the listener runs
|
|
360
|
-
// on Linux (shell:false), so a multi-line `-m` value is passed intact.
|
|
411
|
+
// Wake through the already-running Gateway. The Gateway is the single owner
|
|
412
|
+
// of native session state and MCP/plugin wiring; embedded `--local` turns are
|
|
413
|
+
// deliberately forbidden because they create a second authority path.
|
|
361
414
|
const agentId = process.env.AGENT_ROOMS_OPENCLAW_AGENT?.trim() || "main";
|
|
362
|
-
const args = ["agent", "--agent", agentId, "--
|
|
363
|
-
// Spec
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
415
|
+
const args = ["agent", "--agent", agentId, "--json"];
|
|
416
|
+
// Spec 35 §11 (Said's ruling): build against latest OpenClaw and route the
|
|
417
|
+
// turn through the RUNNING gateway — ONE accepted code path. The current CLI
|
|
418
|
+
// may announce an embedded fallback after a transport failure; runWake kills
|
|
419
|
+
// it immediately via forbiddenOutputPattern above.
|
|
420
|
+
// gateway already holds the agent-rooms MCP server (buildMcpAdd wires it via
|
|
421
|
+
// `openclaw mcp add … --auth oauth`), so the woken agent gets the room tools
|
|
422
|
+
// from the gateway, not from this process's env. There is deliberately no
|
|
423
|
+
// `--local` escape hatch: an embedded result is a second, untested authority
|
|
424
|
+
// path and Spec 35 requires the running gateway or a loud failure.
|
|
425
|
+
// Spec 23/35 §11: route + isolate per room. OpenClaw's sessionKey IS the
|
|
426
|
+
// conversation bucket; without an explicit room key every wake lands in the
|
|
427
|
+
// default `main` session, merging all rooms into one transcript. The gateway
|
|
428
|
+
// owns the session-id rotation behind this stable key (daily/idle/compaction
|
|
429
|
+
// resets are accepted as native; rotated ids are reported per §2).
|
|
367
430
|
if (spec.sessionKey)
|
|
368
431
|
args.push("--session-key", spec.sessionKey);
|
|
369
|
-
|
|
370
|
-
|
|
432
|
+
const promptPlaceholder = "__AGENT_ROOMS_PROMPT_FILE__";
|
|
433
|
+
args.push("--message-file", promptPlaceholder);
|
|
434
|
+
return {
|
|
435
|
+
command: this.bin,
|
|
436
|
+
args,
|
|
437
|
+
cwd: spec.workspace,
|
|
438
|
+
env: spec.env,
|
|
439
|
+
promptFile: { placeholder: promptPlaceholder, contents: spec.prompt }
|
|
440
|
+
};
|
|
441
|
+
},
|
|
442
|
+
buildPreflight() {
|
|
443
|
+
return { command: this.bin, args: ["gateway", "health", "--json", "--timeout", "10000"], cwd: process.cwd() };
|
|
371
444
|
},
|
|
372
445
|
buildMcpAdd(apiBase) {
|
|
373
446
|
// `--auth oauth` registers the server for OpenClaw's OAuth flow (completed by
|
|
@@ -375,7 +448,7 @@ export const openclawAdapter = {
|
|
|
375
448
|
// streamable-http server is wired with no auth and every room tool 401s.
|
|
376
449
|
return {
|
|
377
450
|
command: this.bin,
|
|
378
|
-
args: ["mcp", "add", "agent-rooms", "--transport", "streamable-http", "--url", `${apiBase}/mcp`, "--auth", "oauth"],
|
|
451
|
+
args: ["mcp", "add", "agent-rooms", "--transport", "streamable-http", "--url", `${apiBase}/mcp/openclaw`, "--auth", "oauth"],
|
|
379
452
|
cwd: process.cwd()
|
|
380
453
|
};
|
|
381
454
|
},
|
|
@@ -384,19 +457,44 @@ export const openclawAdapter = {
|
|
|
384
457
|
},
|
|
385
458
|
parseSessionId(stdout) {
|
|
386
459
|
return firstJsonField(stdout, ["session_id", "sessionId", "resumeSessionId"]);
|
|
460
|
+
},
|
|
461
|
+
parseFinalText(stdout) {
|
|
462
|
+
const result = parseJsonDocument(stdout);
|
|
463
|
+
const payloads = result?.payloads;
|
|
464
|
+
if (!Array.isArray(payloads))
|
|
465
|
+
return null;
|
|
466
|
+
const texts = payloads
|
|
467
|
+
.map((payload) => payload && typeof payload === "object" ? payload.text : null)
|
|
468
|
+
.filter((value) => typeof value === "string" && value.trim().length > 0);
|
|
469
|
+
return texts.length > 0 ? texts.join("\n") : null;
|
|
470
|
+
},
|
|
471
|
+
// OpenClaw 2026.7.1 prints one pretty JSON envelope. Usage is
|
|
472
|
+
// meta.agentMeta.usage {input,output,cacheRead,cacheWrite}; `input` is
|
|
473
|
+
// cache-exclusive in OpenClaw's normalizeUsage/derivePromptTokens contract.
|
|
474
|
+
parseUsageOutput(stdout) {
|
|
475
|
+
const result = parseJsonDocument(stdout);
|
|
476
|
+
const meta = result?.meta;
|
|
477
|
+
const agentMeta = meta?.agentMeta;
|
|
478
|
+
const usage = agentMeta?.usage;
|
|
479
|
+
if (!usage)
|
|
480
|
+
return null;
|
|
481
|
+
return {
|
|
482
|
+
uncached_input: num(usage.input), cache_read: num(usage.cacheRead),
|
|
483
|
+
cache_write: num(usage.cacheWrite), cache_write_5m: 0, cache_write_1h: 0,
|
|
484
|
+
reasoning: 0, output: num(usage.output), attempts: 1
|
|
485
|
+
};
|
|
387
486
|
}
|
|
388
487
|
};
|
|
389
488
|
export const hermesAdapter = {
|
|
390
489
|
name: "hermes",
|
|
391
490
|
bin: "hermes",
|
|
392
491
|
allowedTools: ROOM_TOOLS,
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
skillDir: null,
|
|
492
|
+
// --ignore-rules skips ambient/preconfigured skills, but current Hermes loads
|
|
493
|
+
// an explicit --skills argument after constructing that isolated context. This
|
|
494
|
+
// keeps memory/rules off while making the hosted Agent Rooms map visible.
|
|
495
|
+
skillDir: join(homedir(), ".hermes", "skills"),
|
|
496
|
+
supportsConstrainedWake: false,
|
|
497
|
+
requiresSessionId: true,
|
|
400
498
|
buildWake(spec) {
|
|
401
499
|
// Spec 23 / Spec 24 — ROOM-LOCKED, MEMORY-OFF wake. The token-burn bug was
|
|
402
500
|
// cross-session bleed; Hermes' equivalent leak is its persistent MEMORY.md /
|
|
@@ -418,7 +516,7 @@ export const hermesAdapter = {
|
|
|
418
516
|
// `hermes sessions list`. The prompt rides argv (-q's value); Hermes has no
|
|
419
517
|
// stdin prompt mode. The listener runs alongside Hermes on Linux (shell:false),
|
|
420
518
|
// so a multi-line argv value is passed intact (execve, no shell word-splitting).
|
|
421
|
-
const args = ["chat", "-q", spec.prompt, "-Q", "--yolo", "--ignore-rules", "--source", "tool"];
|
|
519
|
+
const args = ["chat", "-q", spec.prompt, "-Q", "--yolo", "--ignore-rules", "--skills", "agent-rooms", "--source", "tool"];
|
|
422
520
|
if (spec.sessionId) {
|
|
423
521
|
args.push("--resume", spec.sessionId);
|
|
424
522
|
}
|
|
@@ -442,15 +540,39 @@ export const hermesAdapter = {
|
|
|
442
540
|
parseSessionId(_stdout, stderr) {
|
|
443
541
|
// Hermes single-query mode prints `\nsession_id: <id>` to STDERR (stdout stays a
|
|
444
542
|
// clean answer). Id shape is `%Y%m%d_%H%M%S_<shortuuid>` e.g. 20260626_143022_a1b2c3d4.
|
|
543
|
+
//
|
|
544
|
+
// Spec 35 §10: compaction can rotate the session id through a parent/child
|
|
545
|
+
// chain, printing the parent then the child. We must capture the LAST id (the
|
|
546
|
+
// rotated child), not the first — resuming the stale parent would fail. Match
|
|
547
|
+
// ALL occurrences and take the final one.
|
|
445
548
|
const haystack = stderr ?? "";
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
549
|
+
const strict = [...haystack.matchAll(/session_id:\s*([0-9]{8}_[0-9]{6}_[0-9a-f]+)/gi)];
|
|
550
|
+
if (strict.length > 0)
|
|
551
|
+
return strict[strict.length - 1][1] ?? null;
|
|
552
|
+
const loose = [...haystack.matchAll(/session_id:\s*(\S+)/gi)];
|
|
553
|
+
return loose.length > 0 ? (loose[loose.length - 1][1] ?? null) : null;
|
|
554
|
+
},
|
|
555
|
+
parseFinalText(stdout) {
|
|
556
|
+
return stdout.trim() || null;
|
|
557
|
+
},
|
|
558
|
+
// Spec 35 §10: a `--resume <id>` against a session Hermes no longer has must be
|
|
559
|
+
// reported (→ server evicts → fresh next wake), never retried blindly. Hermes
|
|
560
|
+
// prints a load/restore failure to stderr; match it narrowly.
|
|
561
|
+
resumeFailurePattern: /session .*(?:not found|does not exist|could not be (?:found|loaded|restored))|no such session|failed to (?:load|restore|resume) session/i
|
|
453
562
|
};
|
|
563
|
+
function parseJsonDocument(stdout) {
|
|
564
|
+
const start = stdout.indexOf("{");
|
|
565
|
+
const end = stdout.lastIndexOf("}");
|
|
566
|
+
if (start < 0 || end <= start)
|
|
567
|
+
return null;
|
|
568
|
+
try {
|
|
569
|
+
const value = JSON.parse(stdout.slice(start, end + 1));
|
|
570
|
+
return value && typeof value === "object" ? value : null;
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
454
576
|
// General adapter (spec 18): wake any other MCP-capable CLI the user wires via
|
|
455
577
|
// env — AGENT_ROOMS_CUSTOM_BIN (the command) + AGENT_ROOMS_CUSTOM_ARGS (space-
|
|
456
578
|
// separated argv; the wake prompt rides stdin, same env-scrub/argv/stop-kill
|
|
@@ -460,6 +582,8 @@ export const customAdapter = {
|
|
|
460
582
|
bin: process.env.AGENT_ROOMS_CUSTOM_BIN || "",
|
|
461
583
|
allowedTools: ROOM_TOOLS,
|
|
462
584
|
skillDir: null,
|
|
585
|
+
supportsConstrainedWake: false,
|
|
586
|
+
requiresSessionId: false,
|
|
463
587
|
buildWake(spec) {
|
|
464
588
|
const extra = (process.env.AGENT_ROOMS_CUSTOM_ARGS || "").split(/\s+/).filter(Boolean);
|
|
465
589
|
return { command: this.bin, args: extra, cwd: spec.workspace, stdin: spec.prompt, env: spec.env };
|
|
@@ -498,9 +622,60 @@ function binOnPath(bin) {
|
|
|
498
622
|
return false;
|
|
499
623
|
}
|
|
500
624
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
625
|
+
// Spec 35 §11/§13 — the host versions this listener is built and tested against.
|
|
626
|
+
// doctor gates on these and tells the user to update; we ship no compatibility
|
|
627
|
+
// shims for older builds. Bump alongside the compatibility matrix in the docs.
|
|
628
|
+
export const MIN_HOST_VERSIONS = {
|
|
629
|
+
openclaw: "2026.7.1",
|
|
630
|
+
claude_code: "2.1.190",
|
|
631
|
+
codex: "0.142.0",
|
|
632
|
+
hermes: "0.18.2"
|
|
633
|
+
};
|
|
634
|
+
/** Read a host CLI's reported version string (first version-looking token), or
|
|
635
|
+
* null if the probe fails. Zero model tokens — a plain `--version` call. */
|
|
636
|
+
export function readHostVersion(bin) {
|
|
637
|
+
try {
|
|
638
|
+
const probe = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 5000, shell: process.platform === "win32" });
|
|
639
|
+
const text = `${probe.stdout ?? ""} ${probe.stderr ?? ""}`;
|
|
640
|
+
const match = /(\d+\.\d+(?:\.\d+)?)/.exec(text);
|
|
641
|
+
return match ? match[1] : null;
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/** Compare dotted numeric versions: is `version` >= `min`? Missing segments = 0. */
|
|
648
|
+
export function versionAtLeast(version, min) {
|
|
649
|
+
const a = version.split(".").map((s) => Number.parseInt(s, 10) || 0);
|
|
650
|
+
const b = min.split(".").map((s) => Number.parseInt(s, 10) || 0);
|
|
651
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
652
|
+
const x = a[i] ?? 0;
|
|
653
|
+
const y = b[i] ?? 0;
|
|
654
|
+
if (x > y)
|
|
655
|
+
return true;
|
|
656
|
+
if (x < y)
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
/** Spec 35 §11/§14 — the version gate. Returns an actionable message when the
|
|
662
|
+
* installed host is too old (or unreadable), else null (pass). */
|
|
663
|
+
export function hostVersionGate(host) {
|
|
664
|
+
const min = MIN_HOST_VERSIONS[host] ?? null;
|
|
665
|
+
if (!min)
|
|
666
|
+
return { ok: true, installed: null, min: null, message: null };
|
|
667
|
+
const adapter = getAdapter(host);
|
|
668
|
+
const bin = adapter?.bin ?? host;
|
|
669
|
+
const installed = readHostVersion(bin);
|
|
670
|
+
if (!installed) {
|
|
671
|
+
return { ok: false, installed: null, min, message: `Could not read ${bin} --version; ensure ${bin} ${min}+ is installed and on PATH.` };
|
|
672
|
+
}
|
|
673
|
+
if (!versionAtLeast(installed, min)) {
|
|
674
|
+
return { ok: false, installed, min, message: `${bin} ${installed} is older than the required ${min}. Update ${bin} to ${min}+ and restart the listener.` };
|
|
675
|
+
}
|
|
676
|
+
return { ok: true, installed, min, message: null };
|
|
677
|
+
}
|
|
678
|
+
/** Is every binary required by this adapter available on PATH? */
|
|
504
679
|
export function isHostInstalled(adapter) {
|
|
505
680
|
if (!binOnPath(adapter.bin))
|
|
506
681
|
return false;
|
|
@@ -515,9 +690,7 @@ export function detectInstalledHosts() {
|
|
|
515
690
|
// are not offered. The general adapter is opt-in via env, so it's not auto-detected.
|
|
516
691
|
return [claudeAdapter, codexAdapter, openclawAdapter, hermesAdapter].filter(isHostInstalled);
|
|
517
692
|
}
|
|
518
|
-
/** Is the
|
|
519
|
-
* is, the woken agent already knows the room protocol, so `watch` sends a short
|
|
520
|
-
* wake prompt (just the mention) instead of the full how-to. */
|
|
693
|
+
/** Is the owned hosted Agent Rooms skill present in this host's skill directory? */
|
|
521
694
|
export function isSkillInstalled(adapter) {
|
|
522
695
|
if (!adapter.skillDir)
|
|
523
696
|
return false;
|