@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/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
  /**
@@ -60,6 +88,17 @@ export interface AgentDiscoveryConfig {
60
88
  credentialsDir?: string;
61
89
  }
62
90
 
91
+ export interface OpenclawDiscoveryConfig {
92
+ /** Defaults to true. */
93
+ enabled?: boolean;
94
+ /** Overrides the local config-file search roots. */
95
+ searchPaths?: string[];
96
+ /** Overrides the local loopback ports to probe. */
97
+ defaultPorts?: number[];
98
+ /** Defaults to true. When false, discovery only persists gateways. */
99
+ autoProvision?: boolean;
100
+ }
101
+
63
102
  export interface DaemonConfig {
64
103
  /**
65
104
  * @deprecated Kept for backward compatibility with pre-multi-agent configs.
@@ -90,6 +129,34 @@ export interface DaemonConfig {
90
129
  routes: RouteRule[];
91
130
  /** If true, stream blocks (only meaningful for rm_oc_* rooms). */
92
131
  streamBlocks: boolean;
132
+ /**
133
+ * Persistent transcript-logging settings (design §3 / §6). Defaults to
134
+ * disabled — see `BOTCORD_TRANSCRIPT` for env-driven temporary overrides.
135
+ */
136
+ transcript?: TranscriptConfig;
137
+
138
+ /**
139
+ * Optional registry of OpenClaw gateway endpoints. Routes / managed routes
140
+ * with `adapter === "openclaw-acp"` reference these by `name`. Resolution
141
+ * to {@link ResolvedOpenclawGateway} happens eagerly in `toGatewayConfig`
142
+ * so the dispatcher never re-queries this list.
143
+ */
144
+ openclawGateways?: OpenclawGatewayProfile[];
145
+
146
+ /**
147
+ * Daemon-side local OpenClaw discovery. Omitted means enabled with default
148
+ * search paths/ports and automatic adoption of discovered agents.
149
+ */
150
+ openclawDiscovery?: OpenclawDiscoveryConfig;
151
+ }
152
+
153
+ /**
154
+ * Persistent transcript settings (design §6). Default-off — `botcord-daemon
155
+ * transcript enable` flips `enabled` and `transcript disable` flips it back.
156
+ * The env var `BOTCORD_TRANSCRIPT` can override at boot.
157
+ */
158
+ export interface TranscriptConfig {
159
+ enabled?: boolean;
93
160
  }
94
161
 
95
162
  /**
@@ -160,11 +227,15 @@ function ensureDir(): void {
160
227
  }
161
228
  }
162
229
 
230
+ export const CONFIG_MISSING = "CONFIG_MISSING";
231
+
163
232
  export function loadConfig(): DaemonConfig {
164
233
  if (!existsSync(CONFIG_PATH)) {
165
- throw new Error(
166
- `daemon config not found at ${CONFIG_PATH}. Run \`botcord-daemon init\` first.`,
167
- );
234
+ const err = new Error(`daemon config not found at ${CONFIG_PATH}`) as Error & {
235
+ code?: string;
236
+ };
237
+ err.code = CONFIG_MISSING;
238
+ throw err;
168
239
  }
169
240
  const raw = readFileSync(CONFIG_PATH, "utf8");
170
241
  const parsed = JSON.parse(raw) as Partial<DaemonConfig>;
@@ -196,6 +267,65 @@ export function loadConfig(): DaemonConfig {
196
267
  }
197
268
  validateAdapter(parsed.defaultRoute.adapter, "defaultRoute.adapter");
198
269
 
270
+ const gatewaysRaw = (parsed as Partial<DaemonConfig>).openclawGateways;
271
+ const gatewayNames = new Set<string>();
272
+ if (gatewaysRaw !== undefined) {
273
+ if (!Array.isArray(gatewaysRaw)) {
274
+ throw new Error(
275
+ `daemon config "openclawGateways" must be an array (${CONFIG_PATH})`,
276
+ );
277
+ }
278
+ for (const [i, g] of gatewaysRaw.entries()) {
279
+ if (!g || typeof g !== "object") {
280
+ throw new Error(
281
+ `daemon config openclawGateways[${i}] is not an object (${CONFIG_PATH})`,
282
+ );
283
+ }
284
+ const gg = g as Partial<OpenclawGatewayProfile>;
285
+ if (typeof gg.name !== "string" || gg.name.length === 0) {
286
+ throw new Error(
287
+ `daemon config openclawGateways[${i}].name must be a non-empty string (${CONFIG_PATH})`,
288
+ );
289
+ }
290
+ if (typeof gg.url !== "string" || gg.url.length === 0) {
291
+ throw new Error(
292
+ `daemon config openclawGateways[${i}].url must be a non-empty string (${CONFIG_PATH})`,
293
+ );
294
+ }
295
+ if (gatewayNames.has(gg.name)) {
296
+ throw new Error(
297
+ `daemon config openclawGateways[${i}].name "${gg.name}" duplicated (${CONFIG_PATH})`,
298
+ );
299
+ }
300
+ gatewayNames.add(gg.name);
301
+ }
302
+ }
303
+
304
+ const validateGatewayRef = (
305
+ adapter: string,
306
+ gateway: unknown,
307
+ where: string,
308
+ ): void => {
309
+ if (adapter === "openclaw-acp") {
310
+ if (typeof gateway !== "string" || gateway.length === 0) {
311
+ throw new Error(
312
+ `daemon config ${where} adapter "openclaw-acp" requires a "gateway" name (${CONFIG_PATH})`,
313
+ );
314
+ }
315
+ if (!gatewayNames.has(gateway)) {
316
+ throw new Error(
317
+ `daemon config ${where}.gateway "${gateway}" not in openclawGateways (${CONFIG_PATH})`,
318
+ );
319
+ }
320
+ }
321
+ };
322
+
323
+ validateGatewayRef(
324
+ parsed.defaultRoute.adapter,
325
+ (parsed.defaultRoute as DaemonRouteDefault).gateway,
326
+ "defaultRoute",
327
+ );
328
+
199
329
  const routesRaw = parsed.routes ?? [];
200
330
  if (!Array.isArray(routesRaw)) {
201
331
  throw new Error(`daemon config "routes" must be an array (${CONFIG_PATH})`);
@@ -210,6 +340,7 @@ export function loadConfig(): DaemonConfig {
210
340
  );
211
341
  }
212
342
  validateAdapter(r.adapter, `routes[${i}].adapter`);
343
+ validateGatewayRef(r.adapter, (r as RouteRule).gateway, `routes[${i}]`);
213
344
  }
214
345
  // Preserve the on-disk shape as-is so `config` prints what the user wrote.
215
346
  // Resolution of agents vs agentId happens at the consumption boundary
@@ -219,6 +350,20 @@ export function loadConfig(): DaemonConfig {
219
350
  routes: routesRaw,
220
351
  streamBlocks: parsed.streamBlocks ?? true,
221
352
  };
353
+ if (parsed.transcript && typeof parsed.transcript === "object") {
354
+ const t: TranscriptConfig = {};
355
+ if (typeof parsed.transcript.enabled === "boolean") t.enabled = parsed.transcript.enabled;
356
+ out.transcript = t;
357
+ }
358
+ if (gatewaysRaw && Array.isArray(gatewaysRaw)) {
359
+ out.openclawGateways = (gatewaysRaw as OpenclawGatewayProfile[]).map((g) => {
360
+ const copy: OpenclawGatewayProfile = { name: g.name, url: g.url };
361
+ if (typeof g.token === "string") copy.token = g.token;
362
+ if (typeof g.tokenFile === "string") copy.tokenFile = g.tokenFile;
363
+ if (typeof g.defaultAgent === "string") copy.defaultAgent = g.defaultAgent;
364
+ return copy;
365
+ });
366
+ }
222
367
  if (hasAgents) out.agents = (parsed.agents as string[]).slice();
223
368
  if (hasLegacy) out.agentId = parsed.agentId;
224
369
  if (discovery && typeof discovery === "object") {
@@ -229,6 +374,25 @@ export function loadConfig(): DaemonConfig {
229
374
  }
230
375
  out.agentDiscovery = copy;
231
376
  }
377
+ const openclawDiscovery = parsed.openclawDiscovery;
378
+ if (openclawDiscovery && typeof openclawDiscovery === "object") {
379
+ const copy: OpenclawDiscoveryConfig = {};
380
+ if (typeof openclawDiscovery.enabled === "boolean") copy.enabled = openclawDiscovery.enabled;
381
+ if (Array.isArray(openclawDiscovery.searchPaths)) {
382
+ copy.searchPaths = openclawDiscovery.searchPaths.filter(
383
+ (p): p is string => typeof p === "string" && p.length > 0,
384
+ );
385
+ }
386
+ if (Array.isArray(openclawDiscovery.defaultPorts)) {
387
+ copy.defaultPorts = openclawDiscovery.defaultPorts.filter(
388
+ (p): p is number => Number.isInteger(p) && p > 0 && p < 65536,
389
+ );
390
+ }
391
+ if (typeof openclawDiscovery.autoProvision === "boolean") {
392
+ copy.autoProvision = openclawDiscovery.autoProvision;
393
+ }
394
+ out.openclawDiscovery = copy;
395
+ }
232
396
  return out;
233
397
  }
234
398
 
@@ -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
  /**
@@ -24,7 +118,7 @@ export interface ToGatewayConfigOptions {
24
118
  * turns to its runtime. Explicit `cfg.routes` entries still win because
25
119
  * synthesized routes are appended after them.
26
120
  */
27
- agentRuntimes?: Record<string, { runtime?: string; cwd?: string }>;
121
+ agentRuntimes?: Record<string, AgentRuntimeMeta>;
28
122
  }
29
123
 
30
124
  /**
@@ -59,7 +153,11 @@ function mapTrustLevel(
59
153
  * legacy alias and its canonical field are present, the canonical field
60
154
  * wins and a warning is logged.
61
155
  */
62
- function mapRoute(r: RouteRule): GatewayRoute {
156
+ function mapRoute(
157
+ r: RouteRule,
158
+ profiles: Map<string, PreparedGatewayProfile>,
159
+ index: number,
160
+ ): GatewayRoute {
63
161
  const match: RouteMatch = {};
64
162
  if (r.match.channel) match.channel = r.match.channel;
65
163
  if (r.match.accountId) match.accountId = r.match.accountId;
@@ -95,13 +193,22 @@ function mapRoute(r: RouteRule): GatewayRoute {
95
193
  if (typeof r.match.mentioned === "boolean") match.mentioned = r.match.mentioned;
96
194
 
97
195
  const rawTrust = (r as { trustLevel?: "owner" | "untrusted" }).trustLevel;
98
- return {
196
+ const out: GatewayRoute = {
99
197
  match,
100
198
  runtime: r.adapter,
101
199
  cwd: r.cwd,
102
200
  extraArgs: r.extraArgs,
103
201
  trustLevel: mapTrustLevel(rawTrust),
104
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;
105
212
  }
106
213
 
107
214
  /**
@@ -134,6 +241,8 @@ export function toGatewayConfig(
134
241
 
135
242
  // DaemonConfig's typed surface doesn't carry `trustLevel`, but we read it
136
243
  // defensively so future config extensions can propagate without a shape bump.
244
+ const profiles = prepareGatewayProfiles(cfg.openclawGateways);
245
+
137
246
  const rawDefaultTrust = (cfg.defaultRoute as { trustLevel?: "owner" | "untrusted" })
138
247
  .trustLevel;
139
248
  const defaultRoute: GatewayRoute = {
@@ -144,8 +253,17 @@ export function toGatewayConfig(
144
253
  // (direct → cancel-previous, group → serial).
145
254
  trustLevel: mapTrustLevel(rawDefaultTrust),
146
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
+ }
147
265
 
148
- const routes: GatewayRoute[] = (cfg.routes ?? []).map(mapRoute);
266
+ const routes: GatewayRoute[] = (cfg.routes ?? []).map((r, i) => mapRoute(r, profiles, i));
149
267
 
150
268
  // Synthesize a per-agent route for every bound agent and hand it to the
151
269
  // gateway via the managed-routes bucket (plan §10.1). User-authored
@@ -157,6 +275,7 @@ export function toGatewayConfig(
157
275
  agentIds,
158
276
  opts.agentRuntimes ?? {},
159
277
  defaultRoute,
278
+ profiles,
160
279
  );
161
280
 
162
281
  return {
@@ -184,17 +303,43 @@ export function toGatewayConfig(
184
303
  */
185
304
  export function buildManagedRoutes(
186
305
  agentIds: string[],
187
- agentRuntimes: Record<string, { runtime?: string; cwd?: string }>,
306
+ agentRuntimes: Record<string, AgentRuntimeMeta>,
188
307
  defaultRoute: GatewayRoute,
308
+ openclawProfiles?: Map<string, PreparedGatewayProfile>,
189
309
  ): Map<string, GatewayRoute> {
190
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>();
191
313
  for (const agentId of agentIds) {
192
314
  const meta = agentRuntimes[agentId] ?? {};
193
- out.set(agentId, {
315
+ const runtime = meta.runtime ?? defaultRoute.runtime;
316
+ const route: GatewayRoute = {
194
317
  match: { accountId: agentId },
195
- runtime: meta.runtime ?? defaultRoute.runtime,
318
+ runtime,
196
319
  cwd: meta.cwd || agentWorkspaceDir(agentId),
197
- });
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);
198
343
  }
199
344
  return out;
200
345
  }
package/src/daemon.ts CHANGED
@@ -1,7 +1,12 @@
1
- import { CONTROL_FRAME_TYPES } from "@botcord/protocol-core";
1
+ import {
2
+ CONTROL_FRAME_TYPES,
3
+ shouldWake,
4
+ type AttentionPolicy,
5
+ } from "@botcord/protocol-core";
2
6
  import {
3
7
  Gateway,
4
8
  createBotCordChannel,
9
+ resolveTranscriptEnabled,
5
10
  sanitizeUntrustedContent,
6
11
  type ChannelAdapter,
7
12
  type GatewayChannelConfig,
@@ -18,7 +23,12 @@ import { ensureAgentWorkspace } from "./agent-workspace.js";
18
23
  import { ControlChannel } from "./control-channel.js";
19
24
  import { toGatewayConfig } from "./daemon-config-map.js";
20
25
  import { log as daemonLog } from "./log.js";
21
- import { collectRuntimeSnapshot, createProvisioner } from "./provision.js";
26
+ import {
27
+ adoptDiscoveredOpenclawAgents,
28
+ collectRuntimeSnapshot,
29
+ createProvisioner,
30
+ } from "./provision.js";
31
+ import { openclawAutoProvisionEnabled } from "./openclaw-discovery.js";
22
32
  import { SnapshotWriter } from "./snapshot-writer.js";
23
33
  import { createDaemonSystemContextBuilder } from "./system-context.js";
24
34
  import { createRoomStaticContextBuilder } from "./room-context.js";
@@ -31,6 +41,8 @@ import {
31
41
  } from "./loop-risk.js";
32
42
  import { composeBotCordUserTurn } from "./turn-text.js";
33
43
  import { UserAuthManager } from "./user-auth.js";
44
+ import { PolicyResolver } from "./gateway/policy-resolver.js";
45
+ import { scanMention } from "./mention-scan.js";
34
46
 
35
47
  /**
36
48
  * Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
@@ -222,6 +234,18 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
222
234
 
223
235
  const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
224
236
 
237
+ // Per-agent hub URL — read from each credential file at boot. Used to
238
+ // populate `BOTCORD_HUB` for runtime CLI subprocesses so the bundled
239
+ // `botcord` CLI talks to the same hub the agent is registered against,
240
+ // even when a single daemon hosts agents from different hubs.
241
+ const hubUrlByAgentId = new Map<string, string>();
242
+ for (const a of boot.agents) {
243
+ if (a.hubUrl) hubUrlByAgentId.set(a.agentId, a.hubUrl);
244
+ }
245
+ const fallbackHubUrl = opts.hubBaseUrl;
246
+ const resolveHubUrl = (accountId: string): string | undefined =>
247
+ hubUrlByAgentId.get(accountId) ?? fallbackHubUrl;
248
+
225
249
  // ActivityTracker lives at the daemon layer (not the gateway core). We
226
250
  // expose it to the gateway via (a) the `buildSystemContext` hook so the
227
251
  // cross-room digest reflects current activity, and (b) the `onInbound`
@@ -319,6 +343,40 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
319
343
  });
320
344
  };
321
345
 
346
+ // Per-agent attention policy cache (PR3, design §4.2 / §5). Seeded from
347
+ // the optional `defaultAttention` / `attentionKeywords` carried by
348
+ // `provision_agent`, refreshed in-place by the `policy_updated` control
349
+ // frame. PR2 will plug per-room overrides into `fetchEffective`; PR3
350
+ // leaves it absent so the resolver collapses to per-agent state.
351
+ const policyResolver = new PolicyResolver({
352
+ fetchGlobal: async (_agentId: string) => undefined,
353
+ });
354
+
355
+ // Display-name lookup for the mention text-fallback. Populated from boot
356
+ // credentials; multi-agent daemons can reuse the same map via accountId.
357
+ const displayNameByAgent = new Map<string, string>();
358
+ for (const a of boot.agents) {
359
+ if (a.displayName) displayNameByAgent.set(a.agentId, a.displayName);
360
+ }
361
+
362
+ // Attention gate: compose `messages.mentioned` (sender-supplied — distrust)
363
+ // with a local `@<display_name>` / `@<agent_id>` text scan, resolve the
364
+ // effective policy, then defer to the protocol-core `shouldWake` decision.
365
+ const attentionGate = async (msg: GatewayInboundMessage): Promise<boolean> => {
366
+ const policy: AttentionPolicy = await policyResolver.resolve(
367
+ msg.accountId,
368
+ msg.conversation.id,
369
+ );
370
+ const localMention = scanMention(msg.text, {
371
+ agentId: msg.accountId,
372
+ displayName: displayNameByAgent.get(msg.accountId),
373
+ });
374
+ return shouldWake(policy, {
375
+ mentioned: msg.mentioned === true || localMention,
376
+ text: msg.text,
377
+ });
378
+ };
379
+
322
380
  const gateway = new Gateway({
323
381
  config: gwConfig,
324
382
  sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
@@ -340,6 +398,12 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
340
398
  onInbound,
341
399
  onOutbound,
342
400
  composeUserTurn: composeBotCordUserTurn,
401
+ attentionGate,
402
+ resolveHubUrl,
403
+ transcriptEnabled: resolveTranscriptEnabled(
404
+ process.env.BOTCORD_TRANSCRIPT,
405
+ opts.config.transcript?.enabled === true,
406
+ ),
343
407
  });
344
408
 
345
409
  logger.info("daemon starting", {
@@ -356,13 +420,37 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
356
420
  logger.warn("daemon starting with no channels", {
357
421
  source: boot.source,
358
422
  credentialsDir: boot.credentialsDir,
359
- hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon init --agent <ag_xxx>`",
423
+ hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon start --agent <ag_xxx>` (only seeds config on first run)",
360
424
  });
361
425
  }
362
426
 
363
427
  await gateway.start();
364
428
  logger.info("daemon started", { agents: agentIds });
365
429
 
430
+ if (openclawAutoProvisionEnabled(opts.config)) {
431
+ try {
432
+ const adopted = await adoptDiscoveredOpenclawAgents({
433
+ gateway,
434
+ cfg: opts.config,
435
+ });
436
+ if (
437
+ adopted.adopted.length > 0 ||
438
+ adopted.failed.length > 0 ||
439
+ adopted.skipped.length > 0
440
+ ) {
441
+ logger.info("openclaw auto-provision completed", {
442
+ adopted: adopted.adopted,
443
+ skipped: adopted.skipped,
444
+ failed: adopted.failed,
445
+ });
446
+ }
447
+ } catch (err) {
448
+ logger.warn("openclaw auto-provision failed; continuing", {
449
+ error: err instanceof Error ? err.message : String(err),
450
+ });
451
+ }
452
+ }
453
+
366
454
  // Control channel is optional — daemon still runs (data-plane only)
367
455
  // when user-auth hasn't been set up yet. Operators can `login` later
368
456
  // without restarting, but for P0 we require a restart to pick it up.
@@ -376,7 +464,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
376
464
  userId: userAuth.current.userId,
377
465
  hubUrl: userAuth.current.hubUrl,
378
466
  });
379
- const provisioner = createProvisioner({ gateway });
467
+ const provisioner = createProvisioner({ gateway, policyResolver });
380
468
  controlChannel = new ControlChannel({
381
469
  auth: userAuth,
382
470
  handle: provisioner,
@@ -443,7 +531,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
443
531
  */
444
532
  export interface BootBackfillResult {
445
533
  credentialPathByAgentId: Map<string, string>;
446
- agentRuntimes: Record<string, { runtime?: string; cwd?: string }>;
534
+ agentRuntimes: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }>;
447
535
  }
448
536
 
449
537
  /**
@@ -466,10 +554,12 @@ export function backfillBootAgents(
466
554
  const failed: string[] = [];
467
555
  for (const a of agents) {
468
556
  if (a.credentialsFile) credentialPathByAgentId.set(a.agentId, a.credentialsFile);
469
- if (a.runtime || a.cwd) {
557
+ if (a.runtime || a.cwd || a.openclawGateway || a.openclawAgent) {
470
558
  agentRuntimes[a.agentId] = {
471
559
  ...(a.runtime ? { runtime: a.runtime } : {}),
472
560
  ...(a.cwd ? { cwd: a.cwd } : {}),
561
+ ...(a.openclawGateway ? { openclawGateway: a.openclawGateway } : {}),
562
+ ...(a.openclawAgent ? { openclawAgent: a.openclawAgent } : {}),
473
563
  };
474
564
  }
475
565
  // Seed files are written only when missing (see `ensureAgentWorkspace`),