@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
@@ -7,8 +7,14 @@
7
7
  * codex-home/ — per-agent CODEX_HOME used by the codex adapter so codex
8
8
  * reads a daemon-written AGENTS.md (systemContext carrier)
9
9
  * and stores its sessions/ without touching ~/.codex.
10
- *
11
- * See docs/daemon-agent-workspace-plan.md §4 for the full layout rationale.
10
+ * hermes-home/ — per-agent HERMES_HOME used by the hermes-acp
11
+ * adapter (carries .env, state.db, skills/) so
12
+ * hermes-acp's per-user state stays isolated.
13
+ * hermes-workspace/ — per-agent runtime cwd for hermes-acp; the adapter
14
+ * writes systemContext into AGENTS.md here every turn.
15
+ * Kept separate from `workspace/` so daemon-written
16
+ * systemContext does not clobber the user/agent-
17
+ * editable workspace AGENTS.md.
12
18
  */
13
19
  import {
14
20
  chmodSync,
@@ -16,6 +22,7 @@ import {
16
22
  existsSync,
17
23
  lstatSync,
18
24
  mkdirSync,
25
+ readFileSync,
19
26
  readlinkSync,
20
27
  symlinkSync,
21
28
  unlinkSync,
@@ -60,6 +67,26 @@ export function agentCodexHomeDir(agentId: string): string {
60
67
  return path.join(agentHomeDir(agentId), "codex-home");
61
68
  }
62
69
 
70
+ /**
71
+ * Per-agent HERMES_HOME. Carries the hermes-acp `.env`, `state.db`, and
72
+ * `skills/` so each daemon-managed agent has an isolated hermes config
73
+ * tree and never reads/writes the user's `~/.hermes`.
74
+ */
75
+ export function agentHermesHomeDir(agentId: string): string {
76
+ return path.join(agentHomeDir(agentId), "hermes-home");
77
+ }
78
+
79
+ /**
80
+ * Per-agent runtime cwd for hermes-acp. Distinct from `workspace/` so the
81
+ * adapter can rewrite `AGENTS.md` here every turn (carrying the dynamic
82
+ * systemContext) without clobbering the user/agent-editable workspace
83
+ * `AGENTS.md`. hermes discovers `AGENTS.md` from cwd upward, so the file
84
+ * must live alongside the spawn cwd.
85
+ */
86
+ export function agentHermesWorkspaceDir(agentId: string): string {
87
+ return path.join(agentHomeDir(agentId), "hermes-workspace");
88
+ }
89
+
63
90
  export interface WorkspaceSeed {
64
91
  displayName?: string;
65
92
  bio?: string;
@@ -89,10 +116,14 @@ This directory is your persistent workspace. You run with \`cwd\` set here.
89
116
 
90
117
  ## How to use this
91
118
 
92
- You are **instructed** to skim \`identity.md\`, \`memory.md\`, \`task.md\` before each
93
- response and to write back what changed after meaningful turns. Nothing in the
94
- runtime enforces this the daemon does not auto-load these files into your
95
- context. Treat AGENTS.md as a convention, not a mechanism.
119
+ - \`identity.md\` is **auto-loaded** by the daemon and injected into every turn's
120
+ system context as the \`[BotCord Identity]\` block. Edits to this file (yours,
121
+ the dashboard's via \`applyAgentIdentity\`, or a hello-snapshot reapply) take
122
+ effect on the next turn no restart needed.
123
+ - \`memory.md\` and \`task.md\` are **convention, not mechanism**. The daemon does
124
+ not auto-load them; you are instructed to skim them before responding and to
125
+ write back what changed after meaningful turns. Keep them tight enough to be
126
+ worth re-reading.
96
127
  `;
97
128
 
98
129
  const MEMORY_MD = `# Memory
@@ -100,9 +131,9 @@ const MEMORY_MD = `# Memory
100
131
  <!--
101
132
  Long-lived facts about the user, past decisions, and preferences that should
102
133
  survive across conversations. Organize by topic. Keep entries short. Prune
103
- regularly — AGENTS.md instructs the runtime to consult this file before each
104
- response, but nothing loads it automatically; keep it short enough to be
105
- worth re-reading.
134
+ regularly — AGENTS.md instructs you to consult this file before each
135
+ response, but nothing loads it automatically (unlike identity.md); keep it
136
+ short enough to be worth re-reading.
106
137
  -->
107
138
  `;
108
139
 
@@ -217,6 +248,28 @@ export function ensureAgentCodexHome(agentId: string): string {
217
248
  return dir;
218
249
  }
219
250
 
251
+ /**
252
+ * Idempotently create the per-agent HERMES_HOME and HERMES workspace
253
+ * directories. Writes a stub `.env` inside HERMES_HOME so hermes-acp's
254
+ * `_load_env` does not log "No .env found" on every spawn; users can edit
255
+ * this file to add API keys / model overrides.
256
+ */
257
+ export function ensureAgentHermesWorkspace(agentId: string): {
258
+ hermesHome: string;
259
+ hermesWorkspace: string;
260
+ } {
261
+ const hermesHome = agentHermesHomeDir(agentId);
262
+ const hermesWorkspace = agentHermesWorkspaceDir(agentId);
263
+ mkdirTolerant(hermesHome);
264
+ mkdirTolerant(hermesWorkspace);
265
+ writeIfMissing(
266
+ path.join(hermesHome, ".env"),
267
+ "# hermes-agent environment overrides for this BotCord agent.\n" +
268
+ "# Add e.g. HERMES_INFERENCE_PROVIDER=openrouter, OPENROUTER_API_KEY=...\n",
269
+ );
270
+ return { hermesHome, hermesWorkspace };
271
+ }
272
+
220
273
  /**
221
274
  * Idempotently create the agent's home / workspace / state directories and
222
275
  * seed the workspace Markdown files. Existing files are never overwritten —
@@ -235,6 +288,7 @@ export function ensureAgentWorkspace(agentId: string, seed: WorkspaceSeed): void
235
288
  mkdirTolerant(notes);
236
289
  mkdirTolerant(state);
237
290
  ensureAgentCodexHome(agentId);
291
+ ensureAgentHermesWorkspace(agentId);
238
292
 
239
293
  const agentsMdPath = path.join(workspace, "AGENTS.md");
240
294
  const claudeMdPath = path.join(workspace, "CLAUDE.md");
@@ -245,3 +299,113 @@ export function ensureAgentWorkspace(agentId: string, seed: WorkspaceSeed): void
245
299
  writeIfMissing(path.join(workspace, "task.md"), TASK_MD);
246
300
  writeIfMissing(path.join(notes, ".gitkeep"), "");
247
301
  }
302
+
303
+ /** Patch fields accepted by {@link applyAgentIdentity}. `bio = null` clears it. */
304
+ export interface AgentIdentityPatch {
305
+ displayName?: string;
306
+ bio?: string | null;
307
+ }
308
+
309
+ /**
310
+ * Result of applying an identity patch. `changed` is true only when the
311
+ * file was rewritten on disk; `skipped` reports why (no-op vs. unable).
312
+ */
313
+ export interface AgentIdentityApplyResult {
314
+ changed: boolean;
315
+ skipped?: "missing-file" | "no-change" | "unparseable";
316
+ }
317
+
318
+ const DISPLAY_NAME_LINE = /^- \*\*Display name\*\*: .*$/m;
319
+ // Match the Bio section's body. Anchor on the next `##` heading when one
320
+ // exists, otherwise consume to end-of-file — keeps the rewrite working when
321
+ // the user has stripped Role/Boundaries sections.
322
+ const BIO_SECTION = /(## Bio\n\n)([\s\S]*?)(\n+##\s|$)/;
323
+
324
+ /**
325
+ * Surgically rewrite the `Display name` and `Bio` fields inside an existing
326
+ * `identity.md`, preserving anything the user has authored elsewhere
327
+ * (Role / Boundaries / arbitrary new sections). No-op when the file is
328
+ * missing — provisioning will create it with the correct values, and
329
+ * subsequent hello snapshots simply reapply the dashboard truth.
330
+ *
331
+ * The identity.md template carries `Role` / `Boundaries` headings after
332
+ * `## Bio`; we anchor the Bio rewrite on "next `##`" so user-added
333
+ * paragraphs inside Bio are replaced wholesale (the dashboard is the
334
+ * source of truth) without disturbing siblings.
335
+ */
336
+ export function applyAgentIdentity(
337
+ agentId: string,
338
+ patch: AgentIdentityPatch,
339
+ ): AgentIdentityApplyResult {
340
+ assertSafeAgentId(agentId);
341
+ const file = path.join(agentWorkspaceDir(agentId), "identity.md");
342
+ if (!existsSync(file)) {
343
+ return { changed: false, skipped: "missing-file" };
344
+ }
345
+
346
+ let text: string;
347
+ try {
348
+ text = readFileSync(file, "utf8");
349
+ } catch {
350
+ return { changed: false, skipped: "missing-file" };
351
+ }
352
+
353
+ const original = text;
354
+ let touched = false;
355
+
356
+ if (typeof patch.displayName === "string") {
357
+ const value = patch.displayName.length > 0 ? patch.displayName : FIELD_PLACEHOLDER;
358
+ if (DISPLAY_NAME_LINE.test(text)) {
359
+ // Use a function replacer so `$1`, `$&` etc. inside the value are
360
+ // treated literally rather than as backreferences.
361
+ text = text.replace(DISPLAY_NAME_LINE, () => `- **Display name**: ${value}`);
362
+ touched = true;
363
+ } else {
364
+ // Heavily-edited file without the canonical metadata block — bail
365
+ // out rather than guess where to splice.
366
+ return { changed: false, skipped: "unparseable" };
367
+ }
368
+ }
369
+
370
+ if (patch.bio !== undefined) {
371
+ const bioText =
372
+ patch.bio !== null && patch.bio.trim().length > 0
373
+ ? patch.bio.trim()
374
+ : BIO_PLACEHOLDER;
375
+ if (BIO_SECTION.test(text)) {
376
+ text = text.replace(BIO_SECTION, (_match, head, _body, tail) => `${head}${bioText}${tail}`);
377
+ touched = true;
378
+ } else {
379
+ return { changed: false, skipped: "unparseable" };
380
+ }
381
+ }
382
+
383
+ if (!touched || text === original) {
384
+ return { changed: false, skipped: "no-change" };
385
+ }
386
+
387
+ writeFileSync(file, text, { mode: 0o600 });
388
+ return { changed: true };
389
+ }
390
+
391
+ /**
392
+ * Read the agent's `identity.md` verbatim, if it exists. Returns the raw
393
+ * contents (including the leading `# Identity` heading) so callers can
394
+ * splice it into the system context. Returns `null` when the workspace
395
+ * has not been provisioned yet, the file is empty, or the read fails.
396
+ *
397
+ * Each call hits disk — same contract as `readWorkingMemory`, so a
398
+ * dashboard-driven edit (`applyAgentIdentity` from a control frame, or
399
+ * a hello-snapshot reapply, or the agent's own self-edit) is visible
400
+ * on the very next turn without restarting the gateway.
401
+ */
402
+ export function readIdentity(agentId: string): string | null {
403
+ assertSafeAgentId(agentId);
404
+ const file = path.join(agentWorkspaceDir(agentId), "identity.md");
405
+ try {
406
+ const raw = readFileSync(file, "utf8");
407
+ return raw.trim().length > 0 ? raw : null;
408
+ } catch {
409
+ return null;
410
+ }
411
+ }
package/src/config.ts CHANGED
@@ -13,7 +13,24 @@ export const SNAPSHOT_PATH = path.join(DAEMON_DIR, "snapshot.json");
13
13
  * Adapter ids. Built-in adapters are enumerated for editor hints; any string
14
14
  * accepted by the registry is valid at runtime.
15
15
  */
16
- export type AdapterName = "claude-code" | "codex" | "gemini" | (string & {});
16
+ export type AdapterName = "claude-code" | "codex" | "gemini" | "openclaw-acp" | (string & {});
17
+
18
+ /**
19
+ * One OpenClaw gateway profile. Referenced by `RouteRule.gateway` and
20
+ * `DaemonRouteDefault.gateway` (and `StoredBotCordCredentials.openclawGateway`)
21
+ * via `name`. `tokenFile` is `~`-expanded and read at `toGatewayConfig` time;
22
+ * read failures do not block boot — the gateway becomes unusable but other
23
+ * gateways still work.
24
+ */
25
+ export interface OpenclawGatewayProfile {
26
+ name: string;
27
+ url: string;
28
+ /** Bearer token; mutually-exclusive priority is `token > tokenFile`. */
29
+ token?: string;
30
+ tokenFile?: string;
31
+ /** Default OpenClaw agent profile name when a route does not pin one. */
32
+ defaultAgent?: string;
33
+ }
17
34
 
18
35
  /**
19
36
  * Predicates selecting messages for a route. `roomId` / `roomPrefix` are
@@ -41,12 +58,23 @@ export interface RouteRule {
41
58
  cwd: string;
42
59
  /** Extra CLI flags appended to the adapter invocation. */
43
60
  extraArgs?: string[];
61
+ /**
62
+ * Required when `adapter === "openclaw-acp"`: name of an entry in
63
+ * `DaemonConfig.openclawGateways[]`.
64
+ */
65
+ gateway?: string;
66
+ /** Overrides `OpenclawGatewayProfile.defaultAgent` when set. */
67
+ openclawAgent?: string;
44
68
  }
45
69
 
46
70
  export interface DaemonRouteDefault {
47
71
  adapter: AdapterName;
48
72
  cwd: string;
49
73
  extraArgs?: string[];
74
+ /** Same semantics as `RouteRule.gateway`. */
75
+ gateway?: string;
76
+ /** Same semantics as `RouteRule.openclawAgent`. */
77
+ openclawAgent?: string;
50
78
  }
51
79
 
52
80
  /**
@@ -90,6 +118,28 @@ export interface DaemonConfig {
90
118
  routes: RouteRule[];
91
119
  /** If true, stream blocks (only meaningful for rm_oc_* rooms). */
92
120
  streamBlocks: boolean;
121
+ /**
122
+ * Persistent transcript-logging settings (design §3 / §6). Defaults to
123
+ * disabled — see `BOTCORD_TRANSCRIPT` for env-driven temporary overrides.
124
+ */
125
+ transcript?: TranscriptConfig;
126
+
127
+ /**
128
+ * Optional registry of OpenClaw gateway endpoints. Routes / managed routes
129
+ * with `adapter === "openclaw-acp"` reference these by `name`. Resolution
130
+ * to {@link ResolvedOpenclawGateway} happens eagerly in `toGatewayConfig`
131
+ * so the dispatcher never re-queries this list.
132
+ */
133
+ openclawGateways?: OpenclawGatewayProfile[];
134
+ }
135
+
136
+ /**
137
+ * Persistent transcript settings (design §6). Default-off — `botcord-daemon
138
+ * transcript enable` flips `enabled` and `transcript disable` flips it back.
139
+ * The env var `BOTCORD_TRANSCRIPT` can override at boot.
140
+ */
141
+ export interface TranscriptConfig {
142
+ enabled?: boolean;
93
143
  }
94
144
 
95
145
  /**
@@ -160,11 +210,15 @@ function ensureDir(): void {
160
210
  }
161
211
  }
162
212
 
213
+ export const CONFIG_MISSING = "CONFIG_MISSING";
214
+
163
215
  export function loadConfig(): DaemonConfig {
164
216
  if (!existsSync(CONFIG_PATH)) {
165
- throw new Error(
166
- `daemon config not found at ${CONFIG_PATH}. Run \`botcord-daemon init\` first.`,
167
- );
217
+ const err = new Error(`daemon config not found at ${CONFIG_PATH}`) as Error & {
218
+ code?: string;
219
+ };
220
+ err.code = CONFIG_MISSING;
221
+ throw err;
168
222
  }
169
223
  const raw = readFileSync(CONFIG_PATH, "utf8");
170
224
  const parsed = JSON.parse(raw) as Partial<DaemonConfig>;
@@ -196,6 +250,65 @@ export function loadConfig(): DaemonConfig {
196
250
  }
197
251
  validateAdapter(parsed.defaultRoute.adapter, "defaultRoute.adapter");
198
252
 
253
+ const gatewaysRaw = (parsed as Partial<DaemonConfig>).openclawGateways;
254
+ const gatewayNames = new Set<string>();
255
+ if (gatewaysRaw !== undefined) {
256
+ if (!Array.isArray(gatewaysRaw)) {
257
+ throw new Error(
258
+ `daemon config "openclawGateways" must be an array (${CONFIG_PATH})`,
259
+ );
260
+ }
261
+ for (const [i, g] of gatewaysRaw.entries()) {
262
+ if (!g || typeof g !== "object") {
263
+ throw new Error(
264
+ `daemon config openclawGateways[${i}] is not an object (${CONFIG_PATH})`,
265
+ );
266
+ }
267
+ const gg = g as Partial<OpenclawGatewayProfile>;
268
+ if (typeof gg.name !== "string" || gg.name.length === 0) {
269
+ throw new Error(
270
+ `daemon config openclawGateways[${i}].name must be a non-empty string (${CONFIG_PATH})`,
271
+ );
272
+ }
273
+ if (typeof gg.url !== "string" || gg.url.length === 0) {
274
+ throw new Error(
275
+ `daemon config openclawGateways[${i}].url must be a non-empty string (${CONFIG_PATH})`,
276
+ );
277
+ }
278
+ if (gatewayNames.has(gg.name)) {
279
+ throw new Error(
280
+ `daemon config openclawGateways[${i}].name "${gg.name}" duplicated (${CONFIG_PATH})`,
281
+ );
282
+ }
283
+ gatewayNames.add(gg.name);
284
+ }
285
+ }
286
+
287
+ const validateGatewayRef = (
288
+ adapter: string,
289
+ gateway: unknown,
290
+ where: string,
291
+ ): void => {
292
+ if (adapter === "openclaw-acp") {
293
+ if (typeof gateway !== "string" || gateway.length === 0) {
294
+ throw new Error(
295
+ `daemon config ${where} adapter "openclaw-acp" requires a "gateway" name (${CONFIG_PATH})`,
296
+ );
297
+ }
298
+ if (!gatewayNames.has(gateway)) {
299
+ throw new Error(
300
+ `daemon config ${where}.gateway "${gateway}" not in openclawGateways (${CONFIG_PATH})`,
301
+ );
302
+ }
303
+ }
304
+ };
305
+
306
+ validateGatewayRef(
307
+ parsed.defaultRoute.adapter,
308
+ (parsed.defaultRoute as DaemonRouteDefault).gateway,
309
+ "defaultRoute",
310
+ );
311
+
199
312
  const routesRaw = parsed.routes ?? [];
200
313
  if (!Array.isArray(routesRaw)) {
201
314
  throw new Error(`daemon config "routes" must be an array (${CONFIG_PATH})`);
@@ -210,6 +323,7 @@ export function loadConfig(): DaemonConfig {
210
323
  );
211
324
  }
212
325
  validateAdapter(r.adapter, `routes[${i}].adapter`);
326
+ validateGatewayRef(r.adapter, (r as RouteRule).gateway, `routes[${i}]`);
213
327
  }
214
328
  // Preserve the on-disk shape as-is so `config` prints what the user wrote.
215
329
  // Resolution of agents vs agentId happens at the consumption boundary
@@ -219,6 +333,20 @@ export function loadConfig(): DaemonConfig {
219
333
  routes: routesRaw,
220
334
  streamBlocks: parsed.streamBlocks ?? true,
221
335
  };
336
+ if (parsed.transcript && typeof parsed.transcript === "object") {
337
+ const t: TranscriptConfig = {};
338
+ if (typeof parsed.transcript.enabled === "boolean") t.enabled = parsed.transcript.enabled;
339
+ out.transcript = t;
340
+ }
341
+ if (gatewaysRaw && Array.isArray(gatewaysRaw)) {
342
+ out.openclawGateways = (gatewaysRaw as OpenclawGatewayProfile[]).map((g) => {
343
+ const copy: OpenclawGatewayProfile = { name: g.name, url: g.url };
344
+ if (typeof g.token === "string") copy.token = g.token;
345
+ if (typeof g.tokenFile === "string") copy.tokenFile = g.tokenFile;
346
+ if (typeof g.defaultAgent === "string") copy.defaultAgent = g.defaultAgent;
347
+ return copy;
348
+ });
349
+ }
222
350
  if (hasAgents) out.agents = (parsed.agents as string[]).slice();
223
351
  if (hasLegacy) out.agentId = parsed.agentId;
224
352
  if (discovery && typeof discovery === "object") {
@@ -5,8 +5,6 @@
5
5
  * Independent from the agent data-plane WS: different auth (user access
6
6
  * token vs agent JWT), different endpoint (`/daemon/ws`), different
7
7
  * lifecycle (alive even when zero agents are bound).
8
- *
9
- * See `docs/daemon-control-plane-plan.md` §4.1, §4.3, §8.
10
8
  */
11
9
  import WebSocket from "ws";
12
10
  import {
@@ -31,8 +29,7 @@ const REPLAY_DEDUPE_CAP = 256;
31
29
 
32
30
  /**
33
31
  * Build the canonical signing input for a control frame: RFC 8785 (JCS)
34
- * canonicalization of `{id, type, params, ts}`. Per
35
- * `docs/daemon-control-plane-api-contract.md` §3.3 — the Hub uses Python
32
+ * canonicalization of `{id, type, params, ts}`. The Hub uses Python
36
33
  * `jcs.canonicalize` over the same object before signing.
37
34
  *
38
35
  * Excludes `sig` by definition. `params` defaults to `{}` (empty object)
@@ -1,15 +1,109 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
1
4
  import type {
2
5
  GatewayChannelConfig,
3
6
  GatewayConfig,
4
7
  GatewayRoute,
8
+ ResolvedOpenclawGateway,
5
9
  RouteMatch,
6
10
  TrustLevel as GatewayTrustLevel,
7
11
  } from "./gateway/index.js";
8
- import type { DaemonConfig, RouteRule } from "./config.js";
12
+ import type {
13
+ DaemonConfig,
14
+ DaemonRouteDefault,
15
+ OpenclawGatewayProfile,
16
+ RouteRule,
17
+ } from "./config.js";
9
18
  import { resolveAgentIds } from "./config.js";
10
19
  import { agentWorkspaceDir } from "./agent-workspace.js";
11
20
  import { log as daemonLog } from "./log.js";
12
21
 
22
+ /** Per-agent metadata cached from credentials, used by `buildManagedRoutes`. */
23
+ export interface AgentRuntimeMeta {
24
+ runtime?: string;
25
+ cwd?: string;
26
+ /** OpenClaw gateway profile name to lookup in the registry. */
27
+ openclawGateway?: string;
28
+ /** Optional override of the OpenClaw agent profile within the gateway. */
29
+ openclawAgent?: string;
30
+ }
31
+
32
+ /** Profile + tokenFile-resolved bearer token. Exported so other module-boundary
33
+ * paths (runtime probing, post-provision hot-add) reuse the same resolver
34
+ * instead of duplicating tokenFile semantics. */
35
+ export interface PreparedGatewayProfile extends OpenclawGatewayProfile {
36
+ /** Token actually usable at dispatch time; empty when load failed. */
37
+ resolvedToken?: string;
38
+ /** Reason `resolvedToken` is empty, for logs. */
39
+ tokenError?: string;
40
+ }
41
+
42
+ function expandHome(p: string): string {
43
+ if (p === "~") return homedir();
44
+ if (p.startsWith("~/")) return path.join(homedir(), p.slice(2));
45
+ return p;
46
+ }
47
+
48
+ /** Resolve one profile's token (inline > tokenFile). Failures are swallowed
49
+ * into `tokenError`; `resolvedToken` is left undefined. Logs at warn for ops
50
+ * visibility. */
51
+ export function prepareGatewayProfile(
52
+ p: OpenclawGatewayProfile,
53
+ ): PreparedGatewayProfile {
54
+ const prepared: PreparedGatewayProfile = { ...p };
55
+ if (p.token && p.token.length > 0) {
56
+ prepared.resolvedToken = p.token;
57
+ } else if (p.tokenFile && p.tokenFile.length > 0) {
58
+ try {
59
+ prepared.resolvedToken = readFileSync(expandHome(p.tokenFile), "utf8").trim();
60
+ } catch (err: any) {
61
+ prepared.tokenError = err?.message ?? String(err);
62
+ daemonLog.warn("daemon.config.openclaw.tokenfile_failed", {
63
+ gateway: p.name,
64
+ tokenFile: p.tokenFile,
65
+ error: prepared.tokenError,
66
+ });
67
+ }
68
+ }
69
+ return prepared;
70
+ }
71
+
72
+ /** Build a name → prepared-profile map for a config's gateway registry. */
73
+ export function prepareGatewayProfiles(
74
+ profiles: OpenclawGatewayProfile[] | undefined,
75
+ ): Map<string, PreparedGatewayProfile> {
76
+ const out = new Map<string, PreparedGatewayProfile>();
77
+ if (!profiles) return out;
78
+ for (const p of profiles) out.set(p.name, prepareGatewayProfile(p));
79
+ return out;
80
+ }
81
+
82
+ function resolveGateway(
83
+ profiles: Map<string, PreparedGatewayProfile>,
84
+ gatewayName: string | undefined,
85
+ agentOverride: string | undefined,
86
+ where: string,
87
+ ): ResolvedOpenclawGateway | undefined {
88
+ if (!gatewayName) {
89
+ daemonLog.warn("daemon.config.openclaw.missing_gateway", { where });
90
+ return undefined;
91
+ }
92
+ const profile = profiles.get(gatewayName);
93
+ if (!profile) {
94
+ daemonLog.warn("daemon.config.openclaw.unknown_gateway", { where, gateway: gatewayName });
95
+ return undefined;
96
+ }
97
+ const resolved: ResolvedOpenclawGateway = {
98
+ name: profile.name,
99
+ url: profile.url,
100
+ };
101
+ if (profile.resolvedToken) resolved.token = profile.resolvedToken;
102
+ const agent = agentOverride ?? profile.defaultAgent;
103
+ if (agent) resolved.openclawAgent = agent;
104
+ return resolved;
105
+ }
106
+
13
107
  /** Options accepted by {@link toGatewayConfig}. */
14
108
  export interface ToGatewayConfigOptions {
15
109
  /**
@@ -19,13 +113,12 @@ export interface ToGatewayConfigOptions {
19
113
  */
20
114
  agentIds?: string[];
21
115
  /**
22
- * Per-agent runtime/cwd cached from credentials (see
23
- * `docs/agent-runtime-property-plan.md`). When present for an agent id,
24
- * `toGatewayConfig` synthesizes a terminal route pinning that agent's
116
+ * Per-agent runtime/cwd cached from credentials. When present for an agent
117
+ * id, `toGatewayConfig` synthesizes a terminal route pinning that agent's
25
118
  * turns to its runtime. Explicit `cfg.routes` entries still win because
26
119
  * synthesized routes are appended after them.
27
120
  */
28
- agentRuntimes?: Record<string, { runtime?: string; cwd?: string }>;
121
+ agentRuntimes?: Record<string, AgentRuntimeMeta>;
29
122
  }
30
123
 
31
124
  /**
@@ -60,7 +153,11 @@ function mapTrustLevel(
60
153
  * legacy alias and its canonical field are present, the canonical field
61
154
  * wins and a warning is logged.
62
155
  */
63
- function mapRoute(r: RouteRule): GatewayRoute {
156
+ function mapRoute(
157
+ r: RouteRule,
158
+ profiles: Map<string, PreparedGatewayProfile>,
159
+ index: number,
160
+ ): GatewayRoute {
64
161
  const match: RouteMatch = {};
65
162
  if (r.match.channel) match.channel = r.match.channel;
66
163
  if (r.match.accountId) match.accountId = r.match.accountId;
@@ -96,13 +193,22 @@ function mapRoute(r: RouteRule): GatewayRoute {
96
193
  if (typeof r.match.mentioned === "boolean") match.mentioned = r.match.mentioned;
97
194
 
98
195
  const rawTrust = (r as { trustLevel?: "owner" | "untrusted" }).trustLevel;
99
- return {
196
+ const out: GatewayRoute = {
100
197
  match,
101
198
  runtime: r.adapter,
102
199
  cwd: r.cwd,
103
200
  extraArgs: r.extraArgs,
104
201
  trustLevel: mapTrustLevel(rawTrust),
105
202
  };
203
+ if (r.adapter === "openclaw-acp") {
204
+ out.gateway = resolveGateway(
205
+ profiles,
206
+ r.gateway,
207
+ r.openclawAgent,
208
+ `routes[${index}]`,
209
+ );
210
+ }
211
+ return out;
106
212
  }
107
213
 
108
214
  /**
@@ -135,6 +241,8 @@ export function toGatewayConfig(
135
241
 
136
242
  // DaemonConfig's typed surface doesn't carry `trustLevel`, but we read it
137
243
  // defensively so future config extensions can propagate without a shape bump.
244
+ const profiles = prepareGatewayProfiles(cfg.openclawGateways);
245
+
138
246
  const rawDefaultTrust = (cfg.defaultRoute as { trustLevel?: "owner" | "untrusted" })
139
247
  .trustLevel;
140
248
  const defaultRoute: GatewayRoute = {
@@ -145,8 +253,17 @@ export function toGatewayConfig(
145
253
  // (direct → cancel-previous, group → serial).
146
254
  trustLevel: mapTrustLevel(rawDefaultTrust),
147
255
  };
256
+ if (cfg.defaultRoute.adapter === "openclaw-acp") {
257
+ const dr = cfg.defaultRoute as DaemonRouteDefault;
258
+ defaultRoute.gateway = resolveGateway(
259
+ profiles,
260
+ dr.gateway,
261
+ dr.openclawAgent,
262
+ "defaultRoute",
263
+ );
264
+ }
148
265
 
149
- const routes: GatewayRoute[] = (cfg.routes ?? []).map(mapRoute);
266
+ const routes: GatewayRoute[] = (cfg.routes ?? []).map((r, i) => mapRoute(r, profiles, i));
150
267
 
151
268
  // Synthesize a per-agent route for every bound agent and hand it to the
152
269
  // gateway via the managed-routes bucket (plan §10.1). User-authored
@@ -158,6 +275,7 @@ export function toGatewayConfig(
158
275
  agentIds,
159
276
  opts.agentRuntimes ?? {},
160
277
  defaultRoute,
278
+ profiles,
161
279
  );
162
280
 
163
281
  return {
@@ -185,17 +303,43 @@ export function toGatewayConfig(
185
303
  */
186
304
  export function buildManagedRoutes(
187
305
  agentIds: string[],
188
- agentRuntimes: Record<string, { runtime?: string; cwd?: string }>,
306
+ agentRuntimes: Record<string, AgentRuntimeMeta>,
189
307
  defaultRoute: GatewayRoute,
308
+ openclawProfiles?: Map<string, PreparedGatewayProfile>,
190
309
  ): Map<string, GatewayRoute> {
191
310
  const out = new Map<string, GatewayRoute>();
311
+ // Lazy-build profile map when caller didn't pass one (legacy callers).
312
+ const profiles = openclawProfiles ?? new Map<string, PreparedGatewayProfile>();
192
313
  for (const agentId of agentIds) {
193
314
  const meta = agentRuntimes[agentId] ?? {};
194
- out.set(agentId, {
315
+ const runtime = meta.runtime ?? defaultRoute.runtime;
316
+ const route: GatewayRoute = {
195
317
  match: { accountId: agentId },
196
- runtime: meta.runtime ?? defaultRoute.runtime,
318
+ runtime,
197
319
  cwd: meta.cwd || agentWorkspaceDir(agentId),
198
- });
320
+ // Inherit defaultRoute's extraArgs so synthesized per-agent routes
321
+ // pick up operator-wide flags (e.g. `--permission-mode bypassPermissions`)
322
+ // that would otherwise apply only to agents listed in `cfg.routes[]`.
323
+ ...(defaultRoute.extraArgs ? { extraArgs: defaultRoute.extraArgs.slice() } : {}),
324
+ };
325
+ if (runtime === "openclaw-acp") {
326
+ // Per RFC §3.4: prefer credentials, fall back to defaultRoute.gateway.
327
+ const gatewayName = meta.openclawGateway ?? defaultRoute.gateway?.name;
328
+ const agentOverride = meta.openclawAgent;
329
+ const resolved = gatewayName
330
+ ? resolveGateway(profiles, gatewayName, agentOverride, `managedRoute[${agentId}]`)
331
+ : defaultRoute.gateway;
332
+ if (!resolved) {
333
+ // No usable gateway — skip the managed route so defaultRoute can take over.
334
+ daemonLog.warn("daemon.config.openclaw.managed_route_skipped", {
335
+ agentId,
336
+ gatewayName,
337
+ });
338
+ continue;
339
+ }
340
+ route.gateway = resolved;
341
+ }
342
+ out.set(agentId, route);
199
343
  }
200
344
  return out;
201
345
  }