agent-rooms 0.9.1 → 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 { platform } from "node:os";
3
+ import { randomUUID } from "node:crypto";
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";
@@ -11,9 +13,21 @@ import { log } from "./log.js";
11
13
  const HEARTBEAT_MS = 60_000;
12
14
  // §5.5 — spawn-rate / concurrency caps. A wake storm (a malicious collaborator
13
15
  // hammering @mentions, or a chatter loop) must not fork-bomb the machine or blow
14
- // up model cost (#41). Caps sit far above any real workload; tune via env.
15
- const MAX_CONCURRENT_SPAWNS = clampInt(process.env.AGENT_ROOMS_MAX_CONCURRENT_SPAWNS, 3, 1, 32);
16
- const MAX_SPAWNS_PER_MIN = clampInt(process.env.AGENT_ROOMS_MAX_SPAWNS_PER_MIN, 20, 1, 600);
16
+ // up model cost (#41).
17
+ //
18
+ // Concurrency is NOT a hard product limit on how many agents you can run — it's
19
+ // how many can spawn at the *same instant* on one machine; the rest just wait a
20
+ // beat (their mentions stay queued server-side, nothing is lost). A spawn is a
21
+ // heavy agent CLI that mostly waits on the model API, so the real limit is RAM +
22
+ // model cost, not CPU — but core count is a fair proxy for "how much machine you
23
+ // have". So the default now scales with the box (floor 4) instead of a fixed 3,
24
+ // and the env override goes much higher for beefy machines. The per-minute *rate*
25
+ // cap is the actual brake on a runaway loop, regardless of concurrency.
26
+ const CPU_COUNT = Math.max(1, cpus().length);
27
+ const DEFAULT_CONCURRENT_SPAWNS = Math.min(32, Math.max(4, CPU_COUNT));
28
+ const DEFAULT_SPAWNS_PER_MIN = Math.max(30, CPU_COUNT * 4);
29
+ const MAX_CONCURRENT_SPAWNS = clampInt(process.env.AGENT_ROOMS_MAX_CONCURRENT_SPAWNS, DEFAULT_CONCURRENT_SPAWNS, 1, 128);
30
+ const MAX_SPAWNS_PER_MIN = clampInt(process.env.AGENT_ROOMS_MAX_SPAWNS_PER_MIN, DEFAULT_SPAWNS_PER_MIN, 1, 1200);
17
31
  const RATE_WINDOW_MS = 60_000;
18
32
  // Spec 21: how long to back off after the model provider reports a hard rate-limit.
19
33
  const RATE_LIMIT_BACKOFF_MS = clampInt(process.env.AGENT_ROOMS_RATE_LIMIT_BACKOFF_MS, 30_000, 1000, 300_000);
@@ -341,11 +355,26 @@ export class ListenerRuntime {
341
355
  this.emit("warn", `Model provider rate-limited; backing off ${Math.round(waitMs / 1000)}s before waking ${wake.target}.`, "rate_limit_backoff");
342
356
  await new Promise((resolve) => setTimeout(resolve, waitMs));
343
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;
344
362
  // Register an abort handle so an owner stop can terminate this run mid-flight.
345
363
  const abort = new AbortController();
346
364
  this.trackSpawn(wake.target, abort);
347
365
  this.inFlightSpawns += 1;
348
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());
349
378
  // Spec 21: wakes run UNSANDBOXED on every host by default — it's the only mode
350
379
  // that actually does work (a contained agent can read + post but can't build,
351
380
  // edit, or run anything). This is independent of who/how the wake was triggered,
@@ -356,12 +385,21 @@ export class ListenerRuntime {
356
385
  const fullAccess = process.env.AGENT_ROOMS_WAKE_CONTAIN !== "1";
357
386
  let outcome;
358
387
  try {
359
- 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);
360
390
  }
361
391
  finally {
362
392
  this.inFlightSpawns -= 1;
363
393
  this.untrackSpawn(wake.target, abort);
364
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
+ }
365
403
  // Spec 21: if the run hit a hard rate-limit, set the cooldown so the next wake backs off.
366
404
  if (outcome.rateLimited) {
367
405
  this.rateLimitCooldownUntil = Date.now() + RATE_LIMIT_BACKOFF_MS;
@@ -370,8 +408,11 @@ export class ListenerRuntime {
370
408
  // wake in THIS room resumes THIS room's session — never another room's. OpenClaw
371
409
  // needs no stored id (its stable per-room session-key is the lock), so we only
372
410
  // record for resume-by-id hosts.
373
- if (outcome.sessionId && !isOpenClaw) {
374
- 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);
375
416
  saveConfig(next);
376
417
  this.config = next;
377
418
  }
@@ -407,12 +448,87 @@ export class ListenerRuntime {
407
448
  instance_id: instanceId,
408
449
  room: wake.room,
409
450
  status: reported,
410
- session_id: outcome.sessionId ?? undefined
451
+ session_id: settledSessionId ?? undefined
411
452
  }).catch((error) => this.emit("warn", `wake_result post failed: ${error.message}`, "wake_result_failed"));
412
453
  }
413
454
  this.emit(outcome.status === "error" ? "warn" : "info", `${adapter.name} wake finished with ${outcome.status}.`, "wake_finished");
414
455
  return { status: reported };
415
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
+ }
416
532
  trackSpawn(target, abort) {
417
533
  const set = this.running.get(target) ?? new Set();
418
534
  set.add(abort);
@@ -457,6 +573,10 @@ export class ListenerRuntime {
457
573
  if (exact)
458
574
  return exact;
459
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.
460
580
  return bindings.find((b) => b.agent === wake.target && b.rooms.includes(wake.room))
461
581
  ?? bindings.find((b) => b.agent === wake.target)
462
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.9.1",
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
  },