@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
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") {
@@ -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,
@@ -31,6 +36,8 @@ import {
31
36
  } from "./loop-risk.js";
32
37
  import { composeBotCordUserTurn } from "./turn-text.js";
33
38
  import { UserAuthManager } from "./user-auth.js";
39
+ import { PolicyResolver } from "./gateway/policy-resolver.js";
40
+ import { scanMention } from "./mention-scan.js";
34
41
 
35
42
  /**
36
43
  * Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
@@ -222,6 +229,18 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
222
229
 
223
230
  const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
224
231
 
232
+ // Per-agent hub URL — read from each credential file at boot. Used to
233
+ // populate `BOTCORD_HUB` for runtime CLI subprocesses so the bundled
234
+ // `botcord` CLI talks to the same hub the agent is registered against,
235
+ // even when a single daemon hosts agents from different hubs.
236
+ const hubUrlByAgentId = new Map<string, string>();
237
+ for (const a of boot.agents) {
238
+ if (a.hubUrl) hubUrlByAgentId.set(a.agentId, a.hubUrl);
239
+ }
240
+ const fallbackHubUrl = opts.hubBaseUrl;
241
+ const resolveHubUrl = (accountId: string): string | undefined =>
242
+ hubUrlByAgentId.get(accountId) ?? fallbackHubUrl;
243
+
225
244
  // ActivityTracker lives at the daemon layer (not the gateway core). We
226
245
  // expose it to the gateway via (a) the `buildSystemContext` hook so the
227
246
  // cross-room digest reflects current activity, and (b) the `onInbound`
@@ -319,6 +338,40 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
319
338
  });
320
339
  };
321
340
 
341
+ // Per-agent attention policy cache (PR3, design §4.2 / §5). Seeded from
342
+ // the optional `defaultAttention` / `attentionKeywords` carried by
343
+ // `provision_agent`, refreshed in-place by the `policy_updated` control
344
+ // frame. PR2 will plug per-room overrides into `fetchEffective`; PR3
345
+ // leaves it absent so the resolver collapses to per-agent state.
346
+ const policyResolver = new PolicyResolver({
347
+ fetchGlobal: async (_agentId: string) => undefined,
348
+ });
349
+
350
+ // Display-name lookup for the mention text-fallback. Populated from boot
351
+ // credentials; multi-agent daemons can reuse the same map via accountId.
352
+ const displayNameByAgent = new Map<string, string>();
353
+ for (const a of boot.agents) {
354
+ if (a.displayName) displayNameByAgent.set(a.agentId, a.displayName);
355
+ }
356
+
357
+ // Attention gate: compose `messages.mentioned` (sender-supplied — distrust)
358
+ // with a local `@<display_name>` / `@<agent_id>` text scan, resolve the
359
+ // effective policy, then defer to the protocol-core `shouldWake` decision.
360
+ const attentionGate = async (msg: GatewayInboundMessage): Promise<boolean> => {
361
+ const policy: AttentionPolicy = await policyResolver.resolve(
362
+ msg.accountId,
363
+ msg.conversation.id,
364
+ );
365
+ const localMention = scanMention(msg.text, {
366
+ agentId: msg.accountId,
367
+ displayName: displayNameByAgent.get(msg.accountId),
368
+ });
369
+ return shouldWake(policy, {
370
+ mentioned: msg.mentioned === true || localMention,
371
+ text: msg.text,
372
+ });
373
+ };
374
+
322
375
  const gateway = new Gateway({
323
376
  config: gwConfig,
324
377
  sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
@@ -340,6 +393,12 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
340
393
  onInbound,
341
394
  onOutbound,
342
395
  composeUserTurn: composeBotCordUserTurn,
396
+ attentionGate,
397
+ resolveHubUrl,
398
+ transcriptEnabled: resolveTranscriptEnabled(
399
+ process.env.BOTCORD_TRANSCRIPT,
400
+ opts.config.transcript?.enabled === true,
401
+ ),
343
402
  });
344
403
 
345
404
  logger.info("daemon starting", {
@@ -356,7 +415,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
356
415
  logger.warn("daemon starting with no channels", {
357
416
  source: boot.source,
358
417
  credentialsDir: boot.credentialsDir,
359
- hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon init --agent <ag_xxx>`",
418
+ 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
419
  });
361
420
  }
362
421
 
@@ -376,7 +435,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
376
435
  userId: userAuth.current.userId,
377
436
  hubUrl: userAuth.current.hubUrl,
378
437
  });
379
- const provisioner = createProvisioner({ gateway });
438
+ const provisioner = createProvisioner({ gateway, policyResolver });
380
439
  controlChannel = new ControlChannel({
381
440
  auth: userAuth,
382
441
  handle: provisioner,
@@ -443,7 +502,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
443
502
  */
444
503
  export interface BootBackfillResult {
445
504
  credentialPathByAgentId: Map<string, string>;
446
- agentRuntimes: Record<string, { runtime?: string; cwd?: string }>;
505
+ agentRuntimes: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }>;
447
506
  }
448
507
 
449
508
  /**
@@ -466,10 +525,12 @@ export function backfillBootAgents(
466
525
  const failed: string[] = [];
467
526
  for (const a of agents) {
468
527
  if (a.credentialsFile) credentialPathByAgentId.set(a.agentId, a.credentialsFile);
469
- if (a.runtime || a.cwd) {
528
+ if (a.runtime || a.cwd || a.openclawGateway || a.openclawAgent) {
470
529
  agentRuntimes[a.agentId] = {
471
530
  ...(a.runtime ? { runtime: a.runtime } : {}),
472
531
  ...(a.cwd ? { cwd: a.cwd } : {}),
532
+ ...(a.openclawGateway ? { openclawGateway: a.openclawGateway } : {}),
533
+ ...(a.openclawAgent ? { openclawAgent: a.openclawAgent } : {}),
473
534
  };
474
535
  }
475
536
  // Seed files are written only when missing (see `ensureAgentWorkspace`),
package/src/doctor.ts CHANGED
@@ -31,9 +31,34 @@ export interface DoctorHttpResult {
31
31
  error?: string;
32
32
  }
33
33
 
34
+ /** One endpoint probe entry, mirrored from `RuntimeEndpointProbe`. */
35
+ export interface DoctorRuntimeEndpoint {
36
+ name: string;
37
+ url: string;
38
+ reachable: boolean;
39
+ version?: string;
40
+ error?: string;
41
+ agents?: Array<{
42
+ id: string;
43
+ name?: string;
44
+ workspace?: string;
45
+ model?: { name?: string; provider?: string };
46
+ }>;
47
+ /**
48
+ * Optional warning surfaced by the doctor: e.g. botcord plugin loaded on
49
+ * the gateway (would form a daemon → openclaw → botcord → Hub loop).
50
+ */
51
+ warnings?: string[];
52
+ }
53
+
54
+ /** Augmented runtime entry that may carry endpoint probe results. */
55
+ export interface DoctorRuntimeEntry extends RuntimeProbeEntry {
56
+ endpoints?: DoctorRuntimeEndpoint[];
57
+ }
58
+
34
59
  /** Input for the rendered doctor output. */
35
60
  export interface DoctorInput {
36
- runtimes: RuntimeProbeEntry[];
61
+ runtimes: DoctorRuntimeEntry[];
37
62
  channels: ChannelProbeResult[];
38
63
  }
39
64
 
@@ -226,10 +251,32 @@ export function renderDoctor(input: DoctorInput): string {
226
251
  lines.push(
227
252
  `${pad("RUNTIME", widths.runtime)} ${pad("NAME", widths.name)} ${pad("STATUS", widths.status)} ${pad("VERSION", widths.version)} PATH`,
228
253
  );
229
- for (const r of rows) {
254
+ for (let i = 0; i < rows.length; i += 1) {
255
+ const r = rows[i];
256
+ const e = input.runtimes[i];
230
257
  lines.push(
231
258
  `${pad(r.runtime, widths.runtime)} ${pad(r.name, widths.name)} ${pad(r.status, widths.status)} ${pad(r.version, widths.version)} ${r.path}`,
232
259
  );
260
+ if (e.endpoints && e.endpoints.length > 0) {
261
+ for (const ep of e.endpoints) {
262
+ const mark = ep.reachable ? "✓" : "✗";
263
+ const detail = ep.reachable
264
+ ? ep.version ?? "ok"
265
+ : ep.error ?? "unreachable";
266
+ lines.push(` gateway ${pad(`"${ep.name}"`, 16)} ${pad(ep.url, 40)} ${mark} ${detail}`);
267
+ if (ep.agents && ep.agents.length > 0) {
268
+ // RFC §3.8.4: list by `id` (stable key); show display name when distinct.
269
+ lines.push(
270
+ ` agents (id): ${ep.agents
271
+ .map((a) => (a.name && a.name !== a.id ? `${a.id} (${a.name})` : a.id))
272
+ .join(", ")}`,
273
+ );
274
+ }
275
+ if (ep.warnings) {
276
+ for (const w of ep.warnings) lines.push(` WARN: ${w}`);
277
+ }
278
+ }
279
+ }
233
280
  }
234
281
  const available = input.runtimes.filter((e) => e.result.available).length;
235
282
  lines.push(`\n${available}/${input.runtimes.length} runtimes available`);