agent-rooms 0.10.0 → 0.11.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/listener.js CHANGED
@@ -1,8 +1,10 @@
1
1
  // The listener runtime behind `agent-rooms watch`: registers running instances,
2
2
  // holds the socket to the cloud, and spawns idle local agents on wake.
3
+ import { randomUUID } from "node:crypto";
3
4
  import { cpus, platform } from "node:os";
4
5
  import { loadConfig, openclawSessionKey, recordSession, roomSessionKey, saveConfig } from "./config.js";
5
6
  import { listenerPost } from "./api.js";
7
+ import { createNormalizer } from "./normalize.js";
6
8
  import { getAdapter, isSkillInstalled } from "./hosts.js";
7
9
  import { ensureClaudeMdBridge, writeRoomMd } from "./bridge.js";
8
10
  import { runWake } from "./spawn.js";
@@ -353,11 +355,26 @@ export class ListenerRuntime {
353
355
  this.emit("warn", `Model provider rate-limited; backing off ${Math.round(waitMs / 1000)}s before waking ${wake.target}.`, "rate_limit_backoff");
354
356
  await new Promise((resolve) => setTimeout(resolve, waitMs));
355
357
  }
358
+ // Spec 31: mint the claude session id on the FIRST run for this (agent, room)
359
+ // so continuity + the resume command exist from turn one. Codex mints its own
360
+ // thread_id (captured post-run); other hosts keep their existing behavior.
361
+ const mintSessionId = binding.host === "claude_code" && !sessionId ? randomUUID() : undefined;
356
362
  // Register an abort handle so an owner stop can terminate this run mid-flight.
357
363
  const abort = new AbortController();
358
364
  this.trackSpawn(wake.target, abort);
359
365
  this.inFlightSpawns += 1;
360
366
  this.recentSpawns.push(Date.now());
367
+ // Spec 31: live activity stream. Normalize the host's NDJSON into RunEvents
368
+ // and relay them upstream in small batches on the same authed control route
369
+ // wake_results use. Streaming is best-effort (drop on the floor if the post
370
+ // fails) — but run_started/run_finished always go (terminal state is not
371
+ // best-effort). Only claude_code/codex have normalizers; other hosts skip.
372
+ const streamable = binding.host === "claude_code" || binding.host === "codex";
373
+ const instanceIdForRun = wake.instance_id ?? this.instanceByAgentWorkspace.get(bindingKey(binding.agent, binding.workspace));
374
+ const relay = streamable && instanceIdForRun && config.device
375
+ ? this.createRunRelay(binding, wake, instanceIdForRun, config.device.token)
376
+ : null;
377
+ relay?.push(relay.normalizer.start());
361
378
  // Spec 21: wakes run UNSANDBOXED on every host by default — it's the only mode
362
379
  // that actually does work (a contained agent can read + post but can't build,
363
380
  // edit, or run anything). This is independent of who/how the wake was triggered,
@@ -368,12 +385,21 @@ export class ListenerRuntime {
368
385
  const fullAccess = process.env.AGENT_ROOMS_WAKE_CONTAIN !== "1";
369
386
  let outcome;
370
387
  try {
371
- outcome = await runWake(adapter, { prompt, workspace: binding.workspace, sessionId, sessionKey: sessionKeyForHost, maxTurns: this.maxTurns, fullAccess, env }, undefined, abort.signal);
388
+ outcome = await runWake(adapter, { prompt, workspace: binding.workspace, sessionId, mintSessionId, sessionKey: sessionKeyForHost, maxTurns: this.maxTurns, fullAccess, env }, undefined, abort.signal, relay ? (line) => { for (const ev of relay.normalizer.line(line))
389
+ relay.push(ev); } : undefined);
372
390
  }
373
391
  finally {
374
392
  this.inFlightSpawns -= 1;
375
393
  this.untrackSpawn(wake.target, abort);
376
394
  }
395
+ // Terminal state is never best-effort: crash / kill / nonzero exit with no
396
+ // vendor terminal event still lands a run_finished (failed) in the room.
397
+ if (relay) {
398
+ const fin = relay.normalizer.finish(outcome.code, outcome.status === "replied" ? "ok" : outcome.status === "stopped" || outcome.status === "timeout" || outcome.status === "error" ? "failed" : undefined);
399
+ if (fin)
400
+ relay.push(fin);
401
+ await relay.flush(true).catch(() => { });
402
+ }
377
403
  // Spec 21: if the run hit a hard rate-limit, set the cooldown so the next wake backs off.
378
404
  if (outcome.rateLimited) {
379
405
  this.rateLimitCooldownUntil = Date.now() + RATE_LIMIT_BACKOFF_MS;
@@ -382,8 +408,11 @@ export class ListenerRuntime {
382
408
  // wake in THIS room resumes THIS room's session — never another room's. OpenClaw
383
409
  // needs no stored id (its stable per-room session-key is the lock), so we only
384
410
  // record for resume-by-id hosts.
385
- if (outcome.sessionId && !isOpenClaw) {
386
- const next = recordSession(config, key, outcome.sessionId);
411
+ // Spec 31: the minted id is authoritative when the stream didn't echo one
412
+ // (claude accepts --session-id verbatim, so the transcript lives under it).
413
+ const settledSessionId = outcome.sessionId ?? mintSessionId ?? null;
414
+ if (settledSessionId && !isOpenClaw) {
415
+ const next = recordSession(config, key, settledSessionId);
387
416
  saveConfig(next);
388
417
  this.config = next;
389
418
  }
@@ -419,12 +448,87 @@ export class ListenerRuntime {
419
448
  instance_id: instanceId,
420
449
  room: wake.room,
421
450
  status: reported,
422
- session_id: outcome.sessionId ?? undefined
451
+ session_id: settledSessionId ?? undefined
423
452
  }).catch((error) => this.emit("warn", `wake_result post failed: ${error.message}`, "wake_result_failed"));
424
453
  }
425
454
  this.emit(outcome.status === "error" ? "warn" : "info", `${adapter.name} wake finished with ${outcome.status}.`, "wake_finished");
426
455
  return { status: reported };
427
456
  }
457
+ /** Spec 31: batches RunEvents and posts them upstream (~800ms cadence, cap 25
458
+ * per frame). WS-down / post-fail behavior: keep a bounded buffer (drop the
459
+ * oldest mid-run events beyond it), but always retry hard for run_finished —
460
+ * live fidelity is best-effort, terminal state is not. */
461
+ createRunRelay(binding, wake, instanceId, token) {
462
+ const runId = randomUUID();
463
+ const normalizer = createNormalizer(binding.host, runId);
464
+ const queue = [];
465
+ let timer = null;
466
+ let posting = false;
467
+ const BUFFER_CAP = 200;
468
+ const post = async (events, retries) => {
469
+ for (let attempt = 0;; attempt += 1) {
470
+ try {
471
+ await listenerPost(this.apiBase, token, {
472
+ t: "run_event", v: 1, id: rid(), instance_id: instanceId, room: wake.room,
473
+ host: binding.host, trigger_message_id: wake.trigger_message_id, events
474
+ });
475
+ return;
476
+ }
477
+ catch (error) {
478
+ if (attempt >= retries)
479
+ throw error;
480
+ await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
481
+ }
482
+ }
483
+ };
484
+ const flush = async (final = false) => {
485
+ if (timer) {
486
+ clearTimeout(timer);
487
+ timer = null;
488
+ }
489
+ if (posting && !final)
490
+ return; // next tick picks it up
491
+ while (queue.length > 0) {
492
+ posting = true;
493
+ const batch = queue.splice(0, 25);
494
+ const hasTerminal = batch.some((e) => e.kind === "run_finished" || e.kind === "run_started");
495
+ try {
496
+ await post(batch, hasTerminal ? 3 : 0);
497
+ }
498
+ catch (error) {
499
+ if (!final)
500
+ this.emit("warn", `run_event relay failed: ${error.message}`, "run_relay_failed");
501
+ }
502
+ finally {
503
+ posting = false;
504
+ }
505
+ if (!final)
506
+ break; // live mode: one batch per tick, keep latency low
507
+ }
508
+ if (queue.length > 0 && !final && !timer) {
509
+ timer = setTimeout(() => void flush(), 200);
510
+ timer.unref?.();
511
+ }
512
+ };
513
+ return {
514
+ runId,
515
+ normalizer,
516
+ push: (ev) => {
517
+ queue.push(ev);
518
+ // bound the live buffer: drop oldest NON-terminal events beyond the cap
519
+ if (queue.length > BUFFER_CAP) {
520
+ const idx = queue.findIndex((e) => e.kind !== "run_started" && e.kind !== "run_finished");
521
+ if (idx >= 0)
522
+ queue.splice(idx, 1);
523
+ }
524
+ if (!timer && !posting) {
525
+ timer = setTimeout(() => void flush(), 800);
526
+ timer.unref?.();
527
+ }
528
+ },
529
+ flush
530
+ };
531
+ }
428
532
  trackSpawn(target, abort) {
429
533
  const set = this.running.get(target) ?? new Set();
430
534
  set.add(abort);
@@ -469,6 +573,10 @@ export class ListenerRuntime {
469
573
  if (exact)
470
574
  return exact;
471
575
  }
576
+ // KNOWN GAP (Specs/28-ROOM-SCOPED-WAKE-ENFORCEMENT.md): the agent-only
577
+ // fallback below defeats `init --room` scoping — wakes for unbound rooms
578
+ // still spawn. It also papers over the rooms-joined-after-binding case, so
579
+ // do NOT remove it without the wildcard/rebind decision in Spec 28.
472
580
  return bindings.find((b) => b.agent === wake.target && b.rooms.includes(wake.room))
473
581
  ?? bindings.find((b) => b.agent === wake.target)
474
582
  ?? null;
@@ -0,0 +1,269 @@
1
+ // Spec 31 — the normalizer: one module per host mapping vendor stream events to
2
+ // OUR RunEvent vocabulary. This is the shock absorber for vendor schema drift;
3
+ // the backend and UI never see a vendor shape. Unknown events become generic
4
+ // `activity` rows — a run must NEVER crash on an unrecognized event.
5
+ import { log } from "./log.js";
6
+ const TEXT_CAP = 2048; // per-event payload cap (spec §4.2)
7
+ const EVENT_CAP = 500; // relayed events per run; past it only structure survives
8
+ const cap = (s) => (s.length > TEXT_CAP ? `${s.slice(0, TEXT_CAP)}…` : s);
9
+ export function createNormalizer(host, runId) {
10
+ const state = {
11
+ seq: 0,
12
+ relayed: 0,
13
+ finished: false,
14
+ // summary counters (count everything, even past the relay cap)
15
+ actions: 0,
16
+ files: 0,
17
+ commands: 0,
18
+ // codex: command item id → the seq of its `command` event (for patching)
19
+ cmdSeqById: new Map(),
20
+ // claude: tool_use id → { seq, kind } so tool_result patches the right row
21
+ toolById: new Map(),
22
+ // one todo row per run — replaced in place
23
+ todoSeq: null,
24
+ sawTurnFailed: false
25
+ };
26
+ const next = (partial) => {
27
+ state.seq += 1;
28
+ return { run_id: runId, seq: state.seq, ts: Date.now(), ...partial };
29
+ };
30
+ /** Relay-cap filter: past EVENT_CAP, only structural events survive. */
31
+ const emit = (ev) => {
32
+ if (!ev)
33
+ return [];
34
+ state.relayed += 1;
35
+ if (state.relayed > EVENT_CAP) {
36
+ const keep = ev.kind === "command" || ev.kind === "command_result" || ev.kind === "file_change"
37
+ || ev.kind === "todo" || ev.kind === "run_finished";
38
+ if (!keep)
39
+ return [];
40
+ }
41
+ return [ev];
42
+ };
43
+ const mapLine = host === "codex" ? codexLine : claudeLine;
44
+ return {
45
+ start() {
46
+ return next({ kind: "run_started" });
47
+ },
48
+ line(raw) {
49
+ const line = raw.trim();
50
+ if (!line.startsWith("{"))
51
+ return [];
52
+ let obj;
53
+ try {
54
+ obj = JSON.parse(line);
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ try {
60
+ return mapLine(obj, state, next).flatMap((e) => emit(e));
61
+ }
62
+ catch (error) {
63
+ // Never crash the run on a weird event; surface it generically.
64
+ log.warn(`normalizer(${host}): ${error.message}`);
65
+ return emit(next({ kind: "activity", label: "event", detail: cap(String(obj.type ?? "unknown")) }));
66
+ }
67
+ },
68
+ finish(exitCode, forcedOutcome) {
69
+ if (state.finished)
70
+ return null;
71
+ state.finished = true;
72
+ const outcome = forcedOutcome ?? (state.sawTurnFailed || exitCode !== 0 ? "failed" : "ok");
73
+ return next({
74
+ kind: "run_finished",
75
+ outcome,
76
+ summary: { actions: state.actions, files: state.files, commands: state.commands }
77
+ });
78
+ }
79
+ };
80
+ }
81
+ // ── claude_code: `--output-format stream-json` ───────────────────────────────
82
+ // assistant text → text · Bash tool_use → command · matching tool_result →
83
+ // command_result · Edit/Write/MultiEdit → file_change · TodoWrite → todo ·
84
+ // other tool_use → activity. Field shapes verified against live stream-json:
85
+ // {"type":"assistant","message":{"content":[{"type":"text","text":…}|{"type":
86
+ // "tool_use","id":…,"name":…,"input":{…}}]}} and {"type":"user","message":
87
+ // {"content":[{"type":"tool_result","tool_use_id":…,"is_error":…}]}}.
88
+ function claudeLine(obj, state, next) {
89
+ const type = obj.type;
90
+ if (type === "assistant" || type === "user") {
91
+ const message = obj.message;
92
+ const content = Array.isArray(message?.content) ? message.content : [];
93
+ const out = [];
94
+ for (const block of content) {
95
+ if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
96
+ out.push(next({ kind: "text", text: cap(block.text.trim()) }));
97
+ continue;
98
+ }
99
+ if (block.type === "tool_use" && typeof block.name === "string") {
100
+ state.actions += 1;
101
+ const input = (block.input ?? {});
102
+ const id = typeof block.id === "string" ? block.id : "";
103
+ const name = block.name;
104
+ if (name === "Bash") {
105
+ state.commands += 1;
106
+ const ev = next({ kind: "command", command: cap(String(input.command ?? "")) });
107
+ if (id)
108
+ state.toolById.set(id, { seq: ev.seq, name });
109
+ out.push(ev);
110
+ }
111
+ else if (name === "Edit" || name === "Write" || name === "MultiEdit" || name === "NotebookEdit") {
112
+ state.files += 1;
113
+ out.push(next({
114
+ kind: "file_change",
115
+ file: cap(String(input.file_path ?? input.path ?? "")),
116
+ verb: name === "Write" ? "write" : "edit"
117
+ }));
118
+ }
119
+ else if (name === "Read") {
120
+ out.push(next({ kind: "file_change", file: cap(String(input.file_path ?? "")), verb: "read" }));
121
+ }
122
+ else if (name === "TodoWrite") {
123
+ const todos = Array.isArray(input.todos) ? input.todos : [];
124
+ const items = todos.map((t) => ({
125
+ text: cap(String(t.content ?? t.activeForm ?? "")),
126
+ done: t.status === "completed"
127
+ }));
128
+ const ev = next({ kind: "todo", items, ref_seq: state.todoSeq ?? undefined });
129
+ state.todoSeq = state.todoSeq ?? ev.seq;
130
+ ev.ref_seq = state.todoSeq;
131
+ out.push(ev);
132
+ }
133
+ else {
134
+ // MCP room tools, WebSearch, Grep/Glob, Task … → generic activity
135
+ const short = name.replace(/^mcp__agent-rooms__/, "room: ").replace(/^mcp__/, "");
136
+ out.push(next({ kind: "activity", label: cap(short), detail: cap(oneLine(input)) }));
137
+ }
138
+ continue;
139
+ }
140
+ if (block.type === "tool_result") {
141
+ const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
142
+ const ref = id ? state.toolById.get(id) : undefined;
143
+ if (ref?.name === "Bash") {
144
+ state.toolById.delete(id);
145
+ out.push(next({
146
+ kind: "command_result",
147
+ ref_seq: ref.seq,
148
+ exit_code: block.is_error ? 1 : 0
149
+ }));
150
+ }
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+ if (type === "result") {
156
+ // handled by finish(); the final text already streamed as assistant text
157
+ return [];
158
+ }
159
+ if (type === "system" || type === "rate_limit_event")
160
+ return [];
161
+ return [next({ kind: "activity", label: cap(String(type ?? "event")) })];
162
+ }
163
+ // ── codex: `exec --json` ─────────────────────────────────────────────────────
164
+ // item.* events with item.type ∈ agent_message | reasoning | command_execution |
165
+ // file_change | todo_list | mcp_tool_call | web_search | error. Transient
166
+ // "Reconnecting…" errors are non-fatal. turn.failed → failed outcome.
167
+ function codexLine(obj, state, next) {
168
+ const type = String(obj.type ?? "");
169
+ if (type === "thread.started" || type === "turn.started" || type === "turn.completed")
170
+ return [];
171
+ if (type === "turn.failed") {
172
+ state.sawTurnFailed = true;
173
+ return [];
174
+ }
175
+ if (type === "error") {
176
+ const message = String(obj.message ?? "");
177
+ if (/reconnecting/i.test(message))
178
+ return []; // transient, non-fatal
179
+ return [next({ kind: "activity", label: "error", detail: cap(message) })];
180
+ }
181
+ if (!type.startsWith("item.")) {
182
+ return [next({ kind: "activity", label: cap(type || "event") })];
183
+ }
184
+ const item = (obj.item ?? {});
185
+ const itemType = String(item.type ?? "");
186
+ const itemId = typeof item.id === "string" ? item.id : "";
187
+ const phase = type; // item.started | item.updated | item.completed
188
+ switch (itemType) {
189
+ case "agent_message": {
190
+ if (phase !== "item.completed")
191
+ return [];
192
+ const text = String(item.text ?? "").trim();
193
+ return text ? [next({ kind: "text", text: cap(text) })] : [];
194
+ }
195
+ case "command_execution": {
196
+ if (phase === "item.started") {
197
+ state.actions += 1;
198
+ state.commands += 1;
199
+ const ev = next({ kind: "command", command: cap(String(item.command ?? "")) });
200
+ if (itemId)
201
+ state.cmdSeqById.set(itemId, ev.seq);
202
+ return [ev];
203
+ }
204
+ if (phase === "item.completed") {
205
+ const ref = itemId ? state.cmdSeqById.get(itemId) : undefined;
206
+ if (ref == null)
207
+ return [];
208
+ state.cmdSeqById.delete(itemId);
209
+ const exit = Number(item.exit_code);
210
+ return [next({
211
+ kind: "command_result",
212
+ ref_seq: ref,
213
+ exit_code: Number.isFinite(exit) ? exit : (item.status === "failed" ? 1 : 0)
214
+ })];
215
+ }
216
+ return [];
217
+ }
218
+ case "file_change": {
219
+ if (phase !== "item.completed")
220
+ return [];
221
+ state.actions += 1;
222
+ const changes = Array.isArray(item.changes) ? item.changes : [];
223
+ if (changes.length === 0) {
224
+ state.files += 1;
225
+ return [next({ kind: "file_change", file: cap(String(item.path ?? "")), verb: "edit" })];
226
+ }
227
+ state.files += changes.length;
228
+ return changes.map((c) => next({
229
+ kind: "file_change",
230
+ file: cap(String(c.path ?? "")),
231
+ verb: c.kind === "add" ? "write" : "edit"
232
+ }));
233
+ }
234
+ case "todo_list": {
235
+ const todos = Array.isArray(item.items) ? item.items : [];
236
+ const items = todos.map((t) => ({ text: cap(String(t.text ?? "")), done: t.completed === true }));
237
+ const ev = next({ kind: "todo", items });
238
+ state.todoSeq = state.todoSeq ?? ev.seq;
239
+ ev.ref_seq = state.todoSeq;
240
+ return [ev];
241
+ }
242
+ case "reasoning":
243
+ return []; // internal noise — never relayed
244
+ case "web_search": {
245
+ if (phase !== "item.started" && phase !== "item.completed")
246
+ return [];
247
+ if (phase === "item.started")
248
+ state.actions += 1;
249
+ return phase === "item.started"
250
+ ? [next({ kind: "activity", label: "web search", detail: cap(String(item.query ?? "")) })]
251
+ : [];
252
+ }
253
+ case "mcp_tool_call": {
254
+ if (phase !== "item.started")
255
+ return [];
256
+ state.actions += 1;
257
+ const tool = String(item.tool ?? item.name ?? "tool").replace(/^agent-rooms\./, "room: ");
258
+ return [next({ kind: "activity", label: cap(tool) })];
259
+ }
260
+ case "error":
261
+ return [next({ kind: "activity", label: "error", detail: cap(String(item.message ?? "")) })];
262
+ default:
263
+ return [next({ kind: "activity", label: cap(itemType || "event") })];
264
+ }
265
+ }
266
+ function oneLine(input) {
267
+ const hint = input.description ?? input.query ?? input.pattern ?? input.url ?? input.command ?? "";
268
+ return String(hint).split("\n")[0] ?? "";
269
+ }
package/dist/spawn.js CHANGED
@@ -210,7 +210,7 @@ const DEFAULT_WAKE_TIMEOUT_MS = (() => {
210
210
  const n = Number(process.env.AGENT_ROOMS_WAKE_TIMEOUT_MS);
211
211
  return Number.isFinite(n) && n > 0 ? n : 60 * 60 * 1000;
212
212
  })();
213
- export function runWake(adapter, spec, timeoutMs = DEFAULT_WAKE_TIMEOUT_MS, signal) {
213
+ export function runWake(adapter, spec, timeoutMs = DEFAULT_WAKE_TIMEOUT_MS, signal, onStreamLine) {
214
214
  const { command, args, cwd, stdin, env } = adapter.buildWake(spec);
215
215
  log.info(`spawn ${adapter.name}: ${command} ${args.filter((a) => !a.includes("\n")).slice(0, 6).join(" ")}… (cwd ${cwd})`);
216
216
  const startedAt = Date.now();
@@ -293,6 +293,14 @@ export function runWake(adapter, spec, timeoutMs = DEFAULT_WAKE_TIMEOUT_MS, sign
293
293
  rateLimited = true;
294
294
  if (!DEBUG)
295
295
  summarise(line);
296
+ // Spec 31: live stream tap — the normalizer turns this into RunEvents.
297
+ // A throwing consumer must never kill the run.
298
+ if (onStreamLine) {
299
+ try {
300
+ onStreamLine(line);
301
+ }
302
+ catch { /* consumer's problem, not the run's */ }
303
+ }
296
304
  }
297
305
  });
298
306
  child.stderr?.on("data", (chunk) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-rooms",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Companion listener for Agent Rooms. Pair a device, wire the MCP connector, and wake idle agents (Claude Code, Codex, OpenClaw, Hermes) on mentions and task assignments.",
5
5
  "keywords": [
6
6
  "agent-rooms",
@@ -33,7 +33,7 @@
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",
35
35
  "dev": "tsx src/cli.ts",
36
- "test": "node --import tsx --test src/hosts.test.ts src/config.test.ts src/uninstall.test.ts src/spawn.test.ts src/bridge.test.ts",
36
+ "test": "node --import tsx --test src/hosts.test.ts src/config.test.ts src/uninstall.test.ts src/spawn.test.ts src/bridge.test.ts src/normalize.test.ts",
37
37
  "typecheck": "tsc -p tsconfig.json --noEmit",
38
38
  "prepublishOnly": "npm run build"
39
39
  },