@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/provision.ts CHANGED
@@ -14,16 +14,21 @@ import {
14
14
  derivePublicKey,
15
15
  loadStoredCredentials,
16
16
  writeCredentialsFile,
17
+ type AgentIdentitySnapshot,
17
18
  type ControlAck,
18
19
  type ControlFrame,
20
+ type HelloParams,
19
21
  type ListRuntimesResult,
20
22
  type ProvisionAgentParams,
21
23
  type RevokeAgentParams,
22
24
  type RevokeAgentResult,
23
25
  type RuntimeProbeResult,
24
26
  type StoredBotCordCredentials,
27
+ type UpdateAgentParams,
25
28
  } from "@botcord/protocol-core";
26
29
  import type { Gateway } from "./gateway/index.js";
30
+ import type { PolicyResolverLike } from "./gateway/policy-resolver.js";
31
+ import type { PolicyUpdatedParams } from "@botcord/protocol-core";
27
32
  import type {
28
33
  GatewayChannelConfig,
29
34
  GatewayRuntimeSnapshot,
@@ -36,15 +41,21 @@ import {
36
41
  type RouteRule,
37
42
  type RouteRuleMatch,
38
43
  } from "./config.js";
39
- import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes } from "./daemon-config-map.js";
44
+ import {
45
+ BOTCORD_CHANNEL_TYPE,
46
+ buildManagedRoutes,
47
+ prepareGatewayProfile,
48
+ } from "./daemon-config-map.js";
40
49
  import {
41
50
  agentHomeDir,
42
51
  agentStateDir,
43
52
  agentWorkspaceDir,
53
+ applyAgentIdentity,
44
54
  ensureAgentWorkspace,
45
55
  } from "./agent-workspace.js";
46
56
  import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
47
57
  import { log as daemonLog } from "./log.js";
58
+ import { discoverAgentCredentials } from "./agent-discovery.js";
48
59
 
49
60
  /** Options accepted by {@link createProvisioner}. */
50
61
  export interface ProvisionerOptions {
@@ -55,6 +66,14 @@ export interface ProvisionerOptions {
55
66
  * run without a real Hub.
56
67
  */
57
68
  register?: typeof BotCordClient.register;
69
+ /**
70
+ * Optional policy-resolver handle (PR3). When present, the
71
+ * `policy_updated` control frame routes through it: cache is invalidated
72
+ * for the (agent, room?) pair, and any embedded `policy` payload is
73
+ * applied directly so the next inbound sees the fresh policy without an
74
+ * extra round-trip.
75
+ */
76
+ policyResolver?: PolicyResolverLike;
58
77
  }
59
78
 
60
79
  /** The value a frame handler returns (minus the `id` which the channel fills in). */
@@ -70,6 +89,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
70
89
  ) => Promise<AckBody> {
71
90
  const gateway = opts.gateway;
72
91
  const register = opts.register ?? BotCordClient.register;
92
+ const policyResolver = opts.policyResolver;
73
93
 
74
94
  return async (frame: ControlFrame): Promise<AckBody> => {
75
95
  daemonLog.debug("provision.dispatch", { type: frame.type, id: frame.id });
@@ -77,6 +97,38 @@ export function createProvisioner(opts: ProvisionerOptions): (
77
97
  case CONTROL_FRAME_TYPES.PING:
78
98
  return { ok: true, result: { pong: true, ts: Date.now() } };
79
99
 
100
+ case CONTROL_FRAME_TYPES.HELLO: {
101
+ const params = (frame.params ?? {}) as unknown as HelloParams;
102
+ const result = applyHelloIdentitySnapshot(params.agents);
103
+ daemonLog.debug("hello: identity snapshot applied", {
104
+ frameId: frame.id,
105
+ received: params.agents?.length ?? 0,
106
+ updated: result.updated,
107
+ skipped: result.skipped,
108
+ });
109
+ return { ok: true, result };
110
+ }
111
+
112
+ case CONTROL_FRAME_TYPES.UPDATE_AGENT: {
113
+ const params = (frame.params ?? {}) as unknown as UpdateAgentParams;
114
+ if (!params.agentId) {
115
+ return {
116
+ ok: false,
117
+ error: { code: "bad_params", message: "update_agent requires params.agentId" },
118
+ };
119
+ }
120
+ const result = applyAgentIdentity(params.agentId, {
121
+ displayName: params.displayName,
122
+ bio: params.bio,
123
+ });
124
+ daemonLog.info("update_agent applied", {
125
+ agentId: params.agentId,
126
+ changed: result.changed,
127
+ skipped: result.skipped ?? null,
128
+ });
129
+ return { ok: true, result };
130
+ }
131
+
80
132
  case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
81
133
  const params = (frame.params ?? {}) as unknown as ProvisionAgentParams;
82
134
  daemonLog.info("provision_agent: start", {
@@ -86,6 +138,18 @@ export function createProvisioner(opts: ProvisionerOptions): (
86
138
  name: params.name ?? null,
87
139
  });
88
140
  const agent = await provisionAgent(params, { gateway, register });
141
+ // Seed the policy resolver from the optional `defaultAttention` /
142
+ // `attentionKeywords` fields (PR3, control-frame.ts). Hub builds that
143
+ // don't yet emit these stay backwards-compatible — the resolver just
144
+ // falls back to `mode=always` until a `policy_updated` frame arrives.
145
+ if (policyResolver && params.defaultAttention) {
146
+ policyResolver.put(agent.agentId, null, {
147
+ mode: params.defaultAttention,
148
+ keywords: Array.isArray(params.attentionKeywords)
149
+ ? params.attentionKeywords.slice()
150
+ : [],
151
+ });
152
+ }
89
153
  return {
90
154
  ok: true,
91
155
  result: {
@@ -125,8 +189,57 @@ export function createProvisioner(opts: ProvisionerOptions): (
125
189
  return { ok: true, result: res };
126
190
  }
127
191
 
192
+ case CONTROL_FRAME_TYPES.POLICY_UPDATED: {
193
+ const params = (frame.params ?? {}) as unknown as PolicyUpdatedParams;
194
+ const agentId = params.agent_id;
195
+ if (typeof agentId !== "string" || !agentId) {
196
+ return {
197
+ ok: false,
198
+ error: { code: "bad_params", message: "policy_updated requires agent_id" },
199
+ };
200
+ }
201
+ if (!policyResolver) {
202
+ // No resolver wired — quietly succeed; the daemon may be running
203
+ // without the gateway-level attention gate (e.g. legacy boot path).
204
+ daemonLog.debug("policy_updated: no resolver — noop", { agentId });
205
+ return { ok: true, result: { agent_id: agentId, applied: false } };
206
+ }
207
+ const roomId = typeof params.room_id === "string" ? params.room_id : undefined;
208
+ if (params.policy) {
209
+ // Embedded policy payload — install directly to avoid a refetch.
210
+ policyResolver.put(agentId, roomId ?? null, {
211
+ mode: params.policy.mode,
212
+ keywords: Array.isArray(params.policy.keywords)
213
+ ? params.policy.keywords.slice()
214
+ : [],
215
+ ...(typeof params.policy.muted_until === "number"
216
+ ? { muted_until: params.policy.muted_until }
217
+ : {}),
218
+ });
219
+ } else {
220
+ policyResolver.invalidate(agentId, roomId);
221
+ }
222
+ daemonLog.info("policy_updated: applied", {
223
+ agentId,
224
+ roomId: roomId ?? null,
225
+ embedded: !!params.policy,
226
+ });
227
+ return {
228
+ ok: true,
229
+ result: { agent_id: agentId, applied: true, embedded: !!params.policy },
230
+ };
231
+ }
232
+
128
233
  case CONTROL_FRAME_TYPES.LIST_RUNTIMES: {
129
- const snapshot = collectRuntimeSnapshot();
234
+ // Async path so the openclaw-acp endpoints get probed inline; gateway
235
+ // / WS errors are swallowed inside `collectRuntimeSnapshotAsync`.
236
+ let cfgForProbe: { openclawGateways?: any[] } | undefined;
237
+ try {
238
+ cfgForProbe = loadConfig();
239
+ } catch {
240
+ cfgForProbe = undefined;
241
+ }
242
+ const snapshot = await collectRuntimeSnapshotAsync({ cfg: cfgForProbe });
130
243
  daemonLog.debug("list_runtimes", { count: snapshot.runtimes.length });
131
244
  return { ok: true, result: snapshot };
132
245
  }
@@ -155,6 +268,8 @@ interface ProvisionCtx {
155
268
  register: typeof BotCordClient.register;
156
269
  }
157
270
 
271
+ const openclawProvisionLocks = new Map<string, Promise<unknown>>();
272
+
158
273
  async function provisionAgent(
159
274
  params: ProvisionAgentParams,
160
275
  ctx: ProvisionCtx,
@@ -166,13 +281,53 @@ async function provisionAgent(
166
281
  const explicitCwd = params.credentials?.cwd ?? params.cwd;
167
282
  assertSafeCwd(explicitCwd);
168
283
 
284
+ const openclawSel = pickOpenclawSelection(params);
285
+ if (openclawSel.gateway && openclawSel.agent) {
286
+ return withOpenclawProvisionLock(openclawSel.gateway, openclawSel.agent, async () => {
287
+ const existing = findCredentialsByOpenclaw(openclawSel.gateway!, openclawSel.agent!);
288
+ if (existing) {
289
+ daemonLog.info("provision_agent: openclaw binding already exists", {
290
+ gateway: openclawSel.gateway,
291
+ openclawAgent: openclawSel.agent,
292
+ agentId: existing.agentId,
293
+ });
294
+ return installExistingOpenclawBinding(existing.agentId, ctx);
295
+ }
296
+ const cfg = loadConfig();
297
+ const credentials = await materializeCredentials(params, cfg, ctx, explicitCwd);
298
+ return installLocalAgent(credentials, {
299
+ ...ctx,
300
+ cfg,
301
+ bio: params.bio,
302
+ source: params.credentials ? "hub-supplied" : "registered",
303
+ });
304
+ });
305
+ }
306
+
169
307
  const cfg = loadConfig();
170
308
  const credentials = await materializeCredentials(params, cfg, ctx, explicitCwd);
309
+ return installLocalAgent(credentials, {
310
+ ...ctx,
311
+ cfg,
312
+ bio: params.bio,
313
+ source: params.credentials ? "hub-supplied" : "registered",
314
+ });
315
+ }
316
+
317
+ async function installLocalAgent(
318
+ credentials: StoredBotCordCredentials,
319
+ ctx: ProvisionCtx & {
320
+ cfg: DaemonConfig;
321
+ bio?: string;
322
+ source: "hub-supplied" | "registered" | "adopted-openclaw";
323
+ },
324
+ ): Promise<ProvisionedAgent> {
325
+ const cfg = ctx.cfg;
171
326
  daemonLog.debug("provision: credentials materialized", {
172
327
  agentId: credentials.agentId,
173
328
  hubUrl: credentials.hubUrl,
174
329
  runtime: credentials.runtime ?? null,
175
- source: params.credentials ? "hub-supplied" : "registered",
330
+ source: ctx.source,
176
331
  });
177
332
 
178
333
  const credentialsFile = writeCredentialsFile(
@@ -186,7 +341,7 @@ async function provisionAgent(
186
341
  try {
187
342
  ensureAgentWorkspace(credentials.agentId, {
188
343
  displayName: credentials.displayName,
189
- bio: params.bio,
344
+ bio: ctx.bio,
190
345
  runtime: credentials.runtime,
191
346
  keyId: credentials.keyId,
192
347
  savedAt: credentials.savedAt,
@@ -246,11 +401,7 @@ async function provisionAgent(
246
401
  // Hot-add the synthesized per-agent managed route so the next turn picks
247
402
  // the agent's runtime + workspace cwd without waiting for reload_config.
248
403
  try {
249
- ctx.gateway.upsertManagedRoute(credentials.agentId, {
250
- match: { accountId: credentials.agentId },
251
- runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
252
- cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
253
- });
404
+ upsertManagedRouteForCredentials(credentials, cfg, ctx.gateway);
254
405
  } catch (err) {
255
406
  // Rollback the channel + config + credentials on managed-route failure
256
407
  // (shouldn't happen — pure map op — but keeps the invariant tight).
@@ -289,6 +440,63 @@ async function provisionAgent(
289
440
  };
290
441
  }
291
442
 
443
+ function upsertManagedRouteForCredentials(
444
+ credentials: StoredBotCordCredentials,
445
+ cfg: DaemonConfig,
446
+ gateway: Gateway,
447
+ ): void {
448
+ const synthRoute: import("./gateway/index.js").GatewayRoute = {
449
+ match: { accountId: credentials.agentId },
450
+ runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
451
+ cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
452
+ };
453
+ if (synthRoute.runtime === "openclaw-acp") {
454
+ const profile = (cfg.openclawGateways ?? []).find(
455
+ (g) => g.name === credentials.openclawGateway,
456
+ );
457
+ if (profile) {
458
+ const prepared = prepareGatewayProfile(profile);
459
+ synthRoute.gateway = {
460
+ name: prepared.name,
461
+ url: prepared.url,
462
+ ...(prepared.resolvedToken ? { token: prepared.resolvedToken } : {}),
463
+ ...(credentials.openclawAgent
464
+ ? { openclawAgent: credentials.openclawAgent }
465
+ : prepared.defaultAgent
466
+ ? { openclawAgent: prepared.defaultAgent }
467
+ : {}),
468
+ };
469
+ }
470
+ }
471
+ gateway.upsertManagedRoute(credentials.agentId, synthRoute);
472
+ }
473
+
474
+ async function installExistingOpenclawBinding(
475
+ agentId: string,
476
+ ctx: ProvisionCtx,
477
+ ): Promise<ProvisionedAgent> {
478
+ const credentialsFile = defaultCredentialsFile(agentId);
479
+ const credentials = loadStoredCredentials(credentialsFile);
480
+ const cfg = loadConfig();
481
+ const updated = addAgentToConfig(cfg, credentials.agentId);
482
+ if (updated) saveConfig(updated);
483
+ const snap = ctx.gateway.snapshot();
484
+ if (!snap.channels[credentials.agentId]) {
485
+ await ctx.gateway.addChannel({
486
+ id: credentials.agentId,
487
+ type: BOTCORD_CHANNEL_TYPE,
488
+ accountId: credentials.agentId,
489
+ agentId: credentials.agentId,
490
+ });
491
+ }
492
+ upsertManagedRouteForCredentials(credentials, cfg, ctx.gateway);
493
+ return {
494
+ agentId: credentials.agentId,
495
+ hubUrl: credentials.hubUrl,
496
+ credentialsFile,
497
+ };
498
+ }
499
+
292
500
  async function materializeCredentials(
293
501
  params: ProvisionAgentParams,
294
502
  cfg: DaemonConfig,
@@ -332,6 +540,9 @@ async function materializeCredentials(
332
540
  if (typeof c.tokenExpiresAt === "number") record.tokenExpiresAt = c.tokenExpiresAt;
333
541
  if (runtime) record.runtime = runtime;
334
542
  record.cwd = cwd;
543
+ const openclawSel = pickOpenclawSelection(params);
544
+ if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
545
+ if (openclawSel.agent) record.openclawAgent = openclawSel.agent;
335
546
  return record;
336
547
  }
337
548
 
@@ -361,9 +572,175 @@ async function materializeCredentials(
361
572
  };
362
573
  if (runtime) record.runtime = runtime;
363
574
  record.cwd = cwd;
575
+ const openclawSel = pickOpenclawSelection(params);
576
+ if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
577
+ if (openclawSel.agent) record.openclawAgent = openclawSel.agent;
364
578
  return record;
365
579
  }
366
580
 
581
+ /**
582
+ * Resolve OpenClaw routing selection from a `provision_agent` frame. Top-level
583
+ * `params.openclaw` (nested) wins over the flat `credentials.openclaw*` mirror.
584
+ * Returning `{}` is fine — only meaningful when the agent's runtime is
585
+ * `openclaw-acp`, and `buildManagedRoutes` falls back to defaultRoute.gateway
586
+ * when both are missing.
587
+ */
588
+ function pickOpenclawSelection(
589
+ params: ProvisionAgentParams,
590
+ ): { gateway?: string; agent?: string } {
591
+ const out: { gateway?: string; agent?: string } = {};
592
+ const top = params.openclaw;
593
+ if (top && typeof top.gateway === "string" && top.gateway.length > 0) {
594
+ out.gateway = top.gateway;
595
+ if (typeof top.agent === "string" && top.agent.length > 0) out.agent = top.agent;
596
+ return out;
597
+ }
598
+ const flat = params.credentials;
599
+ if (flat) {
600
+ if (typeof flat.openclawGateway === "string" && flat.openclawGateway.length > 0) {
601
+ out.gateway = flat.openclawGateway;
602
+ }
603
+ if (typeof flat.openclawAgent === "string" && flat.openclawAgent.length > 0) {
604
+ out.agent = flat.openclawAgent;
605
+ }
606
+ }
607
+ return out;
608
+ }
609
+
610
+ async function withOpenclawProvisionLock<T>(
611
+ gateway: string,
612
+ agent: string,
613
+ fn: () => Promise<T>,
614
+ ): Promise<T> {
615
+ const key = `${gateway}\0${agent}`;
616
+ const prev = openclawProvisionLocks.get(key) ?? Promise.resolve();
617
+ let release!: () => void;
618
+ const current = new Promise<void>((resolve) => {
619
+ release = resolve;
620
+ });
621
+ const chain = prev.then(() => current);
622
+ openclawProvisionLocks.set(key, chain);
623
+ await prev.catch(() => undefined);
624
+ try {
625
+ return await fn();
626
+ } finally {
627
+ release();
628
+ if (openclawProvisionLocks.get(key) === chain) {
629
+ openclawProvisionLocks.delete(key);
630
+ }
631
+ }
632
+ }
633
+
634
+ function findCredentialsByOpenclaw(
635
+ gateway: string,
636
+ openclawAgent: string,
637
+ ): { agentId: string; credentialsFile: string } | null {
638
+ const discovered = discoverAgentCredentials({
639
+ credentialsDir: path.join(homedir(), ".botcord", "credentials"),
640
+ });
641
+ for (const a of discovered.agents) {
642
+ if (a.openclawGateway === gateway && a.openclawAgent === openclawAgent) {
643
+ return { agentId: a.agentId, credentialsFile: a.credentialsFile };
644
+ }
645
+ }
646
+ return null;
647
+ }
648
+
649
+ export interface AdoptDiscoveredOpenclawAgentsResult {
650
+ adopted: string[];
651
+ skipped: Array<{ gateway: string; openclawAgent?: string; reason: string }>;
652
+ failed: Array<{ gateway: string; openclawAgent?: string; error: string }>;
653
+ }
654
+
655
+ export async function adoptDiscoveredOpenclawAgents(ctx: {
656
+ gateway: Gateway;
657
+ register?: typeof BotCordClient.register;
658
+ cfg?: DaemonConfig;
659
+ timeoutMs?: number;
660
+ probe?: WsEndpointProbeFn;
661
+ }): Promise<AdoptDiscoveredOpenclawAgentsResult> {
662
+ const register = ctx.register ?? BotCordClient.register;
663
+ const cfg = ctx.cfg ?? loadConfig();
664
+ const result: AdoptDiscoveredOpenclawAgentsResult = {
665
+ adopted: [],
666
+ skipped: [],
667
+ failed: [],
668
+ };
669
+ for (const gw of cfg.openclawGateways ?? []) {
670
+ let probeResult: Awaited<ReturnType<typeof probeOpenclawAgents>>;
671
+ try {
672
+ probeResult = await probeOpenclawAgents(gw, {
673
+ timeoutMs: ctx.timeoutMs,
674
+ probe: ctx.probe,
675
+ });
676
+ } catch (err) {
677
+ result.failed.push({
678
+ gateway: gw.name,
679
+ error: err instanceof Error ? err.message : String(err),
680
+ });
681
+ continue;
682
+ }
683
+ if (!probeResult.ok) {
684
+ result.skipped.push({
685
+ gateway: gw.name,
686
+ reason: probeResult.error ?? "gateway_unreachable",
687
+ });
688
+ continue;
689
+ }
690
+ for (const oc of probeResult.agents ?? []) {
691
+ await withOpenclawProvisionLock(gw.name, oc.id, async () => {
692
+ const existing = findCredentialsByOpenclaw(gw.name, oc.id);
693
+ if (existing) {
694
+ result.skipped.push({
695
+ gateway: gw.name,
696
+ openclawAgent: oc.id,
697
+ reason: "already_bound",
698
+ });
699
+ return;
700
+ }
701
+ const freshCfg = loadConfig();
702
+ if (!inferHubUrl(freshCfg)) {
703
+ result.skipped.push({
704
+ gateway: gw.name,
705
+ openclawAgent: oc.id,
706
+ reason: "missing_hub_url",
707
+ });
708
+ daemonLog.warn("openclaw adopt skipped: no known hubUrl", {
709
+ gateway: gw.name,
710
+ openclawAgent: oc.id,
711
+ });
712
+ return;
713
+ }
714
+ try {
715
+ const params: ProvisionAgentParams = {
716
+ runtime: "openclaw-acp",
717
+ name: oc.name ?? `openclaw-${oc.id}`,
718
+ openclaw: { gateway: gw.name, agent: oc.id },
719
+ };
720
+ const credentials = await materializeCredentials(params, freshCfg, {
721
+ gateway: ctx.gateway,
722
+ register,
723
+ }, undefined);
724
+ const installed = await installLocalAgent(credentials, {
725
+ gateway: ctx.gateway,
726
+ register,
727
+ cfg: freshCfg,
728
+ source: "adopted-openclaw",
729
+ });
730
+ result.adopted.push(installed.agentId);
731
+ } catch (err) {
732
+ result.failed.push({
733
+ gateway: gw.name,
734
+ openclawAgent: oc.id,
735
+ error: err instanceof Error ? err.message : String(err),
736
+ });
737
+ }
738
+ });
739
+ }
740
+ }
741
+ return result;
742
+ }
743
+
367
744
  async function revokeAgent(
368
745
  params: RevokeAgentParams,
369
746
  ctx: { gateway: Gateway },
@@ -555,6 +932,277 @@ export function collectRuntimeSnapshot(): ListRuntimesResult {
555
932
  return { runtimes, probedAt: Date.now() };
556
933
  }
557
934
 
935
+ /** Maximum number of `endpoints[]` entries persisted per runtime (RFC §3.8.2). */
936
+ export const RUNTIME_ENDPOINTS_CAP = 32;
937
+
938
+ /** Injection seam for L2 + L3 endpoint probes — kept testable + side-effect-free. */
939
+ export type WsEndpointProbeFn = (args: {
940
+ url: string;
941
+ token?: string;
942
+ timeoutMs: number;
943
+ }) => Promise<{
944
+ ok: boolean;
945
+ version?: string;
946
+ /**
947
+ * L3 — populated when `agents.list` succeeds. `id` is the stable key
948
+ * consumed by route lookups / `openclawAgent`; `name` is display-only.
949
+ */
950
+ agents?: Array<{
951
+ id: string;
952
+ name?: string;
953
+ workspace?: string;
954
+ model?: { name?: string; provider?: string };
955
+ }>;
956
+ error?: string;
957
+ }>;
958
+
959
+ /**
960
+ * Default L2 + L3 probe — opens a WS handshake against the OpenClaw gateway
961
+ * and, when the connection is up, issues a JSON-RPC `agents.list` request to
962
+ * enumerate configured agent profiles. Best-effort: a successful WS open with
963
+ * a failed `agents.list` still reports `ok: true` (just without `agents`),
964
+ * matching the RFC's "agents populated only when listing succeeded" rule.
965
+ *
966
+ * Method name and result shape follow OpenClaw:
967
+ * `~/claws/openclaw/src/gateway/server-methods/agents.ts:416` and
968
+ * `~/claws/openclaw/src/gateway/session-utils.ts:783` —
969
+ * `{ defaultId, mainKey, scope, agents: [{ id, name?, identity?, workspace, model? }] }`.
970
+ */
971
+ async function defaultWsProbe(args: {
972
+ url: string;
973
+ token?: string;
974
+ timeoutMs: number;
975
+ }): Promise<{
976
+ ok: boolean;
977
+ version?: string;
978
+ agents?: Array<{
979
+ id: string;
980
+ name?: string;
981
+ workspace?: string;
982
+ model?: { name?: string; provider?: string };
983
+ }>;
984
+ error?: string;
985
+ }> {
986
+ type AgentRow = {
987
+ id: string;
988
+ name?: string;
989
+ workspace?: string;
990
+ model?: { name?: string; provider?: string };
991
+ };
992
+ type ProbeResult = {
993
+ ok: boolean;
994
+ version?: string;
995
+ agents?: AgentRow[];
996
+ error?: string;
997
+ };
998
+ const { default: WebSocket } = await import("ws");
999
+ return new Promise<ProbeResult>((resolve) => {
1000
+ let settled = false;
1001
+ let ws: any;
1002
+ let timer: ReturnType<typeof setTimeout> | undefined;
1003
+ const settle = (v: ProbeResult): void => {
1004
+ if (settled) return;
1005
+ settled = true;
1006
+ if (timer) clearTimeout(timer);
1007
+ try {
1008
+ ws?.terminate();
1009
+ } catch {
1010
+ // ignore
1011
+ }
1012
+ resolve(v);
1013
+ };
1014
+ try {
1015
+ const headers: Record<string, string> = {};
1016
+ if (args.token) headers["Authorization"] = `Bearer ${args.token}`;
1017
+ ws = new WebSocket(args.url, { headers });
1018
+ } catch (err) {
1019
+ resolve({ ok: false, error: (err as Error).message });
1020
+ return;
1021
+ }
1022
+ timer = setTimeout(() => settle({ ok: false, error: "timeout" }), args.timeoutMs);
1023
+ const requestId = "probe-agents-list";
1024
+ ws.on("open", () => {
1025
+ // L3: enumerate agent profiles. We don't fail the L2 result if this
1026
+ // call fails — the gateway is reachable either way.
1027
+ try {
1028
+ ws.send(
1029
+ JSON.stringify({
1030
+ jsonrpc: "2.0",
1031
+ id: requestId,
1032
+ method: "agents.list",
1033
+ params: {},
1034
+ }),
1035
+ );
1036
+ } catch (err) {
1037
+ settle({ ok: true, error: `agents.list send failed: ${(err as Error).message}` });
1038
+ }
1039
+ });
1040
+ ws.on("message", (raw: Buffer | string) => {
1041
+ try {
1042
+ const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
1043
+ if (msg?.id !== requestId) return; // ignore unrelated frames
1044
+ if (msg.error) {
1045
+ settle({ ok: true, error: String(msg.error?.message ?? "agents.list error") });
1046
+ return;
1047
+ }
1048
+ const list = Array.isArray(msg.result?.agents) ? msg.result.agents : [];
1049
+ const agents: AgentRow[] = [];
1050
+ for (const a of list) {
1051
+ if (!a || typeof a.id !== "string" || a.id.length === 0) continue;
1052
+ const row: AgentRow = { id: a.id };
1053
+ if (typeof a.name === "string") row.name = a.name;
1054
+ if (typeof a.workspace === "string") row.workspace = a.workspace;
1055
+ if (a.model && typeof a.model === "object") {
1056
+ const model: { name?: string; provider?: string } = {};
1057
+ if (typeof a.model.name === "string") model.name = a.model.name;
1058
+ if (typeof a.model.provider === "string") model.provider = a.model.provider;
1059
+ if (model.name || model.provider) row.model = model;
1060
+ }
1061
+ agents.push(row);
1062
+ }
1063
+ settle({ ok: true, agents });
1064
+ } catch (err) {
1065
+ settle({ ok: true, error: `agents.list parse failed: ${(err as Error).message}` });
1066
+ }
1067
+ });
1068
+ ws.on("error", (err: Error) => {
1069
+ settle({ ok: false, error: err.message });
1070
+ });
1071
+ ws.on("close", () => {
1072
+ // If the socket closes before `agents.list` resolved we still treat
1073
+ // L2 as ok (open fired) and emit no agents.
1074
+ settle({ ok: true });
1075
+ });
1076
+ });
1077
+ }
1078
+
1079
+ export async function probeOpenclawAgents(
1080
+ profile: { url: string; token?: string; tokenFile?: string },
1081
+ opts: { timeoutMs?: number; probe?: WsEndpointProbeFn } = {},
1082
+ ): Promise<{
1083
+ ok: boolean;
1084
+ version?: string;
1085
+ agents?: Array<{
1086
+ id: string;
1087
+ name?: string;
1088
+ workspace?: string;
1089
+ model?: { name?: string; provider?: string };
1090
+ }>;
1091
+ error?: string;
1092
+ }> {
1093
+ const probe = opts.probe ?? defaultWsProbe;
1094
+ const prepared = prepareGatewayProfile({
1095
+ name: "probe",
1096
+ url: profile.url,
1097
+ ...(profile.token ? { token: profile.token } : {}),
1098
+ ...(profile.tokenFile ? { tokenFile: profile.tokenFile } : {}),
1099
+ });
1100
+ return probe({
1101
+ url: profile.url,
1102
+ token: prepared.resolvedToken,
1103
+ timeoutMs: opts.timeoutMs ?? 3000,
1104
+ });
1105
+ }
1106
+
1107
+ /**
1108
+ * Async variant that includes L2 (gateway reachability) and L3 (agent listing)
1109
+ * probes for runtimes that talk to external services. Used by the production
1110
+ * `list_runtimes` and first-connect snapshot paths.
1111
+ *
1112
+ * `cfg` is optional so existing callers without a loaded config (e.g. tests)
1113
+ * can keep using the sync `collectRuntimeSnapshot()` — when absent, the result
1114
+ * is identical to that function.
1115
+ */
1116
+ export async function collectRuntimeSnapshotAsync(opts: {
1117
+ cfg?: { openclawGateways?: Array<{ name: string; url: string; token?: string; tokenFile?: string }> };
1118
+ wsProbe?: WsEndpointProbeFn;
1119
+ timeoutMs?: number;
1120
+ } = {}): Promise<ListRuntimesResult> {
1121
+ const base = collectRuntimeSnapshot();
1122
+ const gateways = opts.cfg?.openclawGateways ?? [];
1123
+ if (gateways.length === 0) return base;
1124
+ // Default daemon-side budget is 3s — it must stay below the Hub's
1125
+ // `list_runtimes` ack wait (5s, see backend/hub/routers/daemon_control.py)
1126
+ // so a single slow gateway can't blow the whole snapshot to a 504.
1127
+ const timeoutMs = opts.timeoutMs ?? 3000;
1128
+ const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
1129
+ const endpoints = await Promise.all(
1130
+ capped.map(async (g) => {
1131
+ try {
1132
+ const res = await probeOpenclawAgents(g, {
1133
+ probe: opts.wsProbe,
1134
+ timeoutMs,
1135
+ });
1136
+ const entry: any = { name: g.name, url: g.url, reachable: res.ok };
1137
+ if (res.version) entry.version = res.version;
1138
+ if (res.error) entry.error = res.error;
1139
+ if (res.agents) entry.agents = res.agents;
1140
+ return entry;
1141
+ } catch (err) {
1142
+ return {
1143
+ name: g.name,
1144
+ url: g.url,
1145
+ reachable: false,
1146
+ error: (err as Error).message,
1147
+ };
1148
+ }
1149
+ }),
1150
+ );
1151
+ const out: ListRuntimesResult = { ...base };
1152
+ out.runtimes = base.runtimes.map((r) =>
1153
+ r.id === "openclaw-acp" ? { ...r, endpoints } : r,
1154
+ );
1155
+ return out;
1156
+ }
1157
+
1158
+ // ---------------------------------------------------------------------------
1159
+ // hello agents snapshot (lightweight identity sync)
1160
+ // ---------------------------------------------------------------------------
1161
+
1162
+ interface HelloIdentityResult {
1163
+ updated: number;
1164
+ skipped: number;
1165
+ }
1166
+
1167
+ /**
1168
+ * Reconcile every agent identity carried by the `hello.agents` snapshot
1169
+ * against the on-disk `identity.md`. Best-effort: a malformed entry or a
1170
+ * file-system error for one agent never aborts the rest.
1171
+ *
1172
+ * Identity-snapshot semantics intentionally only touch the metadata
1173
+ * line + Bio body — Role/Boundaries paragraphs the user authored locally
1174
+ * are preserved (see `applyAgentIdentity`). Missing identity.md files
1175
+ * (agent provisioned on a different daemon, or workspace cleared) are
1176
+ * silently skipped.
1177
+ */
1178
+ export function applyHelloIdentitySnapshot(
1179
+ snapshot: AgentIdentitySnapshot[] | undefined,
1180
+ ): HelloIdentityResult {
1181
+ const out: HelloIdentityResult = { updated: 0, skipped: 0 };
1182
+ if (!Array.isArray(snapshot)) return out;
1183
+ for (const entry of snapshot) {
1184
+ if (!entry || typeof entry.agentId !== "string") {
1185
+ out.skipped += 1;
1186
+ continue;
1187
+ }
1188
+ try {
1189
+ const result = applyAgentIdentity(entry.agentId, {
1190
+ displayName: entry.displayName,
1191
+ bio: entry.bio,
1192
+ });
1193
+ if (result.changed) out.updated += 1;
1194
+ else out.skipped += 1;
1195
+ } catch (err) {
1196
+ out.skipped += 1;
1197
+ daemonLog.warn("hello.identity apply failed", {
1198
+ agentId: entry.agentId,
1199
+ error: err instanceof Error ? err.message : String(err),
1200
+ });
1201
+ }
1202
+ }
1203
+ return out;
1204
+ }
1205
+
558
1206
  // ---------------------------------------------------------------------------
559
1207
  // reload_config / list_agents / set_route handlers (P3)
560
1208
  // ---------------------------------------------------------------------------
@@ -641,17 +1289,19 @@ export async function reloadConfig(ctx: { gateway: Gateway }): Promise<ReloadRes
641
1289
  */
642
1290
  function readAgentRuntimesFromCredentials(
643
1291
  agentIds: string[],
644
- ): Record<string, { runtime?: string; cwd?: string }> {
645
- const out: Record<string, { runtime?: string; cwd?: string }> = {};
1292
+ ): Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }> {
1293
+ const out: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }> = {};
646
1294
  for (const id of agentIds) {
647
1295
  const file = defaultCredentialsFile(id);
648
1296
  try {
649
1297
  if (!existsSync(file)) continue;
650
1298
  const creds = loadStoredCredentials(file);
651
- const entry: { runtime?: string; cwd?: string } = {};
1299
+ const entry: { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string } = {};
652
1300
  if (creds.runtime) entry.runtime = creds.runtime;
653
1301
  if (creds.cwd) entry.cwd = creds.cwd;
654
- if (entry.runtime || entry.cwd) out[id] = entry;
1302
+ if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
1303
+ if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
1304
+ if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent) out[id] = entry;
655
1305
  } catch {
656
1306
  // best-effort — skip agents with unreadable credentials
657
1307
  }
@@ -782,11 +1432,21 @@ export function setRoute(params: unknown): SetRouteResult {
782
1432
  match.conversationPrefix = p.pattern;
783
1433
  }
784
1434
 
1435
+ // Fall back to defaultRoute.extraArgs (mirrors adapter/cwd inheritance
1436
+ // above) so dashboard-driven `set_route` calls that only carry agentId +
1437
+ // pattern still pick up operator-wide flags like `--permission-mode
1438
+ // bypassPermissions`. Without this, every newly provisioned agent lost
1439
+ // those flags and Bash/MCP tool calls would deadlock on permission prompts.
1440
+ const extraArgs = Array.isArray(route?.extraArgs)
1441
+ ? route!.extraArgs!.slice()
1442
+ : Array.isArray(cfg.defaultRoute.extraArgs)
1443
+ ? cfg.defaultRoute.extraArgs.slice()
1444
+ : undefined;
785
1445
  const newRule: RouteRule = {
786
1446
  match,
787
1447
  adapter,
788
1448
  cwd,
789
- ...(Array.isArray(route?.extraArgs) ? { extraArgs: route!.extraArgs!.slice() } : {}),
1449
+ ...(extraArgs ? { extraArgs } : {}),
790
1450
  };
791
1451
 
792
1452
  const routes = Array.isArray(cfg.routes) ? cfg.routes.slice() : [];
@@ -872,5 +1532,13 @@ function inferHubUrl(cfg: DaemonConfig): string | null {
872
1532
  // skip
873
1533
  }
874
1534
  }
1535
+ if (ids.length === 0) {
1536
+ const discovered = discoverAgentCredentials({
1537
+ credentialsDir: path.join(homedir(), ".botcord", "credentials"),
1538
+ });
1539
+ for (const a of discovered.agents) {
1540
+ if (a.hubUrl) return a.hubUrl;
1541
+ }
1542
+ }
875
1543
  return null;
876
1544
  }