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/CHANGELOG.md +77 -67
- 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 +128 -8
- 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 -321
- package/skill/agent-rooms/references/tools.md +20 -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 {
|
|
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).
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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:
|
|
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.
|
|
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
|
},
|