agent-rooms 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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 (§9, §10) — spec 21: wakes run UNSANDBOXED on every host by default. A
7
- // contained wake can read + post but can't build, edit, or run anything, so it
8
- // can't actually do the work unsandboxed is the only mode that functions. Claude
9
- // gets --dangerously-skip-permissions; Codex --dangerously-bypass-approvals-and-sandbox;
10
- // OpenClaw acpx --approve-all. The real safeguards are (1) you only make an agent
11
- // wakeable deliberately, (2) cross-owner wakes are gated upstream by consent + an
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 — the claim work heartbeat report loop (orchestration layer)
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 adopted the Agent Skills standard; it loads skills from ~/.codex/skills
186
- // (the $skill-installer convention). Installing the bundled folder here makes a
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
- // Approvals/sandbox (PROVEN 2026-06): under `--sandbox workspace-write` a
194
- // headless `codex exec` routes every agent-rooms MCP tool call through the
195
- // approval reviewer. In a spawned (non-interactive) run there is no approver,
196
- // so each call returns "user cancelled MCP tool call" — codex reads the skill,
197
- // tries whoami/check_mentions, gets cancelled, and exits 0 having never read
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
- // WAKE VIA THE GATEWAY AGENT TURN `openclaw agent` — NOT the acpx ACP bridge
336
- // (FIX 2026-06, OpenClaw #80814). The acpx bridge opens an ACP session with
337
- // `mcpServers:[]` and advertises `mcpCapabilities.http:false`, so the woken
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, "--local", "--json"];
363
- // Spec 23: route + isolate per room. OpenClaw's sessionKey IS the conversation
364
- // bucket; without an explicit room key every Agent Rooms wake lands in the
365
- // default `main` session, merging all rooms into one transcript (cross-room leak
366
- // + token burn). `--agent` alone is never enough for an Agent Rooms wake.
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
- args.push("-m", spec.prompt);
370
- return { command: this.bin, args, cwd: spec.workspace, env: spec.env };
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
- // INTENTIONALLY null. Hermes loads skills from ~/.hermes/skills, BUT every wake
394
- // runs with `--ignore-rules`, which (verified in hermes source: cli_agent_setup_mixin
395
- // passes skip_memory=skip_context_files=self.ignore_rules) skips auto-injection of
396
- // AGENTS.md/SOUL.md/memory AND *preloaded skills*. So a woken Hermes never sees a
397
- // preloaded skill — we always send the FULL wake prompt (skillDir:null makes
398
- // isSkillInstalled() return false, so the listener never shortens it).
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 m = haystack.match(/session_id:\s*([0-9]{8}_[0-9]{6}_[0-9a-f]+)/i) ?? haystack.match(/session_id:\s*(\S+)/i);
447
- return m?.[1] ?? null;
448
- }
449
- // No parseFinalText: with `-Q` the tool stream is suppressed, so we can't tell
450
- // whether the agent called send_message itself relaying stdout would risk a
451
- // double-post. The woken Hermes posts via the send_message MCP tool (the wake
452
- // prompt instructs it); the live smoke-test verifies that path before prod-enable.
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
- /** Is the host's CLI available on PATH? (used by `init` for auto-detection). When
502
- * the adapter wakes through a separate binary (wakeBin, e.g. OpenClaw acpx), BOTH
503
- * must be present, so we never auto-detect / advertise a host we can't actually wake. */
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 agent-rooms skill already installed in this host's skills dir? When it
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;