@botcord/daemon 0.2.5 → 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 (84) 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 +49 -1
  6. package/dist/config.js +57 -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 +52 -5
  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 +286 -27
  40. package/dist/mention-scan.d.ts +22 -0
  41. package/dist/mention-scan.js +35 -0
  42. package/dist/provision.d.ts +72 -1
  43. package/dist/provision.js +370 -7
  44. package/dist/system-context.d.ts +5 -4
  45. package/dist/system-context.js +35 -5
  46. package/dist/url-utils.d.ts +9 -0
  47. package/dist/url-utils.js +18 -0
  48. package/package.json +2 -1
  49. package/src/__tests__/agent-workspace.test.ts +93 -0
  50. package/src/__tests__/daemon-config-map.test.ts +79 -0
  51. package/src/__tests__/openclaw-acp.test.ts +234 -0
  52. package/src/__tests__/policy-resolver.test.ts +124 -0
  53. package/src/__tests__/policy-updated-handler.test.ts +144 -0
  54. package/src/__tests__/provision.test.ts +160 -0
  55. package/src/__tests__/system-context.test.ts +52 -0
  56. package/src/__tests__/url-utils.test.ts +37 -0
  57. package/src/agent-discovery.ts +8 -0
  58. package/src/agent-workspace.ts +173 -7
  59. package/src/config.ts +132 -4
  60. package/src/daemon-config-map.ts +154 -9
  61. package/src/daemon.ts +66 -5
  62. package/src/doctor.ts +49 -2
  63. package/src/gateway/__tests__/dispatcher.test.ts +65 -0
  64. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
  65. package/src/gateway/__tests__/transcript.test.ts +496 -0
  66. package/src/gateway/cli-resolver.ts +92 -0
  67. package/src/gateway/dispatcher.ts +394 -26
  68. package/src/gateway/gateway.ts +46 -0
  69. package/src/gateway/index.ts +25 -0
  70. package/src/gateway/policy-resolver.ts +171 -0
  71. package/src/gateway/runtimes/acp-stream.ts +535 -0
  72. package/src/gateway/runtimes/codex.ts +7 -0
  73. package/src/gateway/runtimes/hermes-agent.ts +206 -0
  74. package/src/gateway/runtimes/ndjson-stream.ts +16 -3
  75. package/src/gateway/runtimes/openclaw-acp.ts +606 -0
  76. package/src/gateway/runtimes/registry.ts +24 -0
  77. package/src/gateway/transcript-paths.ts +145 -0
  78. package/src/gateway/transcript.ts +300 -0
  79. package/src/gateway/types.ts +32 -0
  80. package/src/index.ts +295 -30
  81. package/src/mention-scan.ts +38 -0
  82. package/src/provision.ts +438 -9
  83. package/src/system-context.ts +41 -9
  84. package/src/url-utils.ts +17 -0
@@ -0,0 +1,500 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readCommandVersion, resolveCommandOnPath, } from "./probe.js";
3
+ import { consoleLogger } from "../log.js";
4
+ const log = consoleLogger;
5
+ const ACP_PROTOCOL_VERSION = 1;
6
+ /** How long an idle (no in-flight prompt) ACP child process is kept alive. */
7
+ const ACP_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
8
+ /** Cap for streamed assistant text per turn. */
9
+ const ASSISTANT_TEXT_CAP = 1 * 1024 * 1024;
10
+ const ACP_POOL = new Map();
11
+ function poolKey(accountId, gatewayName) {
12
+ return `${accountId}::${gatewayName}`;
13
+ }
14
+ function resetIdle(h, key) {
15
+ if (h.idleTimer)
16
+ clearTimeout(h.idleTimer);
17
+ if (h.inFlight > 0)
18
+ return;
19
+ h.idleTimer = setTimeout(() => {
20
+ if (h.inFlight === 0 && !h.closed) {
21
+ log.info("openclaw-acp.idle-timeout", { key });
22
+ shutdownHandle(h, "idle-timeout");
23
+ ACP_POOL.delete(key);
24
+ }
25
+ }, ACP_IDLE_TIMEOUT_MS);
26
+ h.idleTimer.unref?.();
27
+ }
28
+ function shutdownHandle(h, reason) {
29
+ if (h.closed)
30
+ return;
31
+ h.closed = true;
32
+ h.exitReason = reason;
33
+ if (h.idleTimer)
34
+ clearTimeout(h.idleTimer);
35
+ for (const p of h.pending.values()) {
36
+ p.reject(new Error(`openclaw acp child closed: ${reason}`));
37
+ }
38
+ h.pending.clear();
39
+ h.subscribers.clear();
40
+ try {
41
+ h.child.kill("SIGTERM");
42
+ }
43
+ catch {
44
+ // already dead
45
+ }
46
+ }
47
+ /** Test-only: drop all cached child processes. */
48
+ export function __resetOpenclawAcpPoolForTests() {
49
+ for (const [key, h] of ACP_POOL.entries()) {
50
+ shutdownHandle(h, "test-reset");
51
+ ACP_POOL.delete(key);
52
+ }
53
+ }
54
+ // ---------------------------------------------------------------------------
55
+ // Probe
56
+ // ---------------------------------------------------------------------------
57
+ function resolveOpenclawCommand(deps = {}) {
58
+ const explicit = (deps.env ?? process.env).BOTCORD_OPENCLAW_BIN;
59
+ if (explicit && explicit.length > 0)
60
+ return explicit;
61
+ return resolveCommandOnPath("openclaw", deps);
62
+ }
63
+ export function probeOpenclaw(deps = {}) {
64
+ const command = resolveOpenclawCommand(deps);
65
+ if (!command)
66
+ return { available: false };
67
+ return {
68
+ available: true,
69
+ path: command,
70
+ version: readCommandVersion(command, [], deps) ?? undefined,
71
+ };
72
+ }
73
+ /**
74
+ * OpenClaw ACP runtime adapter.
75
+ *
76
+ * Spawns `openclaw acp --url <gateway> [--token <token>]` per
77
+ * `(accountId, gatewayName)` pair and reuses the process across turns. The
78
+ * child speaks JSON-RPC over stdio; we send `initialize` once, then
79
+ * `newSession` (with `_meta.sessionKey`) when the daemon has no persisted
80
+ * runtime session id, and `prompt` for each turn. Streaming `session/update`
81
+ * notifications are relayed to `onBlock`.
82
+ *
83
+ * Process-pool lifetime + abort/cancel semantics live at module scope; see
84
+ * `ACP_POOL` and `shutdownHandle` above.
85
+ */
86
+ export class OpenclawAcpAdapter {
87
+ id = "openclaw-acp";
88
+ spawnFn;
89
+ constructor(deps = {}) {
90
+ this.spawnFn = deps.spawnFn ?? spawn;
91
+ }
92
+ probe() {
93
+ return probeOpenclaw();
94
+ }
95
+ async run(opts) {
96
+ const gateway = opts.gateway;
97
+ if (!gateway) {
98
+ return failResult(opts.sessionId ?? "", "openclaw-acp: missing gateway endpoint (route.gateway not resolved)");
99
+ }
100
+ if (!gateway.openclawAgent) {
101
+ return failResult(opts.sessionId ?? "", `openclaw-acp: gateway "${gateway.name}" did not resolve an openclawAgent (set defaultAgent on the profile or openclawAgent on the route)`);
102
+ }
103
+ const sessionKey = buildAcpSessionKey({
104
+ openclawAgent: gateway.openclawAgent,
105
+ accountId: opts.accountId,
106
+ // The dispatcher passes `context.conversationKey` in for routing;
107
+ // fall back to a stable per-accountId key when it's not present (e.g.
108
+ // synthetic test calls).
109
+ conversationKey: stringField(opts.context, "conversationKey") ?? "default",
110
+ });
111
+ const key = poolKey(opts.accountId, gateway.name);
112
+ let handle;
113
+ try {
114
+ handle = await this.acquireHandle(key, opts, gateway);
115
+ }
116
+ catch (err) {
117
+ return failResult(opts.sessionId ?? "", `openclaw-acp: ${err.message}`);
118
+ }
119
+ handle.inFlight += 1;
120
+ if (handle.idleTimer)
121
+ clearTimeout(handle.idleTimer);
122
+ let acpSessionId = opts.sessionId ?? "";
123
+ let seq = 0;
124
+ let assistantText = "";
125
+ let assistantBytes = 0;
126
+ let capped = false;
127
+ let finalText = "";
128
+ const emitBlock = (block) => {
129
+ try {
130
+ opts.onBlock?.(block);
131
+ }
132
+ catch (err) {
133
+ log.warn("openclaw-acp.onBlock-threw", {
134
+ error: err instanceof Error ? err.message : String(err),
135
+ });
136
+ }
137
+ };
138
+ const onNotification = (note) => {
139
+ seq += 1;
140
+ // Forward raw notification as a stream block for downstream visibility.
141
+ const kind = classifyAcpUpdate(note);
142
+ emitBlock({ raw: note, kind, seq });
143
+ const update = note.params?.update;
144
+ if (update?.sessionUpdate === "agent_message_chunk") {
145
+ const text = extractText(update.content);
146
+ if (text && !capped) {
147
+ const bytes = Buffer.byteLength(text, "utf8");
148
+ if (assistantBytes + bytes > ASSISTANT_TEXT_CAP) {
149
+ capped = true;
150
+ }
151
+ else {
152
+ assistantText += text;
153
+ assistantBytes += bytes;
154
+ }
155
+ }
156
+ }
157
+ };
158
+ let abortListener;
159
+ try {
160
+ // Ensure we have an ACP session id. When the dispatcher doesn't carry
161
+ // one, ask the child to create or rebind one for our sessionKey.
162
+ if (!acpSessionId) {
163
+ try {
164
+ acpSessionId = await this.newSession(handle, {
165
+ cwd: opts.cwd,
166
+ sessionKey,
167
+ });
168
+ }
169
+ catch (err) {
170
+ throw new Error(`newSession failed: ${err.message}`);
171
+ }
172
+ }
173
+ handle.subscribers.set(acpSessionId, onNotification);
174
+ if (opts.signal?.aborted) {
175
+ return failResult(acpSessionId, "openclaw-acp: aborted before prompt");
176
+ }
177
+ abortListener = () => {
178
+ // Best-effort cancel; ACP `cancel` is a notification (fire-and-forget).
179
+ sendNotification(handle, "session/cancel", { sessionId: acpSessionId });
180
+ };
181
+ opts.signal?.addEventListener("abort", abortListener);
182
+ let promptResult;
183
+ try {
184
+ promptResult = await this.prompt(handle, {
185
+ sessionId: acpSessionId,
186
+ text: opts.text,
187
+ });
188
+ }
189
+ catch (err) {
190
+ const msg = err.message ?? "prompt failed";
191
+ // If the child says the session is gone (process restart, GC),
192
+ // recreate it so the next turn doesn't hard-fail.
193
+ if (/session not found|unknown session/i.test(msg)) {
194
+ try {
195
+ const fresh = await this.newSession(handle, {
196
+ cwd: opts.cwd,
197
+ sessionKey,
198
+ });
199
+ handle.subscribers.delete(acpSessionId);
200
+ acpSessionId = fresh;
201
+ handle.subscribers.set(acpSessionId, onNotification);
202
+ promptResult = await this.prompt(handle, {
203
+ sessionId: acpSessionId,
204
+ text: opts.text,
205
+ });
206
+ }
207
+ catch (err2) {
208
+ throw new Error(`prompt failed after session reset: ${err2.message}`);
209
+ }
210
+ }
211
+ else {
212
+ throw err;
213
+ }
214
+ }
215
+ // OpenClaw's prompt response shape isn't strictly fixed; pull a final
216
+ // text out of common locations and otherwise fall back to the streamed
217
+ // chunks accumulated above.
218
+ finalText = pickFinalText(promptResult) ?? assistantText;
219
+ if (capped) {
220
+ log.warn("openclaw-acp.assistant-text-capped", { sessionId: acpSessionId });
221
+ }
222
+ return {
223
+ text: finalText,
224
+ newSessionId: acpSessionId,
225
+ };
226
+ }
227
+ catch (err) {
228
+ return failResult(acpSessionId, `openclaw-acp: ${err.message}`);
229
+ }
230
+ finally {
231
+ if (abortListener && opts.signal) {
232
+ try {
233
+ opts.signal.removeEventListener("abort", abortListener);
234
+ }
235
+ catch {
236
+ // ignore
237
+ }
238
+ }
239
+ handle.subscribers.delete(acpSessionId);
240
+ handle.inFlight = Math.max(0, handle.inFlight - 1);
241
+ resetIdle(handle, key);
242
+ }
243
+ }
244
+ // ---------------------------------------------------------------------
245
+ // Process management
246
+ // ---------------------------------------------------------------------
247
+ async acquireHandle(key, opts, gateway) {
248
+ let handle = ACP_POOL.get(key);
249
+ if (handle && handle.closed) {
250
+ ACP_POOL.delete(key);
251
+ handle = undefined;
252
+ }
253
+ // Invalidate the cached child if its spawn args drifted from the live
254
+ // gateway endpoint — config reload / token rotation under the same
255
+ // profile name must not keep talking to the old --url / --token.
256
+ if (handle &&
257
+ (handle.spawnedUrl !== gateway.url || handle.spawnedToken !== gateway.token)) {
258
+ log.info("openclaw-acp.gateway-args-changed", {
259
+ key,
260
+ oldUrl: handle.spawnedUrl,
261
+ newUrl: gateway.url,
262
+ tokenChanged: handle.spawnedToken !== gateway.token,
263
+ });
264
+ shutdownHandle(handle, "gateway-args-changed");
265
+ ACP_POOL.delete(key);
266
+ handle = undefined;
267
+ }
268
+ if (!handle) {
269
+ handle = this.spawnAcpProcess(key, gateway);
270
+ ACP_POOL.set(key, handle);
271
+ }
272
+ if (!handle.initialized) {
273
+ if (!handle.initializePromise) {
274
+ handle.initializePromise = sendRequest(handle, "initialize", {
275
+ protocolVersion: ACP_PROTOCOL_VERSION,
276
+ clientCapabilities: {},
277
+ }).then(() => {
278
+ handle.initialized = true;
279
+ });
280
+ }
281
+ await handle.initializePromise;
282
+ }
283
+ return handle;
284
+ }
285
+ spawnAcpProcess(key, gateway) {
286
+ const command = resolveOpenclawCommand() ?? "openclaw";
287
+ const args = ["acp", "--url", gateway.url];
288
+ if (gateway.token)
289
+ args.push("--token", gateway.token);
290
+ const child = this.spawnFn(command, args, {
291
+ stdio: ["pipe", "pipe", "pipe"],
292
+ env: { ...process.env },
293
+ });
294
+ const handle = {
295
+ child,
296
+ pending: new Map(),
297
+ subscribers: new Map(),
298
+ nextId: 1,
299
+ buffer: "",
300
+ initialized: false,
301
+ inFlight: 0,
302
+ closed: false,
303
+ spawnedUrl: gateway.url,
304
+ spawnedToken: gateway.token,
305
+ };
306
+ child.stdout.setEncoding("utf8");
307
+ child.stdout.on("data", (chunk) => onStdoutChunk(handle, chunk));
308
+ child.stderr.setEncoding("utf8");
309
+ child.stderr.on("data", (chunk) => {
310
+ log.debug("openclaw-acp.stderr", { key, chunk: chunk.slice(0, 500) });
311
+ });
312
+ child.on("exit", (code, signal) => {
313
+ shutdownHandle(handle, `exit code=${code ?? "null"} signal=${signal ?? "null"}`);
314
+ ACP_POOL.delete(key);
315
+ });
316
+ child.on("error", (err) => {
317
+ log.warn("openclaw-acp.child-error", {
318
+ key,
319
+ error: err instanceof Error ? err.message : String(err),
320
+ });
321
+ shutdownHandle(handle, `error: ${err.message}`);
322
+ ACP_POOL.delete(key);
323
+ });
324
+ return handle;
325
+ }
326
+ async newSession(handle, args) {
327
+ const result = (await sendRequest(handle, "session/new", {
328
+ cwd: args.cwd,
329
+ mcpServers: [],
330
+ _meta: { sessionKey: args.sessionKey },
331
+ }));
332
+ if (!result?.sessionId || typeof result.sessionId !== "string") {
333
+ throw new Error("newSession returned no sessionId");
334
+ }
335
+ return result.sessionId;
336
+ }
337
+ async prompt(handle, args) {
338
+ return sendRequest(handle, "session/prompt", {
339
+ sessionId: args.sessionId,
340
+ prompt: [{ type: "text", text: args.text }],
341
+ });
342
+ }
343
+ }
344
+ // ---------------------------------------------------------------------------
345
+ // JSON-RPC stdio plumbing
346
+ // ---------------------------------------------------------------------------
347
+ function onStdoutChunk(handle, chunk) {
348
+ handle.buffer += chunk;
349
+ let idx;
350
+ while ((idx = handle.buffer.indexOf("\n")) !== -1) {
351
+ const line = handle.buffer.slice(0, idx).trim();
352
+ handle.buffer = handle.buffer.slice(idx + 1);
353
+ if (!line)
354
+ continue;
355
+ let msg;
356
+ try {
357
+ msg = JSON.parse(line);
358
+ }
359
+ catch (err) {
360
+ log.warn("openclaw-acp.parse-error", {
361
+ error: err instanceof Error ? err.message : String(err),
362
+ line: line.slice(0, 200),
363
+ });
364
+ continue;
365
+ }
366
+ routeMessage(handle, msg);
367
+ }
368
+ }
369
+ function routeMessage(handle, msg) {
370
+ if (msg && typeof msg === "object" && "id" in msg && ("result" in msg || "error" in msg)) {
371
+ const id = typeof msg.id === "number" ? msg.id : Number(msg.id);
372
+ const pending = handle.pending.get(id);
373
+ if (!pending)
374
+ return;
375
+ handle.pending.delete(id);
376
+ if (msg.error) {
377
+ const message = typeof msg.error?.message === "string" ? msg.error.message : "rpc error";
378
+ pending.reject(new Error(message));
379
+ }
380
+ else {
381
+ pending.resolve(msg.result);
382
+ }
383
+ return;
384
+ }
385
+ // Notification.
386
+ if (msg?.method && msg?.params) {
387
+ const sid = msg.params?.sessionId;
388
+ if (typeof sid === "string") {
389
+ const sub = handle.subscribers.get(sid);
390
+ if (sub) {
391
+ try {
392
+ sub({ method: msg.method, params: msg.params });
393
+ }
394
+ catch (err) {
395
+ log.warn("openclaw-acp.subscriber-threw", {
396
+ error: err instanceof Error ? err.message : String(err),
397
+ });
398
+ }
399
+ }
400
+ }
401
+ }
402
+ }
403
+ function sendRequest(handle, method, params) {
404
+ if (handle.closed)
405
+ return Promise.reject(new Error("acp child closed"));
406
+ return new Promise((resolve, reject) => {
407
+ const id = handle.nextId++;
408
+ handle.pending.set(id, { resolve, reject, method });
409
+ const frame = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
410
+ try {
411
+ handle.child.stdin.write(frame);
412
+ }
413
+ catch (err) {
414
+ handle.pending.delete(id);
415
+ reject(err);
416
+ }
417
+ });
418
+ }
419
+ function sendNotification(handle, method, params) {
420
+ if (handle.closed)
421
+ return;
422
+ const frame = JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n";
423
+ try {
424
+ handle.child.stdin.write(frame);
425
+ }
426
+ catch {
427
+ // best-effort fire-and-forget
428
+ }
429
+ }
430
+ // ---------------------------------------------------------------------------
431
+ // Helpers
432
+ // ---------------------------------------------------------------------------
433
+ function failResult(sessionId, error) {
434
+ return {
435
+ text: "",
436
+ newSessionId: sessionId,
437
+ error,
438
+ };
439
+ }
440
+ function classifyAcpUpdate(note) {
441
+ const update = note.params?.update;
442
+ const kind = update?.sessionUpdate;
443
+ switch (kind) {
444
+ case "agent_message_chunk":
445
+ return "assistant_text";
446
+ case "tool_call":
447
+ return "tool_use";
448
+ case "tool_call_update":
449
+ return "tool_result";
450
+ case "session_info_update":
451
+ case "available_commands_update":
452
+ case "usage_update":
453
+ return "system";
454
+ default:
455
+ return "other";
456
+ }
457
+ }
458
+ function extractText(content) {
459
+ if (!content)
460
+ return "";
461
+ if (typeof content === "string")
462
+ return content;
463
+ if (Array.isArray(content)) {
464
+ return content.map(extractText).join("");
465
+ }
466
+ if (typeof content === "object") {
467
+ const c = content;
468
+ if (typeof c.text === "string")
469
+ return c.text;
470
+ if (typeof c.content === "string")
471
+ return c.content;
472
+ if (Array.isArray(c.content))
473
+ return extractText(c.content);
474
+ }
475
+ return "";
476
+ }
477
+ function pickFinalText(result) {
478
+ if (!result || typeof result !== "object")
479
+ return undefined;
480
+ const r = result;
481
+ if (typeof r.text === "string" && r.text.length > 0)
482
+ return r.text;
483
+ if (typeof r.message === "string" && r.message.length > 0)
484
+ return r.message;
485
+ return undefined;
486
+ }
487
+ function stringField(bag, key) {
488
+ if (!bag)
489
+ return undefined;
490
+ const v = bag[key];
491
+ return typeof v === "string" && v.length > 0 ? v : undefined;
492
+ }
493
+ /**
494
+ * Build the OpenClaw ACP `sessionKey` for a daemon turn. `accountId` is
495
+ * always included to prevent two daemon agents from colliding on the same
496
+ * gateway-side key (RFC §3.5.2 串号 防御).
497
+ */
498
+ export function buildAcpSessionKey(args) {
499
+ return `agent:${args.openclawAgent}:${args.accountId}:${args.conversationKey}`;
500
+ }
@@ -28,8 +28,12 @@ export interface RuntimeModule {
28
28
  export declare const claudeCodeModule: RuntimeModule;
29
29
  /** Built-in runtime module entry for Codex. */
30
30
  export declare const codexModule: RuntimeModule;
31
+ /** Built-in runtime module entry for Hermes Agent (ACP stdio). */
32
+ export declare const hermesAgentModule: RuntimeModule;
31
33
  /** Built-in runtime module entry for Gemini (probe-only stub). */
32
34
  export declare const geminiModule: RuntimeModule;
35
+ /** Built-in runtime module entry for OpenClaw (ACP). */
36
+ export declare const openclawAcpModule: RuntimeModule;
33
37
  /**
34
38
  * Built-in runtime modules. To add a new runtime:
35
39
  * 1. Create `runtimes/<name>.ts` extending `NdjsonStreamAdapter` (or
@@ -1,6 +1,8 @@
1
1
  import { ClaudeCodeAdapter, probeClaude } from "./claude-code.js";
2
2
  import { CodexAdapter, probeCodex } from "./codex.js";
3
3
  import { GeminiAdapter, probeGemini } from "./gemini.js";
4
+ import { HermesAgentAdapter, probeHermesAgent } from "./hermes-agent.js";
5
+ import { OpenclawAcpAdapter, probeOpenclaw } from "./openclaw-acp.js";
4
6
  /** Built-in runtime module entry for Claude Code. */
5
7
  export const claudeCodeModule = {
6
8
  id: "claude-code",
@@ -18,6 +20,15 @@ export const codexModule = {
18
20
  probe: () => probeCodex(),
19
21
  create: () => new CodexAdapter(),
20
22
  };
23
+ /** Built-in runtime module entry for Hermes Agent (ACP stdio). */
24
+ export const hermesAgentModule = {
25
+ id: "hermes-agent",
26
+ displayName: "Hermes Agent",
27
+ binary: "hermes-acp",
28
+ envVar: "BOTCORD_HERMES_AGENT_BIN",
29
+ probe: () => probeHermesAgent(),
30
+ create: () => new HermesAgentAdapter(),
31
+ };
21
32
  /** Built-in runtime module entry for Gemini (probe-only stub). */
22
33
  export const geminiModule = {
23
34
  id: "gemini",
@@ -27,6 +38,15 @@ export const geminiModule = {
27
38
  create: () => new GeminiAdapter(),
28
39
  supportsRun: false,
29
40
  };
41
+ /** Built-in runtime module entry for OpenClaw (ACP). */
42
+ export const openclawAcpModule = {
43
+ id: "openclaw-acp",
44
+ displayName: "OpenClaw (ACP)",
45
+ binary: "openclaw",
46
+ envVar: "BOTCORD_OPENCLAW_BIN",
47
+ probe: () => probeOpenclaw(),
48
+ create: () => new OpenclawAcpAdapter(),
49
+ };
30
50
  /**
31
51
  * Built-in runtime modules. To add a new runtime:
32
52
  * 1. Create `runtimes/<name>.ts` extending `NdjsonStreamAdapter` (or
@@ -36,7 +56,9 @@ export const geminiModule = {
36
56
  export const RUNTIME_MODULES = [
37
57
  claudeCodeModule,
38
58
  codexModule,
59
+ hermesAgentModule,
39
60
  geminiModule,
61
+ openclawAcpModule,
40
62
  ];
41
63
  const BY_ID = new Map(RUNTIME_MODULES.map((m) => [m.id, m]));
42
64
  /** Lookup a runtime module by id, or null when the id is unknown. */
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Convert a raw ID into a filesystem-safe path segment.
3
+ *
4
+ * Order (must not be reordered — see design §3.1):
5
+ * 1. obviously invalid (empty / `.` / `..` / all control/NUL)
6
+ * → `_invalid_<sha256-8>`
7
+ * 2. Windows reserved name (CON/PRN/AUX/NUL/COM1-9/LPT1-9, case-insensitive)
8
+ * → `_win_<raw>`
9
+ * 3. fast path (`^[A-Za-z0-9_-]{1,128}$`) → return raw
10
+ * 4. percent-encode non-whitelist bytes; truncate at 200 chars without
11
+ * splitting a `%XX` (191 prefix + `_` + sha256-8)
12
+ *
13
+ * The original ID is always written into the transcript record itself; this
14
+ * helper only sanitizes the on-disk filename.
15
+ */
16
+ export declare function safePathSegment(raw: string): string;
17
+ /**
18
+ * Resolve the on-disk transcript file for a given (agent, room, topic). Used
19
+ * by the writer AND the CLI subcommands so both look at the same file.
20
+ *
21
+ * Layout (design §3.1):
22
+ * <rootDir>/<agentId>/transcripts/<roomId>/<topicId|_default>.jsonl
23
+ *
24
+ * Where <rootDir> is typically `~/.botcord/agents`.
25
+ */
26
+ export declare function transcriptFilePath(rootDir: string, agentId: string, roomId: string, topicId: string | null): string;
27
+ /** Directory holding a (agent, room) pair's transcript files. */
28
+ export declare function transcriptRoomDir(rootDir: string, agentId: string, roomId: string): string;
29
+ /** Directory holding all transcript rooms for a single agent. */
30
+ export declare function transcriptAgentRoot(rootDir: string, agentId: string): string;