@botcord/daemon 0.2.4 → 0.2.6

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 (93) hide show
  1. package/dist/agent-discovery.d.ts +7 -3
  2. package/dist/agent-discovery.js +9 -1
  3. package/dist/agent-workspace.d.ts +62 -0
  4. package/dist/agent-workspace.js +140 -10
  5. package/dist/config.d.ts +49 -1
  6. package/dist/config.js +57 -1
  7. package/dist/control-channel.d.ts +1 -4
  8. package/dist/control-channel.js +1 -4
  9. package/dist/daemon-config-map.d.ts +29 -12
  10. package/dist/daemon-config-map.js +105 -8
  11. package/dist/daemon.d.ts +2 -0
  12. package/dist/daemon.js +52 -5
  13. package/dist/doctor.d.ts +27 -1
  14. package/dist/doctor.js +22 -1
  15. package/dist/gateway/cli-resolver.d.ts +34 -0
  16. package/dist/gateway/cli-resolver.js +74 -0
  17. package/dist/gateway/dispatcher.d.ts +66 -1
  18. package/dist/gateway/dispatcher.js +583 -56
  19. package/dist/gateway/gateway.d.ts +29 -1
  20. package/dist/gateway/gateway.js +10 -0
  21. package/dist/gateway/index.d.ts +2 -0
  22. package/dist/gateway/index.js +2 -0
  23. package/dist/gateway/policy-resolver.d.ts +57 -0
  24. package/dist/gateway/policy-resolver.js +123 -0
  25. package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
  26. package/dist/gateway/runtimes/acp-stream.js +394 -0
  27. package/dist/gateway/runtimes/codex.js +7 -0
  28. package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
  29. package/dist/gateway/runtimes/hermes-agent.js +180 -0
  30. package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
  31. package/dist/gateway/runtimes/ndjson-stream.js +16 -3
  32. package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
  33. package/dist/gateway/runtimes/openclaw-acp.js +500 -0
  34. package/dist/gateway/runtimes/registry.d.ts +4 -0
  35. package/dist/gateway/runtimes/registry.js +22 -0
  36. package/dist/gateway/transcript-paths.d.ts +30 -0
  37. package/dist/gateway/transcript-paths.js +114 -0
  38. package/dist/gateway/transcript.d.ts +123 -0
  39. package/dist/gateway/transcript.js +147 -0
  40. package/dist/gateway/types.d.ts +31 -0
  41. package/dist/index.js +286 -27
  42. package/dist/mention-scan.d.ts +22 -0
  43. package/dist/mention-scan.js +35 -0
  44. package/dist/provision.d.ts +73 -3
  45. package/dist/provision.js +373 -12
  46. package/dist/system-context.d.ts +5 -4
  47. package/dist/system-context.js +35 -5
  48. package/dist/turn-text.js +20 -1
  49. package/dist/url-utils.d.ts +9 -0
  50. package/dist/url-utils.js +18 -0
  51. package/dist/user-auth.js +0 -2
  52. package/dist/working-memory.js +1 -1
  53. package/package.json +2 -1
  54. package/src/__tests__/agent-workspace.test.ts +93 -0
  55. package/src/__tests__/daemon-config-map.test.ts +79 -0
  56. package/src/__tests__/openclaw-acp.test.ts +234 -0
  57. package/src/__tests__/policy-resolver.test.ts +124 -0
  58. package/src/__tests__/policy-updated-handler.test.ts +144 -0
  59. package/src/__tests__/provision.test.ts +160 -0
  60. package/src/__tests__/system-context.test.ts +52 -0
  61. package/src/__tests__/url-utils.test.ts +37 -0
  62. package/src/agent-discovery.ts +12 -4
  63. package/src/agent-workspace.ts +173 -9
  64. package/src/config.ts +132 -4
  65. package/src/control-channel.ts +1 -4
  66. package/src/daemon-config-map.ts +156 -12
  67. package/src/daemon.ts +66 -5
  68. package/src/doctor.ts +49 -2
  69. package/src/gateway/__tests__/dispatcher.test.ts +440 -2
  70. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
  71. package/src/gateway/__tests__/transcript.test.ts +496 -0
  72. package/src/gateway/cli-resolver.ts +92 -0
  73. package/src/gateway/dispatcher.ts +681 -58
  74. package/src/gateway/gateway.ts +46 -0
  75. package/src/gateway/index.ts +25 -0
  76. package/src/gateway/policy-resolver.ts +171 -0
  77. package/src/gateway/runtimes/acp-stream.ts +535 -0
  78. package/src/gateway/runtimes/codex.ts +7 -0
  79. package/src/gateway/runtimes/hermes-agent.ts +206 -0
  80. package/src/gateway/runtimes/ndjson-stream.ts +16 -3
  81. package/src/gateway/runtimes/openclaw-acp.ts +606 -0
  82. package/src/gateway/runtimes/registry.ts +24 -0
  83. package/src/gateway/transcript-paths.ts +145 -0
  84. package/src/gateway/transcript.ts +300 -0
  85. package/src/gateway/types.ts +32 -0
  86. package/src/index.ts +295 -30
  87. package/src/mention-scan.ts +38 -0
  88. package/src/provision.ts +446 -20
  89. package/src/system-context.ts +41 -9
  90. package/src/turn-text.ts +22 -1
  91. package/src/url-utils.ts +17 -0
  92. package/src/user-auth.ts +0 -2
  93. package/src/working-memory.ts +1 -1
@@ -0,0 +1,394 @@
1
+ import { spawn } from "node:child_process";
2
+ import { consoleLogger } from "../log.js";
3
+ /**
4
+ * Minimal bidirectional ACP (Agent Client Protocol) client used by runtime
5
+ * adapters whose backing CLI speaks ACP over stdio (JSON-RPC 2.0,
6
+ * newline-delimited).
7
+ *
8
+ * Why a base class instead of `NdjsonStreamAdapter`: ACP is a bidirectional
9
+ * RPC protocol — the agent sends notifications (`session/update`) AND
10
+ * server-initiated requests (`session/request_permission`) that the daemon
11
+ * MUST reply to or the agent stalls. The ndjson base only models a one-way
12
+ * event stream, so it cannot drive ACP correctly.
13
+ */
14
+ const log = consoleLogger;
15
+ /** How much stderr we keep for error reporting. */
16
+ const STDERR_TAIL_CAP = 8 * 1024;
17
+ /** How much of the retained stderr is included in synthesized errors. */
18
+ const STDERR_ERROR_SNIPPET = 500;
19
+ /** Cap on streamed assistant text per turn — guards a runaway runtime. */
20
+ const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
21
+ /** Grace period between SIGTERM and SIGKILL on abort. */
22
+ const KILL_GRACE_MS = 5_000;
23
+ /** Deadline for the initial `initialize` handshake. */
24
+ const INITIALIZE_TIMEOUT_MS = 30_000;
25
+ /** ACP protocol version this client targets. */
26
+ export const ACP_PROTOCOL_VERSION = 1;
27
+ /** Minimal newline-JSON-RPC framing on top of a child process's stdio. */
28
+ class AcpConnection {
29
+ child;
30
+ handlers;
31
+ logId;
32
+ nextId = 1;
33
+ pending = new Map();
34
+ stdoutBuf = "";
35
+ closed = false;
36
+ closeReason = null;
37
+ constructor(child, handlers, logId) {
38
+ this.child = child;
39
+ this.handlers = handlers;
40
+ this.logId = logId;
41
+ child.stdout.setEncoding("utf8");
42
+ child.stdout.on("data", (chunk) => this.onStdout(chunk));
43
+ child.stdout.on("end", () => this.fail(new Error("stdout closed")));
44
+ child.on("close", (code) => this.fail(new Error(`process exited with code ${code ?? 0}`)));
45
+ child.on("error", (err) => this.fail(err));
46
+ }
47
+ onStdout(chunk) {
48
+ this.stdoutBuf += chunk;
49
+ let idx;
50
+ while ((idx = this.stdoutBuf.indexOf("\n")) !== -1) {
51
+ const line = this.stdoutBuf.slice(0, idx).trim();
52
+ this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
53
+ if (!line)
54
+ continue;
55
+ this.dispatchLine(line);
56
+ }
57
+ }
58
+ dispatchLine(line) {
59
+ let msg;
60
+ try {
61
+ msg = JSON.parse(line);
62
+ }
63
+ catch {
64
+ log.warn(`${this.logId} non-json acp line`, { line: line.slice(0, 200) });
65
+ return;
66
+ }
67
+ if (typeof msg !== "object" || msg === null)
68
+ return;
69
+ // Response to a client→server request
70
+ if (typeof msg.id === "number" && (msg.result !== undefined || msg.error !== undefined)) {
71
+ const pending = this.pending.get(msg.id);
72
+ if (!pending)
73
+ return;
74
+ this.pending.delete(msg.id);
75
+ if (msg.error) {
76
+ const err = new Error(`acp error ${msg.error.code ?? "?"}: ${msg.error.message ?? "(no message)"}`);
77
+ pending.reject(err);
78
+ }
79
+ else {
80
+ pending.resolve(msg.result ?? null);
81
+ }
82
+ return;
83
+ }
84
+ if (typeof msg.method === "string") {
85
+ // Server→client request (has `id`) or notification (no `id`)
86
+ if (msg.id !== undefined) {
87
+ void this.handleServerRequest(msg.id, msg.method, msg.params);
88
+ }
89
+ else {
90
+ try {
91
+ this.handlers.onNotification(msg.method, msg.params);
92
+ }
93
+ catch (err) {
94
+ log.warn(`${this.logId} notification handler threw`, {
95
+ method: msg.method,
96
+ err: String(err),
97
+ });
98
+ }
99
+ }
100
+ }
101
+ }
102
+ async handleServerRequest(id, method, params) {
103
+ let result;
104
+ let error = null;
105
+ try {
106
+ result = await this.handlers.onRequest(method, params);
107
+ }
108
+ catch (err) {
109
+ error = {
110
+ code: -32603,
111
+ message: err instanceof Error ? err.message : String(err),
112
+ };
113
+ }
114
+ const reply = error
115
+ ? { jsonrpc: "2.0", id, error }
116
+ : { jsonrpc: "2.0", id, result: result ?? null };
117
+ this.writeMessage(reply);
118
+ }
119
+ writeMessage(obj) {
120
+ if (this.closed)
121
+ return;
122
+ try {
123
+ this.child.stdin.write(JSON.stringify(obj) + "\n");
124
+ }
125
+ catch (err) {
126
+ this.fail(err instanceof Error ? err : new Error(String(err)));
127
+ }
128
+ }
129
+ request(method, params) {
130
+ if (this.closed) {
131
+ return Promise.reject(this.closeReason ?? new Error("acp closed"));
132
+ }
133
+ const id = this.nextId++;
134
+ return new Promise((resolve, reject) => {
135
+ this.pending.set(id, {
136
+ resolve: (v) => resolve(v),
137
+ reject,
138
+ });
139
+ this.writeMessage({ jsonrpc: "2.0", id, method, params });
140
+ });
141
+ }
142
+ notify(method, params) {
143
+ this.writeMessage({ jsonrpc: "2.0", method, params });
144
+ }
145
+ fail(err) {
146
+ if (this.closed)
147
+ return;
148
+ this.closed = true;
149
+ this.closeReason = err;
150
+ for (const [, p] of this.pending)
151
+ p.reject(err);
152
+ this.pending.clear();
153
+ }
154
+ isClosed() {
155
+ return this.closed;
156
+ }
157
+ }
158
+ export class AcpRuntimeAdapter {
159
+ /** Argv tail (excluding the binary). ACP servers usually take none. */
160
+ buildArgs(_opts) {
161
+ return [];
162
+ }
163
+ /** Runtime-specific clientCapabilities sent on initialize. */
164
+ clientCapabilities() {
165
+ return { fs: { readTextFile: false, writeTextFile: false } };
166
+ }
167
+ /** Runtime-specific clientInfo sent on initialize. */
168
+ clientInfo() {
169
+ return { name: "botcord-daemon", version: "0.1" };
170
+ }
171
+ /**
172
+ * Hook invoked synchronously before spawn. Subclasses use this to write
173
+ * systemContext to disk (e.g. `<cwd>/AGENTS.md`).
174
+ */
175
+ prepareTurn(_opts) {
176
+ /* default: noop */
177
+ }
178
+ /** cwd passed to ACP `session/new` / `session/load`. Typically `opts.cwd`. */
179
+ sessionCwd(opts) {
180
+ return opts.cwd;
181
+ }
182
+ async run(opts) {
183
+ if (opts.signal.aborted) {
184
+ return {
185
+ text: "",
186
+ newSessionId: opts.sessionId ?? "",
187
+ error: `${this.id} aborted before spawn`,
188
+ };
189
+ }
190
+ try {
191
+ this.prepareTurn(opts);
192
+ }
193
+ catch (err) {
194
+ log.warn(`${this.id} prepareTurn threw`, { err: String(err) });
195
+ }
196
+ const binary = this.resolveBinary(opts);
197
+ const args = this.buildArgs(opts);
198
+ log.debug(`${this.id} spawn`, {
199
+ cwd: opts.cwd,
200
+ sessionId: opts.sessionId,
201
+ argv: args,
202
+ });
203
+ const child = spawn(binary, args, {
204
+ cwd: opts.cwd,
205
+ env: this.spawnEnv(opts),
206
+ stdio: ["pipe", "pipe", "pipe"],
207
+ });
208
+ let killTimer = null;
209
+ const onAbort = () => {
210
+ if (child.killed)
211
+ return;
212
+ try {
213
+ child.stdin.end();
214
+ }
215
+ catch {
216
+ /* best-effort */
217
+ }
218
+ child.kill("SIGTERM");
219
+ killTimer = setTimeout(() => {
220
+ if (!child.killed) {
221
+ log.warn(`${this.id} did not exit after SIGTERM; sending SIGKILL`);
222
+ try {
223
+ child.kill("SIGKILL");
224
+ }
225
+ catch {
226
+ /* best-effort */
227
+ }
228
+ }
229
+ }, KILL_GRACE_MS);
230
+ if (typeof killTimer.unref === "function")
231
+ killTimer.unref();
232
+ };
233
+ opts.signal.addEventListener("abort", onAbort, { once: true });
234
+ let stderrTail = "";
235
+ child.stderr.setEncoding("utf8");
236
+ child.stderr.on("data", (chunk) => {
237
+ stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_CAP);
238
+ });
239
+ const state = {
240
+ finalText: "",
241
+ assistantTextChunks: [],
242
+ assistantTextBytes: 0,
243
+ assistantTextCapped: false,
244
+ };
245
+ const appendAssistantText = (text) => {
246
+ if (!text || state.assistantTextCapped)
247
+ return;
248
+ const budget = ASSISTANT_TEXT_CAP - state.assistantTextBytes;
249
+ if (budget <= 0) {
250
+ state.assistantTextCapped = true;
251
+ return;
252
+ }
253
+ if (text.length > budget) {
254
+ state.assistantTextChunks.push(text.slice(0, budget));
255
+ state.assistantTextBytes += budget;
256
+ state.assistantTextCapped = true;
257
+ return;
258
+ }
259
+ state.assistantTextChunks.push(text);
260
+ state.assistantTextBytes += text.length;
261
+ };
262
+ let seq = 0;
263
+ const conn = new AcpConnection(child, {
264
+ onNotification: (method, params) => {
265
+ if (method === "session/update") {
266
+ seq += 1;
267
+ this.onUpdate(params, {
268
+ appendAssistantText,
269
+ emitBlock: (b) => opts.onBlock?.(b),
270
+ seq,
271
+ });
272
+ }
273
+ },
274
+ onRequest: async (method, params) => {
275
+ if (method === "session/request_permission") {
276
+ return this.onPermissionRequest(params, opts);
277
+ }
278
+ // Unknown server→client request: signal "method not found" so the
279
+ // server can decide what to do. Throwing here surfaces as a JSON-RPC
280
+ // error reply via AcpConnection.
281
+ const err = new Error(`unknown server request: ${method}`);
282
+ throw err;
283
+ },
284
+ }, this.id);
285
+ const childExit = new Promise((resolve) => {
286
+ child.on("close", (code) => resolve(code ?? 0));
287
+ });
288
+ let newSessionId = opts.sessionId ?? "";
289
+ try {
290
+ // 1) initialize
291
+ await this.withTimeout(conn.request("initialize", {
292
+ protocolVersion: ACP_PROTOCOL_VERSION,
293
+ clientCapabilities: this.clientCapabilities(),
294
+ clientInfo: this.clientInfo(),
295
+ }), INITIALIZE_TIMEOUT_MS, "initialize");
296
+ // 2) session/load (if resuming) → fallback to session/new
297
+ const cwd = this.sessionCwd(opts);
298
+ let sessionId = "";
299
+ if (opts.sessionId) {
300
+ try {
301
+ const loaded = (await conn.request("session/load", {
302
+ sessionId: opts.sessionId,
303
+ cwd,
304
+ mcpServers: [],
305
+ }));
306
+ if (loaded !== null && loaded !== undefined) {
307
+ // Hermes' load_session does NOT return a session_id — reuse the
308
+ // requested one. If a future server returns one, prefer it.
309
+ sessionId =
310
+ (loaded && typeof loaded.sessionId === "string"
311
+ ? loaded.sessionId
312
+ : "") || opts.sessionId;
313
+ }
314
+ }
315
+ catch (err) {
316
+ log.warn(`${this.id} session/load failed; falling back to new`, {
317
+ err: err instanceof Error ? err.message : String(err),
318
+ });
319
+ }
320
+ }
321
+ if (!sessionId) {
322
+ const created = await conn.request("session/new", { cwd, mcpServers: [] });
323
+ sessionId = created?.sessionId ?? "";
324
+ }
325
+ if (!sessionId) {
326
+ throw new Error("acp server did not return a sessionId");
327
+ }
328
+ newSessionId = sessionId;
329
+ // 3) session/prompt
330
+ const promptResult = (await conn.request("session/prompt", {
331
+ sessionId,
332
+ prompt: [{ type: "text", text: opts.text }],
333
+ }));
334
+ const stopReason = promptResult?.stopReason ?? "end_turn";
335
+ if (stopReason === "refusal" || stopReason === "error") {
336
+ state.errorText = state.errorText ?? `prompt stopped: ${stopReason}`;
337
+ }
338
+ // Politely close stdin so the server can exit. Some ACP servers shut
339
+ // down on EOF; if not, abort signal will SIGTERM.
340
+ try {
341
+ child.stdin.end();
342
+ }
343
+ catch {
344
+ /* best-effort */
345
+ }
346
+ }
347
+ catch (err) {
348
+ state.errorText =
349
+ state.errorText ??
350
+ (err instanceof Error ? err.message : String(err));
351
+ try {
352
+ child.stdin.end();
353
+ }
354
+ catch {
355
+ /* best-effort */
356
+ }
357
+ }
358
+ let code = 0;
359
+ try {
360
+ code = await childExit;
361
+ }
362
+ finally {
363
+ opts.signal.removeEventListener("abort", onAbort);
364
+ if (killTimer)
365
+ clearTimeout(killTimer);
366
+ }
367
+ if (code !== 0 && !state.errorText) {
368
+ state.errorText = `${this.id} exited with code ${code}: ${stderrTail.slice(-STDERR_ERROR_SNIPPET)}`;
369
+ }
370
+ const rawText = state.finalText || state.assistantTextChunks.join("").trim();
371
+ const text = rawText.length > ASSISTANT_TEXT_CAP
372
+ ? rawText.slice(0, ASSISTANT_TEXT_CAP)
373
+ : rawText;
374
+ return {
375
+ text,
376
+ newSessionId,
377
+ ...(state.errorText ? { error: state.errorText } : {}),
378
+ };
379
+ }
380
+ withTimeout(p, ms, label) {
381
+ return new Promise((resolve, reject) => {
382
+ const t = setTimeout(() => reject(new Error(`${this.id} ${label} timed out after ${ms}ms`)), ms);
383
+ if (typeof t.unref === "function")
384
+ t.unref();
385
+ p.then((v) => {
386
+ clearTimeout(t);
387
+ resolve(v);
388
+ }, (e) => {
389
+ clearTimeout(t);
390
+ reject(e);
391
+ });
392
+ });
393
+ }
394
+ }
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { agentCodexHomeDir, ensureAgentCodexHome } from "../../agent-workspace.js";
5
+ import { buildCliEnv } from "../cli-resolver.js";
5
6
  import { NdjsonStreamAdapter } from "./ndjson-stream.js";
6
7
  import { firstExistingPath, readCommandVersion, resolveCommandOnPath, } from "./probe.js";
7
8
  const CODEX_DESKTOP_BUNDLE_PATH = "/Applications/Codex.app/Contents/Resources/codex";
@@ -188,8 +189,14 @@ export class CodexAdapter extends NdjsonStreamAdapter {
188
189
  return ["exec", ...tail, "--", prompt];
189
190
  }
190
191
  spawnEnv(opts) {
192
+ const cliEnv = buildCliEnv({
193
+ hubUrl: opts.hubUrl,
194
+ accountId: opts.accountId,
195
+ basePath: process.env.PATH,
196
+ });
191
197
  const env = {
192
198
  ...process.env,
199
+ ...cliEnv,
193
200
  // Keep JSONL free of ANSI codes regardless of user terminal settings.
194
201
  FORCE_COLOR: "0",
195
202
  NO_COLOR: "1",
@@ -0,0 +1,83 @@
1
+ import { AcpRuntimeAdapter, type AcpPermissionRequest, type AcpPermissionResponse, type AcpUpdateCtx, type AcpUpdateParams } from "./acp-stream.js";
2
+ import { type ProbeDeps } from "./probe.js";
3
+ import type { RuntimeProbeResult, RuntimeRunOptions } from "../types.js";
4
+ /** Resolve the `hermes-acp` executable on PATH. */
5
+ export declare function resolveHermesAcpCommand(deps?: ProbeDeps): string | null;
6
+ /** Probe whether `hermes-acp` is installed and report its version. */
7
+ export declare function probeHermesAgent(deps?: ProbeDeps): RuntimeProbeResult;
8
+ /**
9
+ * Hermes Agent adapter. Drives `hermes-acp` (the ACP stdio adapter shipped
10
+ * with `pip install "hermes-agent[acp]"`).
11
+ *
12
+ * ## systemContext injection
13
+ *
14
+ * Hermes discovers `AGENTS.md` from the spawn cwd upward. We point cwd at a
15
+ * runtime-private directory (`~/.botcord/agents/<id>/hermes-workspace/`) and
16
+ * write `<cwd>/AGENTS.md` from `opts.systemContext` before spawn. This is a
17
+ * **first-turn-only** injection: hermes persists the system prompt in the
18
+ * session DB and does not re-read AGENTS.md on continuation turns. The
19
+ * design doc tracks this as a known limitation; a follow-up PR to
20
+ * hermes-agent would expose a per-turn ephemeral prompt channel.
21
+ *
22
+ * ## Per-agent isolation
23
+ *
24
+ * - `HERMES_HOME` → `<agent-home>/hermes-home/` so `.env`, `state.db`,
25
+ * `skills/` per-agent are isolated from `~/.hermes`.
26
+ * - cwd → `<agent-home>/hermes-workspace/` (NOT the user-editable
27
+ * `<agent-home>/workspace/`) so each turn's daemon-rewritten AGENTS.md
28
+ * does not clobber files the user/agent edited.
29
+ *
30
+ * ## Permission policy (trustLevel → ACP outcome)
31
+ *
32
+ * `HERMES_INTERACTIVE=1` makes hermes route dangerous tool calls through the
33
+ * ACP `session/request_permission` reverse-call. We answer per trustLevel:
34
+ * - `owner` → always select an `allow_*` option
35
+ * - `trusted` → same; reasons go to the daemon log only
36
+ * - `public` → cancel (DeniedOutcome) for all writes/exec
37
+ */
38
+ export declare class HermesAgentAdapter extends AcpRuntimeAdapter {
39
+ readonly id: "hermes-agent";
40
+ private readonly explicitBinary;
41
+ private resolvedBinary;
42
+ constructor(opts?: {
43
+ binary?: string;
44
+ });
45
+ probe(): RuntimeProbeResult;
46
+ protected resolveBinary(): string;
47
+ /**
48
+ * hermes-acp is invoked with no positional args — ACP is pure stdio
49
+ * JSON-RPC. We do not forward `opts.extraArgs` because hermes-acp does
50
+ * not accept CLI flags for runtime config; per-agent config goes in
51
+ * `<HERMES_HOME>/.env`.
52
+ */
53
+ protected buildArgs(_opts: RuntimeRunOptions): string[];
54
+ protected spawnEnv(opts: RuntimeRunOptions): NodeJS.ProcessEnv;
55
+ protected sessionCwd(opts: RuntimeRunOptions): string;
56
+ /**
57
+ * Write systemContext to `<hermes-workspace>/AGENTS.md` atomically before
58
+ * spawn. NOTE: hermes only reads this file on the first turn of a session
59
+ * (see class-level docstring); subsequent turns keep the persisted
60
+ * system prompt and ignore filesystem changes.
61
+ */
62
+ protected prepareTurn(opts: RuntimeRunOptions): void;
63
+ /** Spawn with the runtime-private hermes-workspace as cwd. */
64
+ run(opts: RuntimeRunOptions): Promise<import("../types.js").RuntimeRunResult>;
65
+ /**
66
+ * Translate ACP `session/update` notifications into StreamBlocks +
67
+ * assistant text. We surface the common shapes that hermes emits:
68
+ * - `agent_message_chunk` / `user_message_chunk` content blocks
69
+ * - `tool_call` / `tool_call_update`
70
+ * - `agent_thought_chunk`
71
+ *
72
+ * Anything else is forwarded as `kind: "other"` so subclasses /
73
+ * downstream channels can introspect.
74
+ */
75
+ protected onUpdate(params: AcpUpdateParams, ctx: AcpUpdateCtx): void;
76
+ /**
77
+ * trustLevel-driven policy. We pick the FIRST option whose `kind` matches
78
+ * our intent — `allow_*` for permit, otherwise cancel. ACP's
79
+ * DeniedOutcome carries no `optionId` / `reason` field; rationale lives
80
+ * in the daemon log.
81
+ */
82
+ protected onPermissionRequest(req: AcpPermissionRequest, opts: RuntimeRunOptions): Promise<AcpPermissionResponse>;
83
+ }
@@ -0,0 +1,180 @@
1
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { agentHermesHomeDir, agentHermesWorkspaceDir, ensureAgentHermesWorkspace, } from "../../agent-workspace.js";
4
+ import { buildCliEnv } from "../cli-resolver.js";
5
+ import { AcpRuntimeAdapter, } from "./acp-stream.js";
6
+ import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
7
+ /** Resolve the `hermes-acp` executable on PATH. */
8
+ export function resolveHermesAcpCommand(deps = {}) {
9
+ return resolveCommandOnPath("hermes-acp", deps);
10
+ }
11
+ /** Probe whether `hermes-acp` is installed and report its version. */
12
+ export function probeHermesAgent(deps = {}) {
13
+ const command = resolveHermesAcpCommand(deps);
14
+ if (!command)
15
+ return { available: false };
16
+ return {
17
+ available: true,
18
+ path: command,
19
+ version: readCommandVersion(command, [], deps) ?? undefined,
20
+ };
21
+ }
22
+ /**
23
+ * Hermes Agent adapter. Drives `hermes-acp` (the ACP stdio adapter shipped
24
+ * with `pip install "hermes-agent[acp]"`).
25
+ *
26
+ * ## systemContext injection
27
+ *
28
+ * Hermes discovers `AGENTS.md` from the spawn cwd upward. We point cwd at a
29
+ * runtime-private directory (`~/.botcord/agents/<id>/hermes-workspace/`) and
30
+ * write `<cwd>/AGENTS.md` from `opts.systemContext` before spawn. This is a
31
+ * **first-turn-only** injection: hermes persists the system prompt in the
32
+ * session DB and does not re-read AGENTS.md on continuation turns. The
33
+ * design doc tracks this as a known limitation; a follow-up PR to
34
+ * hermes-agent would expose a per-turn ephemeral prompt channel.
35
+ *
36
+ * ## Per-agent isolation
37
+ *
38
+ * - `HERMES_HOME` → `<agent-home>/hermes-home/` so `.env`, `state.db`,
39
+ * `skills/` per-agent are isolated from `~/.hermes`.
40
+ * - cwd → `<agent-home>/hermes-workspace/` (NOT the user-editable
41
+ * `<agent-home>/workspace/`) so each turn's daemon-rewritten AGENTS.md
42
+ * does not clobber files the user/agent edited.
43
+ *
44
+ * ## Permission policy (trustLevel → ACP outcome)
45
+ *
46
+ * `HERMES_INTERACTIVE=1` makes hermes route dangerous tool calls through the
47
+ * ACP `session/request_permission` reverse-call. We answer per trustLevel:
48
+ * - `owner` → always select an `allow_*` option
49
+ * - `trusted` → same; reasons go to the daemon log only
50
+ * - `public` → cancel (DeniedOutcome) for all writes/exec
51
+ */
52
+ export class HermesAgentAdapter extends AcpRuntimeAdapter {
53
+ id = "hermes-agent";
54
+ explicitBinary;
55
+ resolvedBinary = null;
56
+ constructor(opts) {
57
+ super();
58
+ this.explicitBinary = opts?.binary ?? process.env.BOTCORD_HERMES_AGENT_BIN;
59
+ }
60
+ probe() {
61
+ return probeHermesAgent();
62
+ }
63
+ resolveBinary() {
64
+ if (this.explicitBinary)
65
+ return this.explicitBinary;
66
+ if (this.resolvedBinary)
67
+ return this.resolvedBinary;
68
+ this.resolvedBinary = resolveHermesAcpCommand() ?? "hermes-acp";
69
+ return this.resolvedBinary;
70
+ }
71
+ /**
72
+ * hermes-acp is invoked with no positional args — ACP is pure stdio
73
+ * JSON-RPC. We do not forward `opts.extraArgs` because hermes-acp does
74
+ * not accept CLI flags for runtime config; per-agent config goes in
75
+ * `<HERMES_HOME>/.env`.
76
+ */
77
+ buildArgs(_opts) {
78
+ return [];
79
+ }
80
+ spawnEnv(opts) {
81
+ const cliEnv = buildCliEnv({
82
+ hubUrl: opts.hubUrl,
83
+ accountId: opts.accountId,
84
+ basePath: process.env.PATH,
85
+ });
86
+ const env = {
87
+ ...process.env,
88
+ ...cliEnv,
89
+ // Keep ACP stdout free of ANSI codes regardless of terminal settings.
90
+ NO_COLOR: "1",
91
+ // Route dangerous tool calls through ACP request_permission.
92
+ HERMES_INTERACTIVE: "1",
93
+ };
94
+ if (opts.accountId) {
95
+ env.HERMES_HOME = agentHermesHomeDir(opts.accountId);
96
+ }
97
+ return env;
98
+ }
99
+ sessionCwd(opts) {
100
+ if (opts.accountId)
101
+ return agentHermesWorkspaceDir(opts.accountId);
102
+ return opts.cwd;
103
+ }
104
+ /**
105
+ * Write systemContext to `<hermes-workspace>/AGENTS.md` atomically before
106
+ * spawn. NOTE: hermes only reads this file on the first turn of a session
107
+ * (see class-level docstring); subsequent turns keep the persisted
108
+ * system prompt and ignore filesystem changes.
109
+ */
110
+ prepareTurn(opts) {
111
+ if (!opts.accountId)
112
+ return;
113
+ const { hermesWorkspace } = ensureAgentHermesWorkspace(opts.accountId);
114
+ const target = path.join(hermesWorkspace, "AGENTS.md");
115
+ const tmp = path.join(hermesWorkspace, `.AGENTS.md.${process.pid}.tmp`);
116
+ mkdirSync(hermesWorkspace, { recursive: true, mode: 0o700 });
117
+ writeFileSync(tmp, opts.systemContext ?? "", { mode: 0o600 });
118
+ renameSync(tmp, target);
119
+ }
120
+ /** Spawn with the runtime-private hermes-workspace as cwd. */
121
+ async run(opts) {
122
+ const effective = opts.accountId
123
+ ? { ...opts, cwd: agentHermesWorkspaceDir(opts.accountId) }
124
+ : opts;
125
+ return super.run(effective);
126
+ }
127
+ /**
128
+ * Translate ACP `session/update` notifications into StreamBlocks +
129
+ * assistant text. We surface the common shapes that hermes emits:
130
+ * - `agent_message_chunk` / `user_message_chunk` content blocks
131
+ * - `tool_call` / `tool_call_update`
132
+ * - `agent_thought_chunk`
133
+ *
134
+ * Anything else is forwarded as `kind: "other"` so subclasses /
135
+ * downstream channels can introspect.
136
+ */
137
+ onUpdate(params, ctx) {
138
+ const update = params.update ?? {};
139
+ const kind = typeof update.sessionUpdate === "string" ? update.sessionUpdate : "";
140
+ let blockKind = "other";
141
+ if (kind === "agent_message_chunk") {
142
+ const content = update
143
+ .content;
144
+ if (content && content.type === "text" && typeof content.text === "string") {
145
+ ctx.appendAssistantText(content.text);
146
+ }
147
+ blockKind = "assistant_text";
148
+ }
149
+ else if (kind === "agent_thought_chunk") {
150
+ blockKind = "system";
151
+ }
152
+ else if (kind === "tool_call" || kind === "tool_call_update") {
153
+ blockKind = "tool_use";
154
+ }
155
+ else if (kind === "user_message_chunk") {
156
+ blockKind = "other";
157
+ }
158
+ ctx.emitBlock({ raw: params, kind: blockKind, seq: ctx.seq });
159
+ }
160
+ /**
161
+ * trustLevel-driven policy. We pick the FIRST option whose `kind` matches
162
+ * our intent — `allow_*` for permit, otherwise cancel. ACP's
163
+ * DeniedOutcome carries no `optionId` / `reason` field; rationale lives
164
+ * in the daemon log.
165
+ */
166
+ async onPermissionRequest(req, opts) {
167
+ const options = Array.isArray(req.options) ? req.options : [];
168
+ const trust = opts.trustLevel;
169
+ if (trust === "owner" || trust === "trusted") {
170
+ const allow = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow_")) ??
171
+ options[0];
172
+ if (allow?.optionId) {
173
+ return { outcome: { outcome: "selected", optionId: allow.optionId } };
174
+ }
175
+ return { outcome: { outcome: "cancelled" } };
176
+ }
177
+ // public: deny everything that requires explicit approval
178
+ return { outcome: { outcome: "cancelled" } };
179
+ }
180
+ }