agent-rooms 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -3
- package/README.md +119 -140
- package/dist/api.js +21 -0
- package/dist/cli.js +2 -2
- package/dist/commands/doctor.js +68 -16
- package/dist/commands/init.js +94 -22
- package/dist/commands/start.js +15 -8
- package/dist/commands/status.js +29 -0
- package/dist/commands/uninstall.js +92 -15
- package/dist/commands/watch.js +32 -6
- package/dist/config.js +55 -2
- package/dist/envelope.js +83 -0
- package/dist/hosts.js +266 -93
- package/dist/listener.js +355 -194
- package/dist/normalize.js +31 -16
- package/dist/plugins.js +266 -0
- package/dist/probes.js +220 -0
- package/dist/skill.js +61 -23
- package/dist/socket.js +110 -18
- package/dist/spawn.js +185 -38
- package/dist/version.js +4 -0
- package/package.json +5 -4
- package/skill/agent-rooms/AGENTS.md +62 -43
- package/skill/agent-rooms/SKILL.md +201 -115
- package/skill/agent-rooms/references/etiquette.md +31 -20
- package/skill/agent-rooms/references/tools.md +780 -745
- package/skill/agent-rooms/references/troubleshooting.md +35 -25
- package/dist/bridge.js +0 -75
package/dist/skill.js
CHANGED
|
@@ -1,39 +1,77 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
// Spec 35 §13 — the skill is served as static files at a stable hosted URL
|
|
5
|
+
// (frontend prebuild publishes the package's skill folder to /skill/agent-rooms).
|
|
6
|
+
// The installer FETCHES from there so the on-disk copy is always the current
|
|
7
|
+
// hosted version, never a stale bundle pretending to be web-hosted (T13.3).
|
|
8
|
+
export const DEFAULT_SKILL_BASE_URL = "https://tryagentroom.com/skill/agent-rooms";
|
|
9
|
+
export const HOSTED_SKILL_MARKER = ".agent-rooms-hosted.json";
|
|
10
|
+
export function skillBaseUrl() {
|
|
11
|
+
return process.env.AGENT_ROOMS_SKILL_BASE_URL?.trim() || DEFAULT_SKILL_BASE_URL;
|
|
11
12
|
}
|
|
13
|
+
// The skill folder manifest, mirrored from frontend/scripts/bundle-skill.mjs.
|
|
14
|
+
export const SKILL_FILES = [
|
|
15
|
+
"SKILL.md",
|
|
16
|
+
"AGENTS.md",
|
|
17
|
+
"references/tools.md",
|
|
18
|
+
"references/etiquette.md",
|
|
19
|
+
"references/troubleshooting.md"
|
|
20
|
+
];
|
|
12
21
|
export function fallbackAgentRoomsSkillDir() {
|
|
13
22
|
return join(homedir(), ".agent-rooms", "skills", "agent-rooms");
|
|
14
23
|
}
|
|
15
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Spec 35 §13 (T13.3) — install the skill by FETCHING it from the hosted URL,
|
|
26
|
+
* so the on-disk copy is the current web-hosted version, not a stale bundle.
|
|
27
|
+
* A fetch failure leaves any prior install untouched and fails setup explicitly.
|
|
28
|
+
*/
|
|
29
|
+
export async function installHostedSkill(host, opts = {}) {
|
|
16
30
|
if (!host.skillDir) {
|
|
31
|
+
return { installed: false, destination: null, message: `Skill auto-install is not wired for ${host.name} yet.` };
|
|
32
|
+
}
|
|
33
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
34
|
+
const base = (opts.baseUrl ?? skillBaseUrl()).replace(/\/$/, "");
|
|
35
|
+
const dest = join(host.skillDir, "agent-rooms");
|
|
36
|
+
try {
|
|
37
|
+
const files = [];
|
|
38
|
+
for (const rel of SKILL_FILES) {
|
|
39
|
+
const res = await fetchImpl(`${base}/${rel}`, { method: "GET" });
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
throw new Error(`HTTP ${res.status} for ${rel}`);
|
|
42
|
+
files.push({ rel, body: await res.text() });
|
|
43
|
+
}
|
|
44
|
+
// Only write after every file fetched OK, so a partial fetch never leaves a
|
|
45
|
+
// half-installed skill on disk.
|
|
46
|
+
for (const { rel, body } of files) {
|
|
47
|
+
const target = join(dest, rel);
|
|
48
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
49
|
+
writeFileSync(target, body);
|
|
50
|
+
}
|
|
51
|
+
writeFileSync(join(dest, HOSTED_SKILL_MARKER), `${JSON.stringify({ source: base, files: SKILL_FILES }, null, 2)}\n`);
|
|
17
52
|
return {
|
|
18
|
-
installed:
|
|
19
|
-
destination:
|
|
20
|
-
|
|
53
|
+
installed: true,
|
|
54
|
+
destination: dest,
|
|
55
|
+
source: "hosted",
|
|
56
|
+
message: `Installed the agent-rooms skill for ${host.name} from ${base}.`
|
|
21
57
|
};
|
|
22
58
|
}
|
|
23
|
-
|
|
24
|
-
if (!existsSync(src)) {
|
|
59
|
+
catch (error) {
|
|
25
60
|
return {
|
|
26
61
|
installed: false,
|
|
27
62
|
destination: null,
|
|
28
|
-
message: `
|
|
63
|
+
message: `Could not install the hosted skill for ${host.name}: ${error.message}. Retry when ${base} is reachable.`
|
|
29
64
|
};
|
|
30
65
|
}
|
|
66
|
+
}
|
|
67
|
+
/** Remove only a skill installed by this hosted-fetch path. Human-managed skill
|
|
68
|
+
* directories without the ownership marker are never touched. */
|
|
69
|
+
export function removeHostedSkill(host) {
|
|
70
|
+
if (!host.skillDir)
|
|
71
|
+
return false;
|
|
31
72
|
const dest = join(host.skillDir, "agent-rooms");
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
destination: dest,
|
|
37
|
-
message: `Installed the agent-rooms skill for ${host.name}.`
|
|
38
|
-
};
|
|
73
|
+
if (!existsSync(join(dest, HOSTED_SKILL_MARKER)))
|
|
74
|
+
return false;
|
|
75
|
+
rmSync(dest, { recursive: true, force: true });
|
|
76
|
+
return true;
|
|
39
77
|
}
|
package/dist/socket.js
CHANGED
|
@@ -1,54 +1,82 @@
|
|
|
1
1
|
// Persistent WebSocket to the cloud PresenceHub (GET /mcp/daemon), authed with
|
|
2
|
-
// the device token.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// the device token. Spec 35 protocol v2: receives SIGNED `wake2` envelopes,
|
|
3
|
+
// verifies them against the pinned server key before acting (unsigned/tampered
|
|
4
|
+
// frames are dropped + reported, the model never spawns), and sends `wake_receipt`
|
|
5
|
+
// lifecycle frames (accepted on receipt, running at process start). Terminal
|
|
6
|
+
// handled/failed receipts ride the durable HTTP wake_result report instead.
|
|
6
7
|
import WebSocket from "ws";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { verifyWakeFrame } from "./envelope.js";
|
|
7
10
|
import { log } from "./log.js";
|
|
11
|
+
import { PACKAGE_VERSION } from "./version.js";
|
|
8
12
|
const PING_MS = 30_000;
|
|
9
13
|
const MAX_BACKOFF_MS = 30_000;
|
|
14
|
+
const KEY_REFRESH_COOLDOWN_MS = 60_000;
|
|
10
15
|
export class DaemonSocket {
|
|
11
16
|
opts;
|
|
17
|
+
createSocket;
|
|
12
18
|
ws = null;
|
|
13
19
|
backoff = 1000;
|
|
14
20
|
pingTimer = null;
|
|
21
|
+
reconnectTimer = null;
|
|
15
22
|
closed = false;
|
|
16
|
-
|
|
23
|
+
lastKeyRefreshAt = 0;
|
|
24
|
+
keyRefreshInFlight = null;
|
|
25
|
+
constructor(opts, createSocket = (url, options) => new WebSocket(url, options)) {
|
|
17
26
|
this.opts = opts;
|
|
27
|
+
this.createSocket = createSocket;
|
|
18
28
|
}
|
|
19
29
|
wsUrl() {
|
|
20
30
|
return `${this.opts.apiBase.replace(/^http/, "ws")}/mcp/daemon`;
|
|
21
31
|
}
|
|
22
32
|
start() {
|
|
33
|
+
this.closed = false;
|
|
23
34
|
this.connect();
|
|
24
35
|
}
|
|
25
36
|
stop() {
|
|
26
37
|
this.closed = true;
|
|
27
38
|
if (this.pingTimer)
|
|
28
39
|
clearInterval(this.pingTimer);
|
|
40
|
+
if (this.reconnectTimer)
|
|
41
|
+
clearTimeout(this.reconnectTimer);
|
|
42
|
+
this.pingTimer = null;
|
|
43
|
+
this.reconnectTimer = null;
|
|
29
44
|
this.ws?.close();
|
|
45
|
+
this.ws = null;
|
|
30
46
|
}
|
|
31
47
|
connect() {
|
|
48
|
+
if (this.closed)
|
|
49
|
+
return;
|
|
32
50
|
const url = this.wsUrl();
|
|
33
51
|
log.info(`connecting to ${url} ...`);
|
|
34
52
|
this.opts.onActivity?.({ type: "connecting", url });
|
|
35
|
-
const ws =
|
|
53
|
+
const ws = this.createSocket(url, { headers: { authorization: `Bearer ${this.opts.token}` } });
|
|
36
54
|
this.ws = ws;
|
|
37
55
|
ws.on("open", () => {
|
|
38
56
|
this.backoff = 1000;
|
|
39
57
|
this.opts.onActivity?.({ type: "open" });
|
|
40
|
-
this.send({ t: "hello", v: 1, daemon_version:
|
|
58
|
+
this.send({ t: "hello", v: 1, daemon_version: PACKAGE_VERSION, platform: this.opts.platform, agents: this.opts.agents });
|
|
59
|
+
if (this.pingTimer)
|
|
60
|
+
clearInterval(this.pingTimer);
|
|
41
61
|
this.pingTimer = setInterval(() => this.send({ t: "ping", v: 1, id: rid() }), PING_MS);
|
|
42
62
|
});
|
|
43
63
|
ws.on("message", (data) => void this.onMessage(String(data)));
|
|
44
64
|
ws.on("close", (code) => {
|
|
45
65
|
if (this.pingTimer)
|
|
46
66
|
clearInterval(this.pingTimer);
|
|
67
|
+
this.pingTimer = null;
|
|
68
|
+
if (this.ws === ws)
|
|
69
|
+
this.ws = null;
|
|
47
70
|
if (this.closed)
|
|
48
71
|
return;
|
|
49
72
|
log.warn(`socket closed (${code}); reconnecting in ${Math.round(this.backoff / 1000)}s`);
|
|
50
73
|
this.opts.onActivity?.({ type: "close", code, retryMs: this.backoff });
|
|
51
|
-
|
|
74
|
+
if (this.reconnectTimer)
|
|
75
|
+
clearTimeout(this.reconnectTimer);
|
|
76
|
+
this.reconnectTimer = setTimeout(() => {
|
|
77
|
+
this.reconnectTimer = null;
|
|
78
|
+
this.connect();
|
|
79
|
+
}, this.backoff);
|
|
52
80
|
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
53
81
|
});
|
|
54
82
|
ws.on("error", (err) => {
|
|
@@ -61,6 +89,35 @@ export class DaemonSocket {
|
|
|
61
89
|
this.ws.send(JSON.stringify(frame));
|
|
62
90
|
}
|
|
63
91
|
}
|
|
92
|
+
/** Bound refresh work under forged/unknown-kid traffic. All frames arriving
|
|
93
|
+
* in one cooldown window share at most one JWKS request. */
|
|
94
|
+
async refreshKeysOnce() {
|
|
95
|
+
if (!this.opts.refreshVerifyKeys)
|
|
96
|
+
return null;
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
if (!this.keyRefreshInFlight && now - this.lastKeyRefreshAt < KEY_REFRESH_COOLDOWN_MS)
|
|
99
|
+
return null;
|
|
100
|
+
if (!this.keyRefreshInFlight) {
|
|
101
|
+
this.lastKeyRefreshAt = now;
|
|
102
|
+
this.keyRefreshInFlight = this.opts.refreshVerifyKeys().finally(() => {
|
|
103
|
+
this.keyRefreshInFlight = null;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const refreshed = await this.keyRefreshInFlight;
|
|
108
|
+
this.opts.verifyKeys = refreshed;
|
|
109
|
+
return refreshed;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
log.warn(`server key refresh failed: ${error.message}`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Spec 35 §4: lifecycle receipt for a wake — accepted on receipt, running
|
|
117
|
+
* when the harness process actually starts. */
|
|
118
|
+
sendReceipt(wakeId, instanceId, phase) {
|
|
119
|
+
this.send({ t: "wake_receipt", v: 2, id: wakeId, instance_id: instanceId, phase });
|
|
120
|
+
}
|
|
64
121
|
async onMessage(raw) {
|
|
65
122
|
let frame;
|
|
66
123
|
try {
|
|
@@ -74,22 +131,57 @@ export class DaemonSocket {
|
|
|
74
131
|
log.info(`connected - serving ${this.opts.agents.length} agent(s): ${this.opts.agents.join(", ") || "(none)"}`);
|
|
75
132
|
this.opts.onActivity?.({ type: "welcome", agents: this.opts.agents });
|
|
76
133
|
break;
|
|
77
|
-
case "
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
134
|
+
case "wake2": {
|
|
135
|
+
// Spec 35 §3: verify BEFORE acting. Unsigned/tampered/unparseable =
|
|
136
|
+
// dropped + reported; the model is never spawned (T3.2).
|
|
137
|
+
const raw = frame;
|
|
138
|
+
let key = typeof raw.kid === "string" ? this.opts.verifyKeys.get(raw.kid) : undefined;
|
|
139
|
+
let envelope = key && typeof raw.e === "string" && typeof raw.sig === "string"
|
|
140
|
+
? verifyWakeFrame(key, { e: raw.e, sig: raw.sig })
|
|
141
|
+
: null;
|
|
142
|
+
if (!envelope && typeof raw.kid === "string") {
|
|
143
|
+
const refreshed = await this.refreshKeysOnce();
|
|
144
|
+
if (refreshed) {
|
|
145
|
+
key = refreshed.get(raw.kid);
|
|
146
|
+
envelope = key && typeof raw.e === "string" && typeof raw.sig === "string"
|
|
147
|
+
? verifyWakeFrame(key, { e: raw.e, sig: raw.sig })
|
|
148
|
+
: null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!envelope) {
|
|
152
|
+
log.error(`DROPPED wake frame ${raw.id ?? "?"}: envelope verification failed (unsigned/tampered).`);
|
|
153
|
+
this.opts.onActivity?.({ type: "envelope_rejected", wakeId: raw.id ?? "" });
|
|
154
|
+
this.opts.onEnvelopeRejected?.(raw.id ?? "");
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
const from = envelope.sender.plate ?? envelope.sender.name ?? "unknown";
|
|
158
|
+
log.info(`wake - ${envelope.target} in ${envelope.room_id} (${envelope.event_kind} from ${from})`);
|
|
159
|
+
this.opts.onActivity?.({ type: "wake", target: envelope.target, room: envelope.room_id, from });
|
|
83
160
|
try {
|
|
84
|
-
|
|
85
|
-
this.send({ t: "wake_result", v: 1, id: wake.id, ...(wake.instance_id ? { instance_id: wake.instance_id, room: wake.room } : {}), status: result.status });
|
|
161
|
+
await this.opts.onWake(envelope);
|
|
86
162
|
}
|
|
87
163
|
catch (err) {
|
|
88
164
|
log.error(`wake handler threw: ${err.message}`);
|
|
89
|
-
this.send({ t: "wake_result", v: 1, id: wake.id, ...(wake.instance_id ? { instance_id: wake.instance_id, room: wake.room } : {}), status: "error" });
|
|
90
165
|
}
|
|
91
166
|
break;
|
|
92
167
|
}
|
|
168
|
+
case "wake":
|
|
169
|
+
// Legacy v1 wake: a v2 server never sends this to a v2 listener.
|
|
170
|
+
log.warn(`ignoring legacy v1 wake frame (server should speak protocol v2).`);
|
|
171
|
+
break;
|
|
172
|
+
case "error": {
|
|
173
|
+
const code = String(frame.code ?? "");
|
|
174
|
+
const message = String(frame.message ?? "");
|
|
175
|
+
if (code === "upgrade_required") {
|
|
176
|
+
log.error(message || "Server refused this listener version; update agent-rooms.");
|
|
177
|
+
this.opts.onActivity?.({ type: "upgrade_required", message });
|
|
178
|
+
this.stop();
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
log.error(`server error frame: ${code} ${message}`);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
93
185
|
case "stop": {
|
|
94
186
|
const target = String(frame.target);
|
|
95
187
|
log.warn(`stop requested for ${target} (${String(frame.reason)})`);
|
|
@@ -113,5 +205,5 @@ export class DaemonSocket {
|
|
|
113
205
|
}
|
|
114
206
|
}
|
|
115
207
|
function rid() {
|
|
116
|
-
return
|
|
208
|
+
return randomUUID();
|
|
117
209
|
}
|
package/dist/spawn.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// raw stream-json is NOT echoed to the terminal — we parse it and print a few
|
|
4
4
|
// clean progress lines instead (set AGENT_ROOMS_DEBUG=1 to see the raw stream).
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
9
|
+
import { emptyUsage } from "./hosts.js";
|
|
6
10
|
import { log } from "./log.js";
|
|
7
11
|
// Terminate a spawned agent and the whole process tree it launched. This is the
|
|
8
12
|
// teeth behind "stop your agent": an owner's stop must end the real OS process,
|
|
@@ -37,19 +41,22 @@ function terminateChild(child) {
|
|
|
37
41
|
catch { /* best-effort */ }
|
|
38
42
|
return;
|
|
39
43
|
}
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
// POSIX children are process-group leaders. Signal the negative pid so their
|
|
45
|
+
// shells, MCP helpers, and model subprocesses cannot outlive an owner stop.
|
|
46
|
+
const signalTree = (signal) => {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(-pid, signal);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
try {
|
|
52
|
+
child.kill(signal);
|
|
53
|
+
}
|
|
54
|
+
catch { /* already gone */ }
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
signalTree("SIGINT");
|
|
58
|
+
setTimeout(() => signalTree("SIGTERM"), STOP_GRACE_MS).unref();
|
|
59
|
+
setTimeout(() => signalTree("SIGKILL"), STOP_GRACE_MS * 2).unref();
|
|
53
60
|
}
|
|
54
61
|
const DEBUG = process.env.AGENT_ROOMS_DEBUG === "1";
|
|
55
62
|
// --- Tier-0 §3.2: environment scrubbing -------------------------------------
|
|
@@ -63,9 +70,10 @@ const DEBUG = process.env.AGENT_ROOMS_DEBUG === "1";
|
|
|
63
70
|
// #21/#29 exfil). Functionality preserved: the agent keeps its model login, its
|
|
64
71
|
// room tools, and its task inputs — it just can't see your infra credentials.
|
|
65
72
|
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
73
|
+
// Operators may explicitly pass through named task variables with
|
|
74
|
+
// AGENT_ROOMS_SPAWN_ENV_PASSTHROUGH. There is deliberately no "inherit all"
|
|
75
|
+
// switch: envelope input can be cross-owner, so credential scrubbing must not be
|
|
76
|
+
// disabled at runtime.
|
|
69
77
|
const ENV_ALLOW_EXACT = new Set([
|
|
70
78
|
// process + shell runtime essentials (without these, spawning breaks)
|
|
71
79
|
"PATH", "PATHEXT", "HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH",
|
|
@@ -82,13 +90,10 @@ const ENV_ALLOW_EXACT = new Set([
|
|
|
82
90
|
// passed (the agent must be able to call its model and read its own config), and
|
|
83
91
|
// nothing here is infra/cloud credentials.
|
|
84
92
|
const ENV_ALLOW_PREFIX = [
|
|
85
|
-
"ANTHROPIC_", "CLAUDE_", "OPENAI_", "HERMES_", "XDG_", "
|
|
93
|
+
"ANTHROPIC_", "CLAUDE_", "OPENAI_", "HERMES_", "XDG_", "LC_",
|
|
86
94
|
].map((s) => s.toUpperCase());
|
|
87
95
|
/** Build the minimal, explicit environment for a spawned agent (§3.2). */
|
|
88
96
|
export function buildSpawnEnv(extra, source = process.env) {
|
|
89
|
-
if (source.AGENT_ROOMS_SPAWN_ENV_INHERIT === "1") {
|
|
90
|
-
return { ...source, ...(extra ?? {}) };
|
|
91
|
-
}
|
|
92
97
|
const passthrough = new Set((source.AGENT_ROOMS_SPAWN_ENV_PASSTHROUGH ?? "")
|
|
93
98
|
.split(/[,\s]+/)
|
|
94
99
|
.map((n) => n.trim().toUpperCase())
|
|
@@ -113,6 +118,18 @@ const TOOL_LABEL = {
|
|
|
113
118
|
"mcp__agent-rooms__send_message": "posting a reply",
|
|
114
119
|
"mcp__agent-rooms__ack_mentions": "clearing the mention"
|
|
115
120
|
};
|
|
121
|
+
function toNativeUsage(acc) {
|
|
122
|
+
return {
|
|
123
|
+
uncached_input: acc.uncached_input,
|
|
124
|
+
cache_read: acc.cache_read,
|
|
125
|
+
cache_write: acc.cache_write,
|
|
126
|
+
cache_write_5m: acc.cache_write_5m,
|
|
127
|
+
cache_write_1h: acc.cache_write_1h,
|
|
128
|
+
reasoning: acc.reasoning,
|
|
129
|
+
output: acc.output,
|
|
130
|
+
attempts: acc.attempts
|
|
131
|
+
};
|
|
132
|
+
}
|
|
116
133
|
/** A stream line that signals a hard rate-limit (Claude `rate_limit_event` with a
|
|
117
134
|
* non-allowed / rejected status). Used to back off, never to cap usage. */
|
|
118
135
|
function lineIsRateLimited(line) {
|
|
@@ -211,7 +228,54 @@ const DEFAULT_WAKE_TIMEOUT_MS = (() => {
|
|
|
211
228
|
return Number.isFinite(n) && n > 0 ? n : 60 * 60 * 1000;
|
|
212
229
|
})();
|
|
213
230
|
export function runWake(adapter, spec, timeoutMs = DEFAULT_WAKE_TIMEOUT_MS, signal, onStreamLine) {
|
|
214
|
-
const
|
|
231
|
+
const built = adapter.buildWake(spec);
|
|
232
|
+
const { cwd, stdin, env } = built;
|
|
233
|
+
let command = built.command;
|
|
234
|
+
let args = [...built.args];
|
|
235
|
+
let promptTempDir = null;
|
|
236
|
+
if (built.promptFile) {
|
|
237
|
+
promptTempDir = mkdtempSync(join(tmpdir(), "agent-rooms-wake-"));
|
|
238
|
+
const promptPath = join(promptTempDir, "prompt.txt");
|
|
239
|
+
writeFileSync(promptPath, built.promptFile.contents, { encoding: "utf8", mode: 0o600 });
|
|
240
|
+
args = args.map((arg) => arg === built.promptFile.placeholder ? promptPath : arg);
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
({ command, args } = resolveSpawnCommand(command, args));
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (promptTempDir)
|
|
247
|
+
rmSync(promptTempDir, { recursive: true, force: true });
|
|
248
|
+
return Promise.resolve({ status: "error", sessionId: null, code: null, stderrTail: error.message });
|
|
249
|
+
}
|
|
250
|
+
if (adapter.buildPreflight) {
|
|
251
|
+
const check = adapter.buildPreflight();
|
|
252
|
+
let executable;
|
|
253
|
+
try {
|
|
254
|
+
executable = resolveSpawnCommand(check.command, check.args);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (promptTempDir)
|
|
258
|
+
rmSync(promptTempDir, { recursive: true, force: true });
|
|
259
|
+
return Promise.resolve({ status: "error", sessionId: null, code: null, stderrTail: error.message });
|
|
260
|
+
}
|
|
261
|
+
const probe = spawnSync(executable.command, executable.args, {
|
|
262
|
+
cwd: check.cwd, env: buildSpawnEnv(check.env), encoding: "utf8", timeout: 12_000,
|
|
263
|
+
shell: false, windowsHide: true
|
|
264
|
+
});
|
|
265
|
+
let healthy = probe.status === 0;
|
|
266
|
+
try {
|
|
267
|
+
healthy = healthy && JSON.parse(String(probe.stdout ?? "")).ok === true;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
healthy = false;
|
|
271
|
+
}
|
|
272
|
+
if (!healthy) {
|
|
273
|
+
if (promptTempDir)
|
|
274
|
+
rmSync(promptTempDir, { recursive: true, force: true });
|
|
275
|
+
const detail = String(probe.stderr || probe.stdout || probe.error?.message || "Gateway health probe failed").slice(-4000);
|
|
276
|
+
return Promise.resolve({ status: "error", sessionId: null, code: probe.status, stderrTail: `OpenClaw Gateway is required and unhealthy: ${detail}` });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
215
279
|
log.info(`spawn ${adapter.name}: ${command} ${args.filter((a) => !a.includes("\n")).slice(0, 6).join(" ")}… (cwd ${cwd})`);
|
|
216
280
|
const startedAt = Date.now();
|
|
217
281
|
return new Promise((resolve) => {
|
|
@@ -224,11 +288,56 @@ export function runWake(adapter, spec, timeoutMs = DEFAULT_WAKE_TIMEOUT_MS, sign
|
|
|
224
288
|
let errBuffer = "";
|
|
225
289
|
let pending = "";
|
|
226
290
|
let settled = false;
|
|
291
|
+
let terminalOverride = null;
|
|
292
|
+
let killFallback = null;
|
|
293
|
+
let capturedSessionId = null;
|
|
227
294
|
let sawSendMessage = false;
|
|
228
295
|
let rateLimited = false;
|
|
229
296
|
const tokens = { in: 0, out: 0 };
|
|
297
|
+
const usage = emptyUsage();
|
|
298
|
+
const errTail = () => errBuffer.slice(-4000);
|
|
230
299
|
const secs = () => Math.round((Date.now() - startedAt) / 1000);
|
|
231
300
|
const elapsed = () => Date.now() - startedAt;
|
|
301
|
+
const currentSessionId = () => capturedSessionId ?? adapter.parseSessionId(buffer, errBuffer);
|
|
302
|
+
const stopForbiddenFallback = () => {
|
|
303
|
+
if (terminalOverride || !adapter.forbiddenOutputPattern?.test(`${errBuffer}\n${buffer}`))
|
|
304
|
+
return;
|
|
305
|
+
terminalOverride = "error";
|
|
306
|
+
log.error(`${adapter.name} entered a forbidden execution fallback — terminating`);
|
|
307
|
+
terminateChild(child);
|
|
308
|
+
};
|
|
309
|
+
const cleanupPromptFile = () => {
|
|
310
|
+
if (!promptTempDir)
|
|
311
|
+
return;
|
|
312
|
+
try {
|
|
313
|
+
rmSync(promptTempDir, { recursive: true, force: true });
|
|
314
|
+
}
|
|
315
|
+
catch { /* best effort */ }
|
|
316
|
+
promptTempDir = null;
|
|
317
|
+
};
|
|
318
|
+
const finalize = (status, code) => {
|
|
319
|
+
if (settled)
|
|
320
|
+
return;
|
|
321
|
+
settled = true;
|
|
322
|
+
clearTimeout(timer);
|
|
323
|
+
if (killFallback)
|
|
324
|
+
clearTimeout(killFallback);
|
|
325
|
+
signal?.removeEventListener("abort", onAbort);
|
|
326
|
+
cleanupPromptFile();
|
|
327
|
+
resolve({
|
|
328
|
+
status,
|
|
329
|
+
sessionId: currentSessionId(),
|
|
330
|
+
code,
|
|
331
|
+
durationMs: elapsed(),
|
|
332
|
+
tokensIn: tokens.in,
|
|
333
|
+
tokensOut: tokens.out,
|
|
334
|
+
finalText: adapter.parseFinalText?.(buffer) ?? null,
|
|
335
|
+
sawSendMessage,
|
|
336
|
+
rateLimited,
|
|
337
|
+
usage: adapter.parseUsageOutput?.(buffer, errBuffer) ?? (adapter.parseUsageLine ? toNativeUsage(usage) : undefined),
|
|
338
|
+
stderrTail: errTail()
|
|
339
|
+
});
|
|
340
|
+
};
|
|
232
341
|
// When the prompt rides stdin, open stdin as a pipe; otherwise ignore it.
|
|
233
342
|
// §3.2: spawn with a scrubbed, allowlisted environment — never the listener's
|
|
234
343
|
// full secret-bearing process.env.
|
|
@@ -236,7 +345,9 @@ export function runWake(adapter, spec, timeoutMs = DEFAULT_WAKE_TIMEOUT_MS, sign
|
|
|
236
345
|
cwd,
|
|
237
346
|
env: buildSpawnEnv(env),
|
|
238
347
|
stdio: [stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
|
239
|
-
shell:
|
|
348
|
+
shell: false,
|
|
349
|
+
detached: true,
|
|
350
|
+
windowsHide: true
|
|
240
351
|
});
|
|
241
352
|
if (stdin != null && child.stdin) {
|
|
242
353
|
child.stdin.write(stdin);
|
|
@@ -245,11 +356,11 @@ export function runWake(adapter, spec, timeoutMs = DEFAULT_WAKE_TIMEOUT_MS, sign
|
|
|
245
356
|
const timer = setTimeout(() => {
|
|
246
357
|
if (settled)
|
|
247
358
|
return;
|
|
248
|
-
|
|
249
|
-
signal?.removeEventListener("abort", onAbort);
|
|
359
|
+
terminalOverride = "timeout";
|
|
250
360
|
log.warn(`${adapter.name} wake timed out after ${Math.round(timeoutMs / 1000)}s — killing`);
|
|
251
361
|
terminateChild(child);
|
|
252
|
-
|
|
362
|
+
killFallback = setTimeout(() => finalize("timeout", null), STOP_GRACE_MS * 2 + 2000);
|
|
363
|
+
killFallback.unref();
|
|
253
364
|
}, timeoutMs);
|
|
254
365
|
// Owner stop (a stop frame routed from the backend) aborts the signal; we end
|
|
255
366
|
// the real process tree, not just the run. This is the "stop your agent,
|
|
@@ -257,15 +368,17 @@ export function runWake(adapter, spec, timeoutMs = DEFAULT_WAKE_TIMEOUT_MS, sign
|
|
|
257
368
|
function onAbort() {
|
|
258
369
|
if (settled)
|
|
259
370
|
return;
|
|
260
|
-
settled = true;
|
|
261
371
|
clearTimeout(timer);
|
|
372
|
+
terminalOverride = "stopped";
|
|
262
373
|
log.warn(`${adapter.name} stopped by owner — terminating`);
|
|
263
374
|
terminateChild(child);
|
|
264
|
-
|
|
375
|
+
killFallback = setTimeout(() => finalize("stopped", null), STOP_GRACE_MS * 2 + 2000);
|
|
376
|
+
killFallback.unref();
|
|
265
377
|
}
|
|
266
378
|
if (signal) {
|
|
267
379
|
if (signal.aborted) {
|
|
268
380
|
onAbort();
|
|
381
|
+
finalize("stopped", null);
|
|
269
382
|
return;
|
|
270
383
|
}
|
|
271
384
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
@@ -275,6 +388,7 @@ export function runWake(adapter, spec, timeoutMs = DEFAULT_WAKE_TIMEOUT_MS, sign
|
|
|
275
388
|
buffer += text;
|
|
276
389
|
if (buffer.length > 2_000_000)
|
|
277
390
|
buffer = buffer.slice(-1_000_000);
|
|
391
|
+
stopForbiddenFallback();
|
|
278
392
|
if (DEBUG)
|
|
279
393
|
process.stdout.write(text);
|
|
280
394
|
// Parse complete JSON lines for token usage (both modes) and, when not in
|
|
@@ -286,7 +400,17 @@ export function runWake(adapter, spec, timeoutMs = DEFAULT_WAKE_TIMEOUT_MS, sign
|
|
|
286
400
|
pending = pending.slice(nl + 1);
|
|
287
401
|
if (!line)
|
|
288
402
|
continue;
|
|
403
|
+
const lineSessionId = adapter.parseSessionId(line, undefined);
|
|
404
|
+
if (lineSessionId)
|
|
405
|
+
capturedSessionId = lineSessionId;
|
|
289
406
|
captureUsage(line, tokens);
|
|
407
|
+
if (adapter.parseUsageLine) {
|
|
408
|
+
try {
|
|
409
|
+
const obj = JSON.parse(line);
|
|
410
|
+
adapter.parseUsageLine(obj, usage);
|
|
411
|
+
}
|
|
412
|
+
catch { /* non-JSON progress line */ }
|
|
413
|
+
}
|
|
290
414
|
if (!sawSendMessage && lineUsesSendMessage(line))
|
|
291
415
|
sawSendMessage = true;
|
|
292
416
|
if (!rateLimited && lineIsRateLimited(line))
|
|
@@ -304,34 +428,57 @@ export function runWake(adapter, spec, timeoutMs = DEFAULT_WAKE_TIMEOUT_MS, sign
|
|
|
304
428
|
}
|
|
305
429
|
});
|
|
306
430
|
child.stderr?.on("data", (chunk) => {
|
|
307
|
-
|
|
431
|
+
const text = chunk.toString("utf8");
|
|
432
|
+
errBuffer += text;
|
|
308
433
|
if (errBuffer.length > 200_000)
|
|
309
434
|
errBuffer = errBuffer.slice(-100_000);
|
|
435
|
+
stopForbiddenFallback();
|
|
436
|
+
const stderrSessionId = adapter.parseSessionId("", errBuffer);
|
|
437
|
+
if (stderrSessionId)
|
|
438
|
+
capturedSessionId = stderrSessionId;
|
|
310
439
|
if (DEBUG)
|
|
311
440
|
process.stderr.write(chunk);
|
|
312
441
|
});
|
|
313
442
|
child.on("error", (err) => {
|
|
314
443
|
if (settled)
|
|
315
444
|
return;
|
|
316
|
-
settled = true;
|
|
317
|
-
clearTimeout(timer);
|
|
318
|
-
signal?.removeEventListener("abort", onAbort);
|
|
319
445
|
log.error(`${adapter.name} failed to spawn: ${err.message} (is "${command}" on PATH?)`);
|
|
320
|
-
|
|
446
|
+
finalize(terminalOverride ?? "error", null);
|
|
321
447
|
});
|
|
322
448
|
child.on("close", (code) => {
|
|
323
449
|
if (settled)
|
|
324
450
|
return;
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
signal?.removeEventListener("abort", onAbort);
|
|
328
|
-
const sessionId = adapter.parseSessionId(buffer, errBuffer);
|
|
329
|
-
const status = code === 0 ? "replied" : "error";
|
|
451
|
+
stopForbiddenFallback();
|
|
452
|
+
const status = terminalOverride ?? (code === 0 ? "replied" : "error");
|
|
330
453
|
if (status === "replied")
|
|
331
454
|
log.info(`${adapter.name} done (${secs()}s)`);
|
|
332
455
|
else
|
|
333
456
|
log.warn(`${adapter.name} exited ${code} (${secs()}s)`);
|
|
334
|
-
|
|
457
|
+
finalize(status, code);
|
|
335
458
|
});
|
|
336
459
|
});
|
|
337
460
|
}
|
|
461
|
+
/** Resolve Windows npm shims to node + the actual JS entrypoint, so wake data
|
|
462
|
+
* never crosses cmd.exe. Native executables are preferred. POSIX is unchanged. */
|
|
463
|
+
export function resolveSpawnCommand(command, args) {
|
|
464
|
+
if (process.platform !== "win32")
|
|
465
|
+
return { command, args };
|
|
466
|
+
const candidates = isAbsolute(command)
|
|
467
|
+
? [command]
|
|
468
|
+
: String(spawnSync("where.exe", [command], { encoding: "utf8", shell: false, windowsHide: true }).stdout ?? "")
|
|
469
|
+
.split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
|
|
470
|
+
const native = candidates.find((candidate) => [".exe", ".com"].includes(extname(candidate).toLowerCase()));
|
|
471
|
+
if (native)
|
|
472
|
+
return { command: native, args };
|
|
473
|
+
const shim = candidates.find((candidate) => [".cmd", ".bat"].includes(extname(candidate).toLowerCase()));
|
|
474
|
+
if (shim && existsSync(shim)) {
|
|
475
|
+
const text = readFileSync(shim, "utf8");
|
|
476
|
+
const match = /%dp0%[\\/]([^"\r\n]+\.(?:mjs|cjs|js))/i.exec(text);
|
|
477
|
+
if (match?.[1]) {
|
|
478
|
+
const entry = resolve(dirname(shim), match[1].replace(/[\\/]/g, "\\"));
|
|
479
|
+
if (existsSync(entry))
|
|
480
|
+
return { command: process.execPath, args: [entry, ...args] };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
throw new Error(`Cannot safely execute '${command}' on Windows without cmd.exe. Install a native executable or a standard npm node shim.`);
|
|
484
|
+
}
|
package/dist/version.js
ADDED