@botcord/daemon 0.2.5 → 0.2.8

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.
Files changed (88) hide show
  1. package/dist/agent-discovery.d.ts +4 -0
  2. package/dist/agent-discovery.js +8 -0
  3. package/dist/agent-workspace.d.ts +62 -0
  4. package/dist/agent-workspace.js +140 -8
  5. package/dist/config.d.ts +64 -1
  6. package/dist/config.js +73 -1
  7. package/dist/daemon-config-map.d.ts +27 -9
  8. package/dist/daemon-config-map.js +105 -8
  9. package/dist/daemon.d.ts +2 -0
  10. package/dist/daemon.js +76 -6
  11. package/dist/doctor.d.ts +27 -1
  12. package/dist/doctor.js +22 -1
  13. package/dist/gateway/cli-resolver.d.ts +34 -0
  14. package/dist/gateway/cli-resolver.js +74 -0
  15. package/dist/gateway/dispatcher.d.ts +31 -1
  16. package/dist/gateway/dispatcher.js +337 -29
  17. package/dist/gateway/gateway.d.ts +29 -1
  18. package/dist/gateway/gateway.js +10 -0
  19. package/dist/gateway/index.d.ts +2 -0
  20. package/dist/gateway/index.js +2 -0
  21. package/dist/gateway/policy-resolver.d.ts +57 -0
  22. package/dist/gateway/policy-resolver.js +123 -0
  23. package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
  24. package/dist/gateway/runtimes/acp-stream.js +394 -0
  25. package/dist/gateway/runtimes/codex.js +7 -0
  26. package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
  27. package/dist/gateway/runtimes/hermes-agent.js +180 -0
  28. package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
  29. package/dist/gateway/runtimes/ndjson-stream.js +16 -3
  30. package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
  31. package/dist/gateway/runtimes/openclaw-acp.js +500 -0
  32. package/dist/gateway/runtimes/registry.d.ts +4 -0
  33. package/dist/gateway/runtimes/registry.js +22 -0
  34. package/dist/gateway/transcript-paths.d.ts +30 -0
  35. package/dist/gateway/transcript-paths.js +114 -0
  36. package/dist/gateway/transcript.d.ts +123 -0
  37. package/dist/gateway/transcript.js +147 -0
  38. package/dist/gateway/types.d.ts +31 -0
  39. package/dist/index.js +309 -27
  40. package/dist/mention-scan.d.ts +22 -0
  41. package/dist/mention-scan.js +35 -0
  42. package/dist/openclaw-discovery.d.ts +28 -0
  43. package/dist/openclaw-discovery.js +228 -0
  44. package/dist/provision.d.ts +113 -1
  45. package/dist/provision.js +564 -12
  46. package/dist/system-context.d.ts +5 -4
  47. package/dist/system-context.js +35 -5
  48. package/dist/url-utils.d.ts +9 -0
  49. package/dist/url-utils.js +18 -0
  50. package/package.json +3 -2
  51. package/src/__tests__/agent-workspace.test.ts +93 -0
  52. package/src/__tests__/daemon-config-map.test.ts +79 -0
  53. package/src/__tests__/openclaw-acp.test.ts +234 -0
  54. package/src/__tests__/openclaw-discovery.test.ts +150 -0
  55. package/src/__tests__/policy-resolver.test.ts +124 -0
  56. package/src/__tests__/policy-updated-handler.test.ts +144 -0
  57. package/src/__tests__/provision.test.ts +265 -0
  58. package/src/__tests__/system-context.test.ts +52 -0
  59. package/src/__tests__/url-utils.test.ts +37 -0
  60. package/src/agent-discovery.ts +8 -0
  61. package/src/agent-workspace.ts +173 -7
  62. package/src/config.ts +168 -4
  63. package/src/daemon-config-map.ts +154 -9
  64. package/src/daemon.ts +96 -6
  65. package/src/doctor.ts +49 -2
  66. package/src/gateway/__tests__/dispatcher.test.ts +65 -0
  67. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
  68. package/src/gateway/__tests__/transcript.test.ts +496 -0
  69. package/src/gateway/cli-resolver.ts +92 -0
  70. package/src/gateway/dispatcher.ts +394 -26
  71. package/src/gateway/gateway.ts +46 -0
  72. package/src/gateway/index.ts +25 -0
  73. package/src/gateway/policy-resolver.ts +171 -0
  74. package/src/gateway/runtimes/acp-stream.ts +535 -0
  75. package/src/gateway/runtimes/codex.ts +7 -0
  76. package/src/gateway/runtimes/hermes-agent.ts +206 -0
  77. package/src/gateway/runtimes/ndjson-stream.ts +16 -3
  78. package/src/gateway/runtimes/openclaw-acp.ts +606 -0
  79. package/src/gateway/runtimes/registry.ts +24 -0
  80. package/src/gateway/transcript-paths.ts +145 -0
  81. package/src/gateway/transcript.ts +300 -0
  82. package/src/gateway/types.ts +32 -0
  83. package/src/index.ts +321 -30
  84. package/src/mention-scan.ts +38 -0
  85. package/src/openclaw-discovery.ts +262 -0
  86. package/src/provision.ts +682 -14
  87. package/src/system-context.ts +41 -9
  88. package/src/url-utils.ts +17 -0
package/src/doctor.ts CHANGED
@@ -31,9 +31,34 @@ export interface DoctorHttpResult {
31
31
  error?: string;
32
32
  }
33
33
 
34
+ /** One endpoint probe entry, mirrored from `RuntimeEndpointProbe`. */
35
+ export interface DoctorRuntimeEndpoint {
36
+ name: string;
37
+ url: string;
38
+ reachable: boolean;
39
+ version?: string;
40
+ error?: string;
41
+ agents?: Array<{
42
+ id: string;
43
+ name?: string;
44
+ workspace?: string;
45
+ model?: { name?: string; provider?: string };
46
+ }>;
47
+ /**
48
+ * Optional warning surfaced by the doctor: e.g. botcord plugin loaded on
49
+ * the gateway (would form a daemon → openclaw → botcord → Hub loop).
50
+ */
51
+ warnings?: string[];
52
+ }
53
+
54
+ /** Augmented runtime entry that may carry endpoint probe results. */
55
+ export interface DoctorRuntimeEntry extends RuntimeProbeEntry {
56
+ endpoints?: DoctorRuntimeEndpoint[];
57
+ }
58
+
34
59
  /** Input for the rendered doctor output. */
35
60
  export interface DoctorInput {
36
- runtimes: RuntimeProbeEntry[];
61
+ runtimes: DoctorRuntimeEntry[];
37
62
  channels: ChannelProbeResult[];
38
63
  }
39
64
 
@@ -226,10 +251,32 @@ export function renderDoctor(input: DoctorInput): string {
226
251
  lines.push(
227
252
  `${pad("RUNTIME", widths.runtime)} ${pad("NAME", widths.name)} ${pad("STATUS", widths.status)} ${pad("VERSION", widths.version)} PATH`,
228
253
  );
229
- for (const r of rows) {
254
+ for (let i = 0; i < rows.length; i += 1) {
255
+ const r = rows[i];
256
+ const e = input.runtimes[i];
230
257
  lines.push(
231
258
  `${pad(r.runtime, widths.runtime)} ${pad(r.name, widths.name)} ${pad(r.status, widths.status)} ${pad(r.version, widths.version)} ${r.path}`,
232
259
  );
260
+ if (e.endpoints && e.endpoints.length > 0) {
261
+ for (const ep of e.endpoints) {
262
+ const mark = ep.reachable ? "✓" : "✗";
263
+ const detail = ep.reachable
264
+ ? ep.version ?? "ok"
265
+ : ep.error ?? "unreachable";
266
+ lines.push(` gateway ${pad(`"${ep.name}"`, 16)} ${pad(ep.url, 40)} ${mark} ${detail}`);
267
+ if (ep.agents && ep.agents.length > 0) {
268
+ // RFC §3.8.4: list by `id` (stable key); show display name when distinct.
269
+ lines.push(
270
+ ` agents (id): ${ep.agents
271
+ .map((a) => (a.name && a.name !== a.id ? `${a.id} (${a.name})` : a.id))
272
+ .join(", ")}`,
273
+ );
274
+ }
275
+ if (ep.warnings) {
276
+ for (const w of ep.warnings) lines.push(` WARN: ${w}`);
277
+ }
278
+ }
279
+ }
233
280
  }
234
281
  const available = input.runtimes.filter((e) => e.result.available).length;
235
282
  lines.push(`\n${available}/${input.runtimes.length} runtimes available`);
@@ -1551,6 +1551,71 @@ describe("Dispatcher", () => {
1551
1551
  expect(composedItemCounts[1]).toBeLessThanOrEqual(8);
1552
1552
  });
1553
1553
 
1554
+ it("attentionGate=false skips the runtime turn but still acks and runs onInbound (PR3)", async () => {
1555
+ const runtime = new FakeRuntime();
1556
+ const { store, dir } = await makeStore();
1557
+ tempDirs.push(dir);
1558
+ const channel = new FakeChannel();
1559
+ const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
1560
+ const accept = vi.fn(async () => {});
1561
+ const onInbound = vi.fn();
1562
+ const attentionGate = vi.fn(async () => false);
1563
+ const dispatcher = new Dispatcher({
1564
+ config: baseConfig(),
1565
+ channels,
1566
+ runtime: () => runtime,
1567
+ sessionStore: store,
1568
+ log: silentLogger(),
1569
+ onInbound,
1570
+ attentionGate,
1571
+ });
1572
+ await dispatcher.handle(makeEnvelope({ id: "m_gated", text: "hello" }, { accept }));
1573
+ expect(accept).toHaveBeenCalledTimes(1);
1574
+ expect(onInbound).toHaveBeenCalledTimes(1);
1575
+ expect(attentionGate).toHaveBeenCalledTimes(1);
1576
+ expect(runtime.calls.length).toBe(0);
1577
+ expect(channel.sends.length).toBe(0);
1578
+ });
1579
+
1580
+ it("attentionGate=true wakes the runtime as usual (PR3)", async () => {
1581
+ const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
1582
+ const { store, dir } = await makeStore();
1583
+ tempDirs.push(dir);
1584
+ const channel = new FakeChannel();
1585
+ const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
1586
+ const dispatcher = new Dispatcher({
1587
+ config: baseConfig(),
1588
+ channels,
1589
+ runtime: () => runtime,
1590
+ sessionStore: store,
1591
+ log: silentLogger(),
1592
+ attentionGate: () => true,
1593
+ });
1594
+ await dispatcher.handle(makeEnvelope({ id: "m_wake" }));
1595
+ expect(runtime.calls.length).toBe(1);
1596
+ expect(channel.sends.length).toBe(1);
1597
+ });
1598
+
1599
+ it("attentionGate throwing fails open and runs the turn (PR3)", async () => {
1600
+ const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
1601
+ const { store, dir } = await makeStore();
1602
+ tempDirs.push(dir);
1603
+ const channel = new FakeChannel();
1604
+ const channels = new Map<string, ChannelAdapter>([[channel.id, channel]]);
1605
+ const dispatcher = new Dispatcher({
1606
+ config: baseConfig(),
1607
+ channels,
1608
+ runtime: () => runtime,
1609
+ sessionStore: store,
1610
+ log: silentLogger(),
1611
+ attentionGate: () => {
1612
+ throw new Error("boom");
1613
+ },
1614
+ });
1615
+ await dispatcher.handle(makeEnvelope({ id: "m_wake_throw" }));
1616
+ expect(runtime.calls.length).toBe(1);
1617
+ });
1618
+
1554
1619
  it("owner-chat detection: dashboard_user_chat in non-rm_oc room still sends reply", async () => {
1555
1620
  const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid-1" });
1556
1621
  const { dispatcher, channel } = await scaffold({
@@ -0,0 +1,302 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { HermesAgentAdapter } from "../runtimes/hermes-agent.js";
13
+ import { agentHermesWorkspaceDir } from "../../agent-workspace.js";
14
+
15
+ // Spawn a tiny Node "ACP server" we control instead of the real hermes-acp.
16
+ const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "gateway-hermes-"));
17
+
18
+ const originalHome = process.env.HOME;
19
+ const agentHomeRoot = mkdtempSync(path.join(os.tmpdir(), "gateway-hermes-home-"));
20
+
21
+ beforeAll(() => {
22
+ process.env.HOME = agentHomeRoot;
23
+ });
24
+
25
+ afterAll(() => {
26
+ if (originalHome === undefined) delete process.env.HOME;
27
+ else process.env.HOME = originalHome;
28
+ rmSync(tmpRoot, { recursive: true, force: true });
29
+ rmSync(agentHomeRoot, { recursive: true, force: true });
30
+ });
31
+
32
+ /**
33
+ * Mock ACP server. Reads newline-delimited JSON-RPC messages from stdin
34
+ * and replies on stdout. The script body controls the per-test scenario.
35
+ */
36
+ function makeAcpServer(name: string, script: string): string {
37
+ const body = `
38
+ const lines = [];
39
+ let buf = "";
40
+ process.stdin.setEncoding("utf8");
41
+ function send(obj) { process.stdout.write(JSON.stringify(obj) + "\\n"); }
42
+ function reply(req, result) { send({ jsonrpc: "2.0", id: req.id, result }); }
43
+ function err(req, code, message) { send({ jsonrpc: "2.0", id: req.id, error: { code, message } }); }
44
+ function notify(method, params) { send({ jsonrpc: "2.0", method, params }); }
45
+ let nextReqId = 1000;
46
+ const pending = new Map();
47
+ function request(method, params) {
48
+ const id = nextReqId++;
49
+ return new Promise((resolve, reject) => {
50
+ pending.set(id, { resolve, reject });
51
+ send({ jsonrpc: "2.0", id, method, params });
52
+ });
53
+ }
54
+ async function handle(msg) {
55
+ ${script}
56
+ }
57
+ process.stdin.on("data", async (chunk) => {
58
+ buf += chunk;
59
+ let idx;
60
+ while ((idx = buf.indexOf("\\n")) !== -1) {
61
+ const line = buf.slice(0, idx).trim();
62
+ buf = buf.slice(idx + 1);
63
+ if (!line) continue;
64
+ let msg;
65
+ try { msg = JSON.parse(line); } catch { continue; }
66
+ // Server received a response to a request it sent
67
+ if (typeof msg.id === "number" && (msg.result !== undefined || msg.error !== undefined) && pending.has(msg.id)) {
68
+ const p = pending.get(msg.id);
69
+ pending.delete(msg.id);
70
+ if (msg.error) p.reject(msg.error); else p.resolve(msg.result);
71
+ continue;
72
+ }
73
+ try { await handle(msg); } catch (e) { process.stderr.write("mock error: " + e + "\\n"); }
74
+ }
75
+ });
76
+ process.stdin.on("end", () => process.exit(0));
77
+ `;
78
+ const p = path.join(tmpRoot, name);
79
+ writeFileSync(p, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 });
80
+ chmodSync(p, 0o755);
81
+ return p;
82
+ }
83
+
84
+ interface RunOpts {
85
+ sessionId?: string | null;
86
+ trustLevel?: "owner" | "trusted" | "public";
87
+ systemContext?: string;
88
+ accountId?: string;
89
+ onBlock?: (b: unknown) => void;
90
+ }
91
+
92
+ function runAdapter(script: string, opts: RunOpts = {}) {
93
+ const adapter = new HermesAgentAdapter({ binary: script });
94
+ const ctrl = new AbortController();
95
+ return adapter.run({
96
+ text: "hello",
97
+ sessionId: opts.sessionId ?? null,
98
+ accountId: opts.accountId ?? "ag_hermes_test",
99
+ cwd: tmpRoot,
100
+ signal: ctrl.signal,
101
+ trustLevel: opts.trustLevel ?? "owner",
102
+ systemContext: opts.systemContext,
103
+ onBlock: opts.onBlock as never,
104
+ });
105
+ }
106
+
107
+ describe("HermesAgentAdapter", () => {
108
+ it("happy path: initialize → session/new → session/prompt with streamed text", async () => {
109
+ const script = makeAcpServer(
110
+ "happy.js",
111
+ `
112
+ if (msg.method === "initialize") {
113
+ reply(msg, { protocolVersion: 1, agentInfo: { name: "mock", version: "0.0.1" }, agentCapabilities: {} });
114
+ } else if (msg.method === "session/new") {
115
+ reply(msg, { sessionId: "sess-001" });
116
+ } else if (msg.method === "session/prompt") {
117
+ notify("session/update", { sessionId: msg.params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hi " } } });
118
+ notify("session/update", { sessionId: msg.params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "world" } } });
119
+ reply(msg, { stopReason: "end_turn" });
120
+ process.stdin.pause();
121
+ process.exit(0);
122
+ }
123
+ `,
124
+ );
125
+ const blocks: any[] = [];
126
+ const res = await runAdapter(script, { onBlock: (b) => blocks.push(b) });
127
+ expect(res.error).toBeUndefined();
128
+ expect(res.newSessionId).toBe("sess-001");
129
+ expect(res.text).toBe("hi world");
130
+ expect(blocks.map((b) => b.kind)).toContain("assistant_text");
131
+ });
132
+
133
+ it("session/load success → reuses incoming sessionId", async () => {
134
+ const script = makeAcpServer(
135
+ "load.js",
136
+ `
137
+ if (msg.method === "initialize") {
138
+ reply(msg, { protocolVersion: 1 });
139
+ } else if (msg.method === "session/load") {
140
+ // Hermes returns null/empty body without sessionId — adapter must
141
+ // reuse opts.sessionId.
142
+ reply(msg, {});
143
+ } else if (msg.method === "session/new") {
144
+ reply(msg, { sessionId: "should-not-be-used" });
145
+ } else if (msg.method === "session/prompt") {
146
+ notify("session/update", { sessionId: msg.params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "resumed" } } });
147
+ reply(msg, { stopReason: "end_turn" });
148
+ process.exit(0);
149
+ }
150
+ `,
151
+ );
152
+ const res = await runAdapter(script, { sessionId: "sess-existing" });
153
+ expect(res.newSessionId).toBe("sess-existing");
154
+ expect(res.text).toBe("resumed");
155
+ });
156
+
157
+ it("session/load failure → falls back to session/new", async () => {
158
+ const script = makeAcpServer(
159
+ "load-miss.js",
160
+ `
161
+ if (msg.method === "initialize") {
162
+ reply(msg, { protocolVersion: 1 });
163
+ } else if (msg.method === "session/load") {
164
+ err(msg, -32000, "not found");
165
+ } else if (msg.method === "session/new") {
166
+ reply(msg, { sessionId: "sess-fresh" });
167
+ } else if (msg.method === "session/prompt") {
168
+ reply(msg, { stopReason: "end_turn" });
169
+ process.exit(0);
170
+ }
171
+ `,
172
+ );
173
+ const res = await runAdapter(script, { sessionId: "sess-stale" });
174
+ expect(res.newSessionId).toBe("sess-fresh");
175
+ expect(res.error).toBeUndefined();
176
+ });
177
+
178
+ it("owner trust → request_permission selects an allow_* option", async () => {
179
+ const script = makeAcpServer(
180
+ "perm-allow.js",
181
+ `
182
+ if (msg.method === "initialize") {
183
+ reply(msg, { protocolVersion: 1 });
184
+ } else if (msg.method === "session/new") {
185
+ reply(msg, { sessionId: "sess-perm" });
186
+ } else if (msg.method === "session/prompt") {
187
+ const outcome = await request("session/request_permission", {
188
+ sessionId: msg.params.sessionId,
189
+ toolCall: { name: "shell", rawInput: { cmd: "ls" } },
190
+ options: [
191
+ { optionId: "allow", name: "Allow", kind: "allow_once" },
192
+ { optionId: "deny", name: "Deny", kind: "reject_once" }
193
+ ]
194
+ });
195
+ notify("session/update", { sessionId: msg.params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "outcome=" + JSON.stringify(outcome) } } });
196
+ reply(msg, { stopReason: "end_turn" });
197
+ process.exit(0);
198
+ }
199
+ `,
200
+ );
201
+ const res = await runAdapter(script, { trustLevel: "owner" });
202
+ expect(res.text).toContain('"outcome":"selected"');
203
+ expect(res.text).toContain('"optionId":"allow"');
204
+ });
205
+
206
+ it("public trust → request_permission cancels", async () => {
207
+ const script = makeAcpServer(
208
+ "perm-deny.js",
209
+ `
210
+ if (msg.method === "initialize") {
211
+ reply(msg, { protocolVersion: 1 });
212
+ } else if (msg.method === "session/new") {
213
+ reply(msg, { sessionId: "sess-perm" });
214
+ } else if (msg.method === "session/prompt") {
215
+ const outcome = await request("session/request_permission", {
216
+ sessionId: msg.params.sessionId,
217
+ options: [
218
+ { optionId: "allow", kind: "allow_once" },
219
+ { optionId: "deny", kind: "reject_once" }
220
+ ]
221
+ });
222
+ notify("session/update", { sessionId: msg.params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: JSON.stringify(outcome) } } });
223
+ reply(msg, { stopReason: "end_turn" });
224
+ process.exit(0);
225
+ }
226
+ `,
227
+ );
228
+ const res = await runAdapter(script, { trustLevel: "public" });
229
+ expect(res.text).toContain('"outcome":"cancelled"');
230
+ expect(res.text).not.toContain("optionId");
231
+ });
232
+
233
+ it("writes systemContext to <hermes-workspace>/AGENTS.md before spawn", async () => {
234
+ const script = makeAcpServer(
235
+ "ctx.js",
236
+ `
237
+ if (msg.method === "initialize") { reply(msg, { protocolVersion: 1 }); }
238
+ else if (msg.method === "session/new") { reply(msg, { sessionId: "sess-ctx" }); }
239
+ else if (msg.method === "session/prompt") { reply(msg, { stopReason: "end_turn" }); process.exit(0); }
240
+ `,
241
+ );
242
+ await runAdapter(script, {
243
+ accountId: "ag_ctx_test",
244
+ systemContext: "MEMORY: remember X\nDIGEST: room Y",
245
+ });
246
+ const ws = agentHermesWorkspaceDir("ag_ctx_test");
247
+ const agentsMd = path.join(ws, "AGENTS.md");
248
+ expect(existsSync(agentsMd)).toBe(true);
249
+ const body = readFileSync(agentsMd, "utf8");
250
+ expect(body).toContain("MEMORY: remember X");
251
+ expect(body).toContain("DIGEST: room Y");
252
+ });
253
+
254
+ it("session/new uses hermes-workspace as cwd, not the route cwd", async () => {
255
+ const script = makeAcpServer(
256
+ "cwd-echo.js",
257
+ `
258
+ if (msg.method === "initialize") { reply(msg, { protocolVersion: 1 }); }
259
+ else if (msg.method === "session/new") {
260
+ reply(msg, { sessionId: "sess-cwd" });
261
+ // Echo back the cwd we received via a streamed update on prompt.
262
+ this.__cwd = msg.params && msg.params.cwd;
263
+ } else if (msg.method === "session/prompt") {
264
+ notify("session/update", { sessionId: msg.params.sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: this.__cwd || "" } } });
265
+ reply(msg, { stopReason: "end_turn" });
266
+ process.exit(0);
267
+ }
268
+ `,
269
+ );
270
+ const res = await runAdapter(script, { accountId: "ag_cwd_test" });
271
+ expect(res.text).toBe(agentHermesWorkspaceDir("ag_cwd_test"));
272
+ });
273
+
274
+ it("returns early when signal is already aborted", async () => {
275
+ const script = makeAcpServer("noop.js", "");
276
+ const adapter = new HermesAgentAdapter({ binary: script });
277
+ const ctrl = new AbortController();
278
+ ctrl.abort();
279
+ const res = await adapter.run({
280
+ text: "x",
281
+ sessionId: null,
282
+ accountId: "ag_hermes_test",
283
+ cwd: tmpRoot,
284
+ signal: ctrl.signal,
285
+ trustLevel: "owner",
286
+ });
287
+ expect(res.text).toBe("");
288
+ expect(res.error).toMatch(/aborted before spawn/);
289
+ });
290
+
291
+ it("surfaces non-zero exit with stderr snippet", async () => {
292
+ const p = path.join(tmpRoot, "boom.js");
293
+ writeFileSync(
294
+ p,
295
+ `#!/usr/bin/env node\nprocess.stderr.write("hermes auth required\\n"); process.exit(2);\n`,
296
+ { mode: 0o755 },
297
+ );
298
+ chmodSync(p, 0o755);
299
+ const res = await runAdapter(p);
300
+ expect(res.error).toBeDefined();
301
+ });
302
+ });