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/dist/skill.js CHANGED
@@ -1,39 +1,77 @@
1
- import { cpSync, existsSync, mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- export function bundledSkillDir() {
6
- const fromDist = fileURLToPath(new URL("../skill/agent-rooms", import.meta.url));
7
- if (existsSync(fromDist))
8
- return fromDist;
9
- // Source checkout fallback for `tsx src/cli.ts …`.
10
- return fileURLToPath(new URL("../../skill/agent-rooms", import.meta.url));
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
- export function installBundledSkill(host) {
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: false,
19
- destination: null,
20
- message: `Skill auto-install is not wired for ${host.name} yet.`
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
- const src = bundledSkillDir();
24
- if (!existsSync(src)) {
59
+ catch (error) {
25
60
  return {
26
61
  installed: false,
27
62
  destination: null,
28
- message: `Bundled skill not found at ${src}.`
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
- mkdirSync(host.skillDir, { recursive: true });
33
- cpSync(src, dest, { recursive: true });
34
- return {
35
- installed: true,
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. Speaks the daemon wire protocol: announce served agents with
3
- // `hello`, receive `wake` frames, answer `wake_ack` + `wake_result`, keepalive
4
- // with `ping`. Auto-reconnects with capped backoff. Real-time server->listener
5
- // wakes are the Tier-2 upgrade over pull-only MCP.
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
- constructor(opts) {
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 = new WebSocket(url, { headers: { authorization: `Bearer ${this.opts.token}` } });
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: "0.1.0", platform: this.opts.platform, agents: this.opts.agents });
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
- setTimeout(() => this.connect(), this.backoff);
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 "wake": {
78
- const wake = frame;
79
- const from = wake.hint?.from ?? "unknown";
80
- log.info(`wake - ${wake.target} in ${wake.room} (from ${from})`);
81
- this.opts.onActivity?.({ type: "wake", target: wake.target, room: wake.room, from });
82
- this.send({ t: "wake_ack", v: 1, id: wake.id, ...(wake.instance_id ? { instance_id: wake.instance_id } : {}), status: "spawning" });
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
- const result = await this.opts.onWake(wake);
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 Math.random().toString(36).slice(2, 10);
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
- // Graceful: interrupt first so the tool can flush its session, then escalate.
41
- try {
42
- child.kill("SIGINT");
43
- }
44
- catch { /* already gone */ }
45
- setTimeout(() => { try {
46
- child.kill("SIGTERM");
47
- }
48
- catch { /* gone */ } }, STOP_GRACE_MS).unref();
49
- setTimeout(() => { try {
50
- child.kill("SIGKILL");
51
- }
52
- catch { /* gone */ } }, STOP_GRACE_MS * 2).unref();
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
- // Escape hatches (functionality first): set AGENT_ROOMS_SPAWN_ENV_PASSTHROUGH to
67
- // a comma/space list of extra var names to allow, or AGENT_ROOMS_SPAWN_ENV_INHERIT=1
68
- // to inherit the full environment (debugging only defeats the control).
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_", "AGENT_ROOMS_", "LC_",
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 { command, args, cwd, stdin, env } = adapter.buildWake(spec);
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: process.platform === "win32"
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
- settled = true;
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
- resolve({ status: "timeout", sessionId: adapter.parseSessionId(buffer, errBuffer), code: null, durationMs: elapsed(), tokensIn: tokens.in, tokensOut: tokens.out, finalText: adapter.parseFinalText?.(buffer) ?? null, sawSendMessage, rateLimited });
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
- resolve({ status: "stopped", sessionId: adapter.parseSessionId(buffer, errBuffer), code: null, durationMs: elapsed(), tokensIn: tokens.in, tokensOut: tokens.out, finalText: adapter.parseFinalText?.(buffer) ?? null, sawSendMessage, rateLimited });
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
- errBuffer += chunk.toString("utf8");
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
- resolve({ status: "error", sessionId: null, code: null, durationMs: elapsed(), tokensIn: tokens.in, tokensOut: tokens.out });
446
+ finalize(terminalOverride ?? "error", null);
321
447
  });
322
448
  child.on("close", (code) => {
323
449
  if (settled)
324
450
  return;
325
- settled = true;
326
- clearTimeout(timer);
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
- resolve({ status, sessionId, code, durationMs: elapsed(), tokensIn: tokens.in, tokensOut: tokens.out, finalText: adapter.parseFinalText?.(buffer) ?? null, sawSendMessage, rateLimited });
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
+ }
@@ -0,0 +1,4 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+ const packageJson = require('../package.json');
4
+ export const PACKAGE_VERSION = packageJson.version ?? '0.0.0';