@botcord/daemon 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/agent-discovery.d.ts +7 -3
  2. package/dist/agent-discovery.js +9 -1
  3. package/dist/agent-workspace.d.ts +62 -0
  4. package/dist/agent-workspace.js +140 -10
  5. package/dist/config.d.ts +49 -1
  6. package/dist/config.js +57 -1
  7. package/dist/control-channel.d.ts +1 -4
  8. package/dist/control-channel.js +1 -4
  9. package/dist/daemon-config-map.d.ts +29 -12
  10. package/dist/daemon-config-map.js +105 -8
  11. package/dist/daemon.d.ts +2 -0
  12. package/dist/daemon.js +52 -5
  13. package/dist/doctor.d.ts +27 -1
  14. package/dist/doctor.js +22 -1
  15. package/dist/gateway/cli-resolver.d.ts +34 -0
  16. package/dist/gateway/cli-resolver.js +74 -0
  17. package/dist/gateway/dispatcher.d.ts +66 -1
  18. package/dist/gateway/dispatcher.js +583 -56
  19. package/dist/gateway/gateway.d.ts +29 -1
  20. package/dist/gateway/gateway.js +10 -0
  21. package/dist/gateway/index.d.ts +2 -0
  22. package/dist/gateway/index.js +2 -0
  23. package/dist/gateway/policy-resolver.d.ts +57 -0
  24. package/dist/gateway/policy-resolver.js +123 -0
  25. package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
  26. package/dist/gateway/runtimes/acp-stream.js +394 -0
  27. package/dist/gateway/runtimes/codex.js +7 -0
  28. package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
  29. package/dist/gateway/runtimes/hermes-agent.js +180 -0
  30. package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
  31. package/dist/gateway/runtimes/ndjson-stream.js +16 -3
  32. package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
  33. package/dist/gateway/runtimes/openclaw-acp.js +500 -0
  34. package/dist/gateway/runtimes/registry.d.ts +4 -0
  35. package/dist/gateway/runtimes/registry.js +22 -0
  36. package/dist/gateway/transcript-paths.d.ts +30 -0
  37. package/dist/gateway/transcript-paths.js +114 -0
  38. package/dist/gateway/transcript.d.ts +123 -0
  39. package/dist/gateway/transcript.js +147 -0
  40. package/dist/gateway/types.d.ts +31 -0
  41. package/dist/index.js +286 -27
  42. package/dist/mention-scan.d.ts +22 -0
  43. package/dist/mention-scan.js +35 -0
  44. package/dist/provision.d.ts +73 -3
  45. package/dist/provision.js +373 -12
  46. package/dist/system-context.d.ts +5 -4
  47. package/dist/system-context.js +35 -5
  48. package/dist/turn-text.js +20 -1
  49. package/dist/url-utils.d.ts +9 -0
  50. package/dist/url-utils.js +18 -0
  51. package/dist/user-auth.js +0 -2
  52. package/dist/working-memory.js +1 -1
  53. package/package.json +2 -1
  54. package/src/__tests__/agent-workspace.test.ts +93 -0
  55. package/src/__tests__/daemon-config-map.test.ts +79 -0
  56. package/src/__tests__/openclaw-acp.test.ts +234 -0
  57. package/src/__tests__/policy-resolver.test.ts +124 -0
  58. package/src/__tests__/policy-updated-handler.test.ts +144 -0
  59. package/src/__tests__/provision.test.ts +160 -0
  60. package/src/__tests__/system-context.test.ts +52 -0
  61. package/src/__tests__/url-utils.test.ts +37 -0
  62. package/src/agent-discovery.ts +12 -4
  63. package/src/agent-workspace.ts +173 -9
  64. package/src/config.ts +132 -4
  65. package/src/control-channel.ts +1 -4
  66. package/src/daemon-config-map.ts +156 -12
  67. package/src/daemon.ts +66 -5
  68. package/src/doctor.ts +49 -2
  69. package/src/gateway/__tests__/dispatcher.test.ts +440 -2
  70. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
  71. package/src/gateway/__tests__/transcript.test.ts +496 -0
  72. package/src/gateway/cli-resolver.ts +92 -0
  73. package/src/gateway/dispatcher.ts +681 -58
  74. package/src/gateway/gateway.ts +46 -0
  75. package/src/gateway/index.ts +25 -0
  76. package/src/gateway/policy-resolver.ts +171 -0
  77. package/src/gateway/runtimes/acp-stream.ts +535 -0
  78. package/src/gateway/runtimes/codex.ts +7 -0
  79. package/src/gateway/runtimes/hermes-agent.ts +206 -0
  80. package/src/gateway/runtimes/ndjson-stream.ts +16 -3
  81. package/src/gateway/runtimes/openclaw-acp.ts +606 -0
  82. package/src/gateway/runtimes/registry.ts +24 -0
  83. package/src/gateway/transcript-paths.ts +145 -0
  84. package/src/gateway/transcript.ts +300 -0
  85. package/src/gateway/types.ts +32 -0
  86. package/src/index.ts +295 -30
  87. package/src/mention-scan.ts +38 -0
  88. package/src/provision.ts +446 -20
  89. package/src/system-context.ts +41 -9
  90. package/src/turn-text.ts +22 -1
  91. package/src/url-utils.ts +17 -0
  92. package/src/user-auth.ts +0 -2
  93. package/src/working-memory.ts +1 -1
package/src/provision.ts CHANGED
@@ -3,8 +3,6 @@
3
3
  * to this module with the parsed {@link ControlFrame}; we execute the
4
4
  * side effects (register agent, write credentials, load route, add/remove
5
5
  * gateway channel) and return an ack payload.
6
- *
7
- * See `docs/daemon-control-plane-plan.md` §4.3, §5.3, §8.
8
6
  */
9
7
  import { existsSync, rmSync, unlinkSync } from "node:fs";
10
8
  import { homedir } from "node:os";
@@ -16,16 +14,21 @@ import {
16
14
  derivePublicKey,
17
15
  loadStoredCredentials,
18
16
  writeCredentialsFile,
17
+ type AgentIdentitySnapshot,
19
18
  type ControlAck,
20
19
  type ControlFrame,
20
+ type HelloParams,
21
21
  type ListRuntimesResult,
22
22
  type ProvisionAgentParams,
23
23
  type RevokeAgentParams,
24
24
  type RevokeAgentResult,
25
25
  type RuntimeProbeResult,
26
26
  type StoredBotCordCredentials,
27
+ type UpdateAgentParams,
27
28
  } from "@botcord/protocol-core";
28
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";
29
32
  import type {
30
33
  GatewayChannelConfig,
31
34
  GatewayRuntimeSnapshot,
@@ -38,11 +41,16 @@ import {
38
41
  type RouteRule,
39
42
  type RouteRuleMatch,
40
43
  } from "./config.js";
41
- 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";
42
49
  import {
43
50
  agentHomeDir,
44
51
  agentStateDir,
45
52
  agentWorkspaceDir,
53
+ applyAgentIdentity,
46
54
  ensureAgentWorkspace,
47
55
  } from "./agent-workspace.js";
48
56
  import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
@@ -57,6 +65,14 @@ export interface ProvisionerOptions {
57
65
  * run without a real Hub.
58
66
  */
59
67
  register?: typeof BotCordClient.register;
68
+ /**
69
+ * Optional policy-resolver handle (PR3). When present, the
70
+ * `policy_updated` control frame routes through it: cache is invalidated
71
+ * for the (agent, room?) pair, and any embedded `policy` payload is
72
+ * applied directly so the next inbound sees the fresh policy without an
73
+ * extra round-trip.
74
+ */
75
+ policyResolver?: PolicyResolverLike;
60
76
  }
61
77
 
62
78
  /** The value a frame handler returns (minus the `id` which the channel fills in). */
@@ -72,6 +88,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
72
88
  ) => Promise<AckBody> {
73
89
  const gateway = opts.gateway;
74
90
  const register = opts.register ?? BotCordClient.register;
91
+ const policyResolver = opts.policyResolver;
75
92
 
76
93
  return async (frame: ControlFrame): Promise<AckBody> => {
77
94
  daemonLog.debug("provision.dispatch", { type: frame.type, id: frame.id });
@@ -79,6 +96,38 @@ export function createProvisioner(opts: ProvisionerOptions): (
79
96
  case CONTROL_FRAME_TYPES.PING:
80
97
  return { ok: true, result: { pong: true, ts: Date.now() } };
81
98
 
99
+ case CONTROL_FRAME_TYPES.HELLO: {
100
+ const params = (frame.params ?? {}) as unknown as HelloParams;
101
+ const result = applyHelloIdentitySnapshot(params.agents);
102
+ daemonLog.debug("hello: identity snapshot applied", {
103
+ frameId: frame.id,
104
+ received: params.agents?.length ?? 0,
105
+ updated: result.updated,
106
+ skipped: result.skipped,
107
+ });
108
+ return { ok: true, result };
109
+ }
110
+
111
+ case CONTROL_FRAME_TYPES.UPDATE_AGENT: {
112
+ const params = (frame.params ?? {}) as unknown as UpdateAgentParams;
113
+ if (!params.agentId) {
114
+ return {
115
+ ok: false,
116
+ error: { code: "bad_params", message: "update_agent requires params.agentId" },
117
+ };
118
+ }
119
+ const result = applyAgentIdentity(params.agentId, {
120
+ displayName: params.displayName,
121
+ bio: params.bio,
122
+ });
123
+ daemonLog.info("update_agent applied", {
124
+ agentId: params.agentId,
125
+ changed: result.changed,
126
+ skipped: result.skipped ?? null,
127
+ });
128
+ return { ok: true, result };
129
+ }
130
+
82
131
  case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
83
132
  const params = (frame.params ?? {}) as unknown as ProvisionAgentParams;
84
133
  daemonLog.info("provision_agent: start", {
@@ -88,6 +137,18 @@ export function createProvisioner(opts: ProvisionerOptions): (
88
137
  name: params.name ?? null,
89
138
  });
90
139
  const agent = await provisionAgent(params, { gateway, register });
140
+ // Seed the policy resolver from the optional `defaultAttention` /
141
+ // `attentionKeywords` fields (PR3, control-frame.ts). Hub builds that
142
+ // don't yet emit these stay backwards-compatible — the resolver just
143
+ // falls back to `mode=always` until a `policy_updated` frame arrives.
144
+ if (policyResolver && params.defaultAttention) {
145
+ policyResolver.put(agent.agentId, null, {
146
+ mode: params.defaultAttention,
147
+ keywords: Array.isArray(params.attentionKeywords)
148
+ ? params.attentionKeywords.slice()
149
+ : [],
150
+ });
151
+ }
91
152
  return {
92
153
  ok: true,
93
154
  result: {
@@ -127,8 +188,57 @@ export function createProvisioner(opts: ProvisionerOptions): (
127
188
  return { ok: true, result: res };
128
189
  }
129
190
 
191
+ case CONTROL_FRAME_TYPES.POLICY_UPDATED: {
192
+ const params = (frame.params ?? {}) as unknown as PolicyUpdatedParams;
193
+ const agentId = params.agent_id;
194
+ if (typeof agentId !== "string" || !agentId) {
195
+ return {
196
+ ok: false,
197
+ error: { code: "bad_params", message: "policy_updated requires agent_id" },
198
+ };
199
+ }
200
+ if (!policyResolver) {
201
+ // No resolver wired — quietly succeed; the daemon may be running
202
+ // without the gateway-level attention gate (e.g. legacy boot path).
203
+ daemonLog.debug("policy_updated: no resolver — noop", { agentId });
204
+ return { ok: true, result: { agent_id: agentId, applied: false } };
205
+ }
206
+ const roomId = typeof params.room_id === "string" ? params.room_id : undefined;
207
+ if (params.policy) {
208
+ // Embedded policy payload — install directly to avoid a refetch.
209
+ policyResolver.put(agentId, roomId ?? null, {
210
+ mode: params.policy.mode,
211
+ keywords: Array.isArray(params.policy.keywords)
212
+ ? params.policy.keywords.slice()
213
+ : [],
214
+ ...(typeof params.policy.muted_until === "number"
215
+ ? { muted_until: params.policy.muted_until }
216
+ : {}),
217
+ });
218
+ } else {
219
+ policyResolver.invalidate(agentId, roomId);
220
+ }
221
+ daemonLog.info("policy_updated: applied", {
222
+ agentId,
223
+ roomId: roomId ?? null,
224
+ embedded: !!params.policy,
225
+ });
226
+ return {
227
+ ok: true,
228
+ result: { agent_id: agentId, applied: true, embedded: !!params.policy },
229
+ };
230
+ }
231
+
130
232
  case CONTROL_FRAME_TYPES.LIST_RUNTIMES: {
131
- const snapshot = collectRuntimeSnapshot();
233
+ // Async path so the openclaw-acp endpoints get probed inline; gateway
234
+ // / WS errors are swallowed inside `collectRuntimeSnapshotAsync`.
235
+ let cfgForProbe: { openclawGateways?: any[] } | undefined;
236
+ try {
237
+ cfgForProbe = loadConfig();
238
+ } catch {
239
+ cfgForProbe = undefined;
240
+ }
241
+ const snapshot = await collectRuntimeSnapshotAsync({ cfg: cfgForProbe });
132
242
  daemonLog.debug("list_runtimes", { count: snapshot.runtimes.length });
133
243
  return { ok: true, result: snapshot };
134
244
  }
@@ -248,11 +358,37 @@ async function provisionAgent(
248
358
  // Hot-add the synthesized per-agent managed route so the next turn picks
249
359
  // the agent's runtime + workspace cwd without waiting for reload_config.
250
360
  try {
251
- ctx.gateway.upsertManagedRoute(credentials.agentId, {
361
+ const synthRoute: import("./gateway/index.js").GatewayRoute = {
252
362
  match: { accountId: credentials.agentId },
253
363
  runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
254
364
  cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
255
- });
365
+ };
366
+ if (synthRoute.runtime === "openclaw-acp") {
367
+ // Resolve gateway from the freshly written credentials + the live
368
+ // openclawGateways registry. A missing/unknown gateway here yields a
369
+ // disabled route (set_route style); next turn for this agent falls
370
+ // back to defaultRoute. Caller already validated via reload semantics.
371
+ const profile = (cfg.openclawGateways ?? []).find(
372
+ (g) => g.name === credentials.openclawGateway,
373
+ );
374
+ if (profile) {
375
+ // Run the same tokenFile-aware resolver `toGatewayConfig` uses so the
376
+ // first turn after provisioning doesn't auth-fail when the gateway
377
+ // ships its bearer via `tokenFile` instead of an inline `token`.
378
+ const prepared = prepareGatewayProfile(profile);
379
+ synthRoute.gateway = {
380
+ name: prepared.name,
381
+ url: prepared.url,
382
+ ...(prepared.resolvedToken ? { token: prepared.resolvedToken } : {}),
383
+ ...(credentials.openclawAgent
384
+ ? { openclawAgent: credentials.openclawAgent }
385
+ : prepared.defaultAgent
386
+ ? { openclawAgent: prepared.defaultAgent }
387
+ : {}),
388
+ };
389
+ }
390
+ }
391
+ ctx.gateway.upsertManagedRoute(credentials.agentId, synthRoute);
256
392
  } catch (err) {
257
393
  // Rollback the channel + config + credentials on managed-route failure
258
394
  // (shouldn't happen — pure map op — but keeps the invariant tight).
@@ -297,9 +433,9 @@ async function materializeCredentials(
297
433
  ctx: ProvisionCtx,
298
434
  explicitCwd: string | undefined,
299
435
  ): Promise<StoredBotCordCredentials> {
300
- // Runtime is an agent property (docs/agent-runtime-property-plan.md §4.1).
301
- // Hub is authoritative; top-level `runtime` wins, `adapter` is a one-release
302
- // alias, and `credentials.runtime` is the per-agent cached copy.
436
+ // Runtime is an agent property. Hub is authoritative; top-level `runtime`
437
+ // wins, `adapter` is a one-release alias, and `credentials.runtime` is the
438
+ // per-agent cached copy.
303
439
  const runtime = pickRuntime(params);
304
440
  if (runtime) assertKnownRuntime(runtime);
305
441
 
@@ -334,6 +470,9 @@ async function materializeCredentials(
334
470
  if (typeof c.tokenExpiresAt === "number") record.tokenExpiresAt = c.tokenExpiresAt;
335
471
  if (runtime) record.runtime = runtime;
336
472
  record.cwd = cwd;
473
+ const openclawSel = pickOpenclawSelection(params);
474
+ if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
475
+ if (openclawSel.agent) record.openclawAgent = openclawSel.agent;
337
476
  return record;
338
477
  }
339
478
 
@@ -363,9 +502,41 @@ async function materializeCredentials(
363
502
  };
364
503
  if (runtime) record.runtime = runtime;
365
504
  record.cwd = cwd;
505
+ const openclawSel = pickOpenclawSelection(params);
506
+ if (openclawSel.gateway) record.openclawGateway = openclawSel.gateway;
507
+ if (openclawSel.agent) record.openclawAgent = openclawSel.agent;
366
508
  return record;
367
509
  }
368
510
 
511
+ /**
512
+ * Resolve OpenClaw routing selection from a `provision_agent` frame. Top-level
513
+ * `params.openclaw` (nested) wins over the flat `credentials.openclaw*` mirror.
514
+ * Returning `{}` is fine — only meaningful when the agent's runtime is
515
+ * `openclaw-acp`, and `buildManagedRoutes` falls back to defaultRoute.gateway
516
+ * when both are missing.
517
+ */
518
+ function pickOpenclawSelection(
519
+ params: ProvisionAgentParams,
520
+ ): { gateway?: string; agent?: string } {
521
+ const out: { gateway?: string; agent?: string } = {};
522
+ const top = params.openclaw;
523
+ if (top && typeof top.gateway === "string" && top.gateway.length > 0) {
524
+ out.gateway = top.gateway;
525
+ if (typeof top.agent === "string" && top.agent.length > 0) out.agent = top.agent;
526
+ return out;
527
+ }
528
+ const flat = params.credentials;
529
+ if (flat) {
530
+ if (typeof flat.openclawGateway === "string" && flat.openclawGateway.length > 0) {
531
+ out.gateway = flat.openclawGateway;
532
+ }
533
+ if (typeof flat.openclawAgent === "string" && flat.openclawAgent.length > 0) {
534
+ out.agent = flat.openclawAgent;
535
+ }
536
+ }
537
+ return out;
538
+ }
539
+
369
540
  async function revokeAgent(
370
541
  params: RevokeAgentParams,
371
542
  ctx: { gateway: Gateway },
@@ -557,6 +728,250 @@ export function collectRuntimeSnapshot(): ListRuntimesResult {
557
728
  return { runtimes, probedAt: Date.now() };
558
729
  }
559
730
 
731
+ /** Maximum number of `endpoints[]` entries persisted per runtime (RFC §3.8.2). */
732
+ export const RUNTIME_ENDPOINTS_CAP = 32;
733
+
734
+ /** Injection seam for L2 + L3 endpoint probes — kept testable + side-effect-free. */
735
+ export type WsEndpointProbeFn = (args: {
736
+ url: string;
737
+ token?: string;
738
+ timeoutMs: number;
739
+ }) => Promise<{
740
+ ok: boolean;
741
+ version?: string;
742
+ /**
743
+ * L3 — populated when `agents.list` succeeds. `id` is the stable key
744
+ * consumed by route lookups / `openclawAgent`; `name` is display-only.
745
+ */
746
+ agents?: Array<{
747
+ id: string;
748
+ name?: string;
749
+ workspace?: string;
750
+ model?: { name?: string; provider?: string };
751
+ }>;
752
+ error?: string;
753
+ }>;
754
+
755
+ /**
756
+ * Default L2 + L3 probe — opens a WS handshake against the OpenClaw gateway
757
+ * and, when the connection is up, issues a JSON-RPC `agents.list` request to
758
+ * enumerate configured agent profiles. Best-effort: a successful WS open with
759
+ * a failed `agents.list` still reports `ok: true` (just without `agents`),
760
+ * matching the RFC's "agents populated only when listing succeeded" rule.
761
+ *
762
+ * Method name and result shape follow OpenClaw:
763
+ * `~/claws/openclaw/src/gateway/server-methods/agents.ts:416` and
764
+ * `~/claws/openclaw/src/gateway/session-utils.ts:783` —
765
+ * `{ defaultId, mainKey, scope, agents: [{ id, name?, identity?, workspace, model? }] }`.
766
+ */
767
+ async function defaultWsProbe(args: {
768
+ url: string;
769
+ token?: string;
770
+ timeoutMs: number;
771
+ }): Promise<{
772
+ ok: boolean;
773
+ version?: string;
774
+ agents?: Array<{
775
+ id: string;
776
+ name?: string;
777
+ workspace?: string;
778
+ model?: { name?: string; provider?: string };
779
+ }>;
780
+ error?: string;
781
+ }> {
782
+ type AgentRow = {
783
+ id: string;
784
+ name?: string;
785
+ workspace?: string;
786
+ model?: { name?: string; provider?: string };
787
+ };
788
+ type ProbeResult = {
789
+ ok: boolean;
790
+ version?: string;
791
+ agents?: AgentRow[];
792
+ error?: string;
793
+ };
794
+ const { default: WebSocket } = await import("ws");
795
+ return new Promise<ProbeResult>((resolve) => {
796
+ let settled = false;
797
+ let ws: any;
798
+ let timer: ReturnType<typeof setTimeout> | undefined;
799
+ const settle = (v: ProbeResult): void => {
800
+ if (settled) return;
801
+ settled = true;
802
+ if (timer) clearTimeout(timer);
803
+ try {
804
+ ws?.terminate();
805
+ } catch {
806
+ // ignore
807
+ }
808
+ resolve(v);
809
+ };
810
+ try {
811
+ const headers: Record<string, string> = {};
812
+ if (args.token) headers["Authorization"] = `Bearer ${args.token}`;
813
+ ws = new WebSocket(args.url, { headers });
814
+ } catch (err) {
815
+ resolve({ ok: false, error: (err as Error).message });
816
+ return;
817
+ }
818
+ timer = setTimeout(() => settle({ ok: false, error: "timeout" }), args.timeoutMs);
819
+ const requestId = "probe-agents-list";
820
+ ws.on("open", () => {
821
+ // L3: enumerate agent profiles. We don't fail the L2 result if this
822
+ // call fails — the gateway is reachable either way.
823
+ try {
824
+ ws.send(
825
+ JSON.stringify({
826
+ jsonrpc: "2.0",
827
+ id: requestId,
828
+ method: "agents.list",
829
+ params: {},
830
+ }),
831
+ );
832
+ } catch (err) {
833
+ settle({ ok: true, error: `agents.list send failed: ${(err as Error).message}` });
834
+ }
835
+ });
836
+ ws.on("message", (raw: Buffer | string) => {
837
+ try {
838
+ const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
839
+ if (msg?.id !== requestId) return; // ignore unrelated frames
840
+ if (msg.error) {
841
+ settle({ ok: true, error: String(msg.error?.message ?? "agents.list error") });
842
+ return;
843
+ }
844
+ const list = Array.isArray(msg.result?.agents) ? msg.result.agents : [];
845
+ const agents: AgentRow[] = [];
846
+ for (const a of list) {
847
+ if (!a || typeof a.id !== "string" || a.id.length === 0) continue;
848
+ const row: AgentRow = { id: a.id };
849
+ if (typeof a.name === "string") row.name = a.name;
850
+ if (typeof a.workspace === "string") row.workspace = a.workspace;
851
+ if (a.model && typeof a.model === "object") {
852
+ const model: { name?: string; provider?: string } = {};
853
+ if (typeof a.model.name === "string") model.name = a.model.name;
854
+ if (typeof a.model.provider === "string") model.provider = a.model.provider;
855
+ if (model.name || model.provider) row.model = model;
856
+ }
857
+ agents.push(row);
858
+ }
859
+ settle({ ok: true, agents });
860
+ } catch (err) {
861
+ settle({ ok: true, error: `agents.list parse failed: ${(err as Error).message}` });
862
+ }
863
+ });
864
+ ws.on("error", (err: Error) => {
865
+ settle({ ok: false, error: err.message });
866
+ });
867
+ ws.on("close", () => {
868
+ // If the socket closes before `agents.list` resolved we still treat
869
+ // L2 as ok (open fired) and emit no agents.
870
+ settle({ ok: true });
871
+ });
872
+ });
873
+ }
874
+
875
+ /**
876
+ * Async variant that includes L2 (gateway reachability) and L3 (agent listing)
877
+ * probes for runtimes that talk to external services. Used by the production
878
+ * `list_runtimes` and first-connect snapshot paths.
879
+ *
880
+ * `cfg` is optional so existing callers without a loaded config (e.g. tests)
881
+ * can keep using the sync `collectRuntimeSnapshot()` — when absent, the result
882
+ * is identical to that function.
883
+ */
884
+ export async function collectRuntimeSnapshotAsync(opts: {
885
+ cfg?: { openclawGateways?: Array<{ name: string; url: string; token?: string; tokenFile?: string }> };
886
+ wsProbe?: WsEndpointProbeFn;
887
+ timeoutMs?: number;
888
+ } = {}): Promise<ListRuntimesResult> {
889
+ const base = collectRuntimeSnapshot();
890
+ const gateways = opts.cfg?.openclawGateways ?? [];
891
+ if (gateways.length === 0) return base;
892
+ const probe = opts.wsProbe ?? defaultWsProbe;
893
+ // Default daemon-side budget is 3s — it must stay below the Hub's
894
+ // `list_runtimes` ack wait (5s, see backend/hub/routers/daemon_control.py)
895
+ // so a single slow gateway can't blow the whole snapshot to a 504.
896
+ const timeoutMs = opts.timeoutMs ?? 3000;
897
+ const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
898
+ const endpoints = await Promise.all(
899
+ capped.map(async (g) => {
900
+ // Resolve `tokenFile` here so token-file-only profiles probe with auth
901
+ // and aren't falsely marked unreachable in the dashboard.
902
+ const prepared = prepareGatewayProfile(g);
903
+ try {
904
+ const res = await probe({ url: g.url, token: prepared.resolvedToken, timeoutMs });
905
+ const entry: any = { name: g.name, url: g.url, reachable: res.ok };
906
+ if (res.version) entry.version = res.version;
907
+ if (res.error) entry.error = res.error;
908
+ if (res.agents) entry.agents = res.agents;
909
+ return entry;
910
+ } catch (err) {
911
+ return {
912
+ name: g.name,
913
+ url: g.url,
914
+ reachable: false,
915
+ error: (err as Error).message,
916
+ };
917
+ }
918
+ }),
919
+ );
920
+ const out: ListRuntimesResult = { ...base };
921
+ out.runtimes = base.runtimes.map((r) =>
922
+ r.id === "openclaw-acp" ? { ...r, endpoints } : r,
923
+ );
924
+ return out;
925
+ }
926
+
927
+ // ---------------------------------------------------------------------------
928
+ // hello agents snapshot (lightweight identity sync)
929
+ // ---------------------------------------------------------------------------
930
+
931
+ interface HelloIdentityResult {
932
+ updated: number;
933
+ skipped: number;
934
+ }
935
+
936
+ /**
937
+ * Reconcile every agent identity carried by the `hello.agents` snapshot
938
+ * against the on-disk `identity.md`. Best-effort: a malformed entry or a
939
+ * file-system error for one agent never aborts the rest.
940
+ *
941
+ * Identity-snapshot semantics intentionally only touch the metadata
942
+ * line + Bio body — Role/Boundaries paragraphs the user authored locally
943
+ * are preserved (see `applyAgentIdentity`). Missing identity.md files
944
+ * (agent provisioned on a different daemon, or workspace cleared) are
945
+ * silently skipped.
946
+ */
947
+ export function applyHelloIdentitySnapshot(
948
+ snapshot: AgentIdentitySnapshot[] | undefined,
949
+ ): HelloIdentityResult {
950
+ const out: HelloIdentityResult = { updated: 0, skipped: 0 };
951
+ if (!Array.isArray(snapshot)) return out;
952
+ for (const entry of snapshot) {
953
+ if (!entry || typeof entry.agentId !== "string") {
954
+ out.skipped += 1;
955
+ continue;
956
+ }
957
+ try {
958
+ const result = applyAgentIdentity(entry.agentId, {
959
+ displayName: entry.displayName,
960
+ bio: entry.bio,
961
+ });
962
+ if (result.changed) out.updated += 1;
963
+ else out.skipped += 1;
964
+ } catch (err) {
965
+ out.skipped += 1;
966
+ daemonLog.warn("hello.identity apply failed", {
967
+ agentId: entry.agentId,
968
+ error: err instanceof Error ? err.message : String(err),
969
+ });
970
+ }
971
+ }
972
+ return out;
973
+ }
974
+
560
975
  // ---------------------------------------------------------------------------
561
976
  // reload_config / list_agents / set_route handlers (P3)
562
977
  // ---------------------------------------------------------------------------
@@ -643,17 +1058,19 @@ export async function reloadConfig(ctx: { gateway: Gateway }): Promise<ReloadRes
643
1058
  */
644
1059
  function readAgentRuntimesFromCredentials(
645
1060
  agentIds: string[],
646
- ): Record<string, { runtime?: string; cwd?: string }> {
647
- const out: Record<string, { runtime?: string; cwd?: string }> = {};
1061
+ ): Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }> {
1062
+ const out: Record<string, { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string }> = {};
648
1063
  for (const id of agentIds) {
649
1064
  const file = defaultCredentialsFile(id);
650
1065
  try {
651
1066
  if (!existsSync(file)) continue;
652
1067
  const creds = loadStoredCredentials(file);
653
- const entry: { runtime?: string; cwd?: string } = {};
1068
+ const entry: { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string } = {};
654
1069
  if (creds.runtime) entry.runtime = creds.runtime;
655
1070
  if (creds.cwd) entry.cwd = creds.cwd;
656
- if (entry.runtime || entry.cwd) out[id] = entry;
1071
+ if (creds.openclawGateway) entry.openclawGateway = creds.openclawGateway;
1072
+ if (creds.openclawAgent) entry.openclawAgent = creds.openclawAgent;
1073
+ if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent) out[id] = entry;
657
1074
  } catch {
658
1075
  // best-effort — skip agents with unreadable credentials
659
1076
  }
@@ -662,8 +1079,7 @@ function readAgentRuntimesFromCredentials(
662
1079
  }
663
1080
 
664
1081
  /**
665
- * Per-agent entry returned by `list_agents`. Shape follows
666
- * `docs/daemon-control-plane-api-contract.md` §3.2 — `{id, name, online}`.
1082
+ * Per-agent entry returned by `list_agents`. Wire shape: `{id, name, online}`.
667
1083
  * `status` and `lastMessageAt` are extra daemon-only fields the dashboard
668
1084
  * may ignore; kept so future contract revisions can promote them without
669
1085
  * breaking the wire.
@@ -725,10 +1141,10 @@ interface SetRouteResult {
725
1141
  interface SetRouteParams {
726
1142
  agentId?: string;
727
1143
  /**
728
- * Contract shape (`docs/daemon-control-plane-api-contract.md` §3.2):
729
- * `{pattern, agentId}`. `pattern` is treated as a conversation-id prefix
730
- * (`rm_oc_*` etc.). When `route` is omitted, we synthesize a sensible
731
- * default route record using the daemon's existing default adapter+cwd.
1144
+ * Contract shape `{pattern, agentId}`. `pattern` is treated as a
1145
+ * conversation-id prefix (`rm_oc_*` etc.). When `route` is omitted, we
1146
+ * synthesize a sensible default route record using the daemon's existing
1147
+ * default adapter+cwd.
732
1148
  */
733
1149
  pattern?: string;
734
1150
  /**
@@ -785,11 +1201,21 @@ export function setRoute(params: unknown): SetRouteResult {
785
1201
  match.conversationPrefix = p.pattern;
786
1202
  }
787
1203
 
1204
+ // Fall back to defaultRoute.extraArgs (mirrors adapter/cwd inheritance
1205
+ // above) so dashboard-driven `set_route` calls that only carry agentId +
1206
+ // pattern still pick up operator-wide flags like `--permission-mode
1207
+ // bypassPermissions`. Without this, every newly provisioned agent lost
1208
+ // those flags and Bash/MCP tool calls would deadlock on permission prompts.
1209
+ const extraArgs = Array.isArray(route?.extraArgs)
1210
+ ? route!.extraArgs!.slice()
1211
+ : Array.isArray(cfg.defaultRoute.extraArgs)
1212
+ ? cfg.defaultRoute.extraArgs.slice()
1213
+ : undefined;
788
1214
  const newRule: RouteRule = {
789
1215
  match,
790
1216
  adapter,
791
1217
  cwd,
792
- ...(Array.isArray(route?.extraArgs) ? { extraArgs: route!.extraArgs!.slice() } : {}),
1218
+ ...(extraArgs ? { extraArgs } : {}),
793
1219
  };
794
1220
 
795
1221
  const routes = Array.isArray(cfg.routes) ? cfg.routes.slice() : [];