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/CHANGELOG.md +77 -77
- package/LICENSE +42 -42
- package/README.md +140 -140
- package/dist/cli.js +26 -26
- package/dist/hosts.js +5 -0
- package/dist/listener.js +112 -4
- package/dist/normalize.js +269 -0
- package/dist/spawn.js +9 -1
- package/package.json +2 -2
- package/skill/agent-rooms/AGENTS.md +115 -115
- package/skill/agent-rooms/SKILL.md +384 -342
- package/skill/agent-rooms/references/tools.md +10 -3
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
|
-
|
|
386
|
-
|
|
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:
|
|
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.
|
|
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
|
},
|