@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/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,11 +41,16 @@ 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";
@@ -55,6 +65,14 @@ export interface ProvisionerOptions {
55
65
  * run without a real Hub.
56
66
  */
57
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;
58
76
  }
59
77
 
60
78
  /** The value a frame handler returns (minus the `id` which the channel fills in). */
@@ -70,6 +88,7 @@ export function createProvisioner(opts: ProvisionerOptions): (
70
88
  ) => Promise<AckBody> {
71
89
  const gateway = opts.gateway;
72
90
  const register = opts.register ?? BotCordClient.register;
91
+ const policyResolver = opts.policyResolver;
73
92
 
74
93
  return async (frame: ControlFrame): Promise<AckBody> => {
75
94
  daemonLog.debug("provision.dispatch", { type: frame.type, id: frame.id });
@@ -77,6 +96,38 @@ export function createProvisioner(opts: ProvisionerOptions): (
77
96
  case CONTROL_FRAME_TYPES.PING:
78
97
  return { ok: true, result: { pong: true, ts: Date.now() } };
79
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
+
80
131
  case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
81
132
  const params = (frame.params ?? {}) as unknown as ProvisionAgentParams;
82
133
  daemonLog.info("provision_agent: start", {
@@ -86,6 +137,18 @@ export function createProvisioner(opts: ProvisionerOptions): (
86
137
  name: params.name ?? null,
87
138
  });
88
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
+ }
89
152
  return {
90
153
  ok: true,
91
154
  result: {
@@ -125,8 +188,57 @@ export function createProvisioner(opts: ProvisionerOptions): (
125
188
  return { ok: true, result: res };
126
189
  }
127
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
+
128
232
  case CONTROL_FRAME_TYPES.LIST_RUNTIMES: {
129
- 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 });
130
242
  daemonLog.debug("list_runtimes", { count: snapshot.runtimes.length });
131
243
  return { ok: true, result: snapshot };
132
244
  }
@@ -246,11 +358,37 @@ async function provisionAgent(
246
358
  // Hot-add the synthesized per-agent managed route so the next turn picks
247
359
  // the agent's runtime + workspace cwd without waiting for reload_config.
248
360
  try {
249
- ctx.gateway.upsertManagedRoute(credentials.agentId, {
361
+ const synthRoute: import("./gateway/index.js").GatewayRoute = {
250
362
  match: { accountId: credentials.agentId },
251
363
  runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
252
364
  cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
253
- });
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);
254
392
  } catch (err) {
255
393
  // Rollback the channel + config + credentials on managed-route failure
256
394
  // (shouldn't happen — pure map op — but keeps the invariant tight).
@@ -332,6 +470,9 @@ async function materializeCredentials(
332
470
  if (typeof c.tokenExpiresAt === "number") record.tokenExpiresAt = c.tokenExpiresAt;
333
471
  if (runtime) record.runtime = runtime;
334
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;
335
476
  return record;
336
477
  }
337
478
 
@@ -361,9 +502,41 @@ async function materializeCredentials(
361
502
  };
362
503
  if (runtime) record.runtime = runtime;
363
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;
364
508
  return record;
365
509
  }
366
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
+
367
540
  async function revokeAgent(
368
541
  params: RevokeAgentParams,
369
542
  ctx: { gateway: Gateway },
@@ -555,6 +728,250 @@ export function collectRuntimeSnapshot(): ListRuntimesResult {
555
728
  return { runtimes, probedAt: Date.now() };
556
729
  }
557
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
+
558
975
  // ---------------------------------------------------------------------------
559
976
  // reload_config / list_agents / set_route handlers (P3)
560
977
  // ---------------------------------------------------------------------------
@@ -641,17 +1058,19 @@ export async function reloadConfig(ctx: { gateway: Gateway }): Promise<ReloadRes
641
1058
  */
642
1059
  function readAgentRuntimesFromCredentials(
643
1060
  agentIds: string[],
644
- ): Record<string, { runtime?: string; cwd?: string }> {
645
- 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 }> = {};
646
1063
  for (const id of agentIds) {
647
1064
  const file = defaultCredentialsFile(id);
648
1065
  try {
649
1066
  if (!existsSync(file)) continue;
650
1067
  const creds = loadStoredCredentials(file);
651
- const entry: { runtime?: string; cwd?: string } = {};
1068
+ const entry: { runtime?: string; cwd?: string; openclawGateway?: string; openclawAgent?: string } = {};
652
1069
  if (creds.runtime) entry.runtime = creds.runtime;
653
1070
  if (creds.cwd) entry.cwd = creds.cwd;
654
- 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;
655
1074
  } catch {
656
1075
  // best-effort — skip agents with unreadable credentials
657
1076
  }
@@ -782,11 +1201,21 @@ export function setRoute(params: unknown): SetRouteResult {
782
1201
  match.conversationPrefix = p.pattern;
783
1202
  }
784
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;
785
1214
  const newRule: RouteRule = {
786
1215
  match,
787
1216
  adapter,
788
1217
  cwd,
789
- ...(Array.isArray(route?.extraArgs) ? { extraArgs: route!.extraArgs!.slice() } : {}),
1218
+ ...(extraArgs ? { extraArgs } : {}),
790
1219
  };
791
1220
 
792
1221
  const routes = Array.isArray(cfg.routes) ? cfg.routes.slice() : [];
@@ -6,10 +6,11 @@
6
6
  * `RuntimeRunOptions.systemContext`. This module composes the daemon's
7
7
  * system-context string from:
8
8
  *
9
- * 1. `[BotCord Scene: Owner Chat]` (owner-trust turns only)
10
- * 2. `[BotCord Working Memory]`
11
- * 3. `[BotCord Room Context]` (group rooms, via optional async fetcher)
12
- * 4. `[BotCord Cross-Room Awareness]` (optional activity tracker)
9
+ * 1. `[BotCord Identity]` (read fresh from workspace/identity.md each turn)
10
+ * 2. `[BotCord Scene: Owner Chat]` (owner-trust turns only)
11
+ * 3. `[BotCord Working Memory]`
12
+ * 4. `[BotCord Room Context]` (group rooms, via optional async fetcher)
13
+ * 5. `[BotCord Cross-Room Awareness]` (optional activity tracker)
13
14
  *
14
15
  * Behavior:
15
16
  * - Working memory is loaded fresh per turn, so a `memory set` from another
@@ -26,6 +27,7 @@ import type { GatewayInboundMessage, SystemContextBuilder } from "./gateway/inde
26
27
  import type { ActivityTracker } from "./activity-tracker.js";
27
28
  import { buildCrossRoomDigest } from "./cross-room.js";
28
29
  import { buildWorkingMemoryPrompt, readWorkingMemory } from "./working-memory.js";
30
+ import { readIdentity } from "./agent-workspace.js";
29
31
  import { classifyActivitySender } from "./sender-classify.js";
30
32
  import { log } from "./log.js";
31
33
 
@@ -86,6 +88,31 @@ function safeReadWorkingMemory(agentId: string) {
86
88
  }
87
89
  }
88
90
 
91
+ /**
92
+ * Read identity.md and wrap it as a system-context block. Placed before
93
+ * every other block so the agent answers "who are you" from this file
94
+ * rather than from the underlying CLI's default persona ("I am Claude
95
+ * Code"). Re-read every turn so dashboard reconcile (`applyAgentIdentity`)
96
+ * and self-edits take effect immediately, mirroring working-memory
97
+ * semantics.
98
+ */
99
+ function buildIdentityPrompt(agentId: string): string | null {
100
+ let raw: string | null = null;
101
+ try {
102
+ raw = readIdentity(agentId);
103
+ } catch (err) {
104
+ log.warn("identity read failed", { agentId, err: String(err) });
105
+ return null;
106
+ }
107
+ if (!raw) return null;
108
+ return [
109
+ "[BotCord Identity]",
110
+ "Your persistent identity card. The fields below are the source of truth — when asked who you are, what you do, or what you will / will not do, answer from this block, not from the underlying CLI's default persona.",
111
+ "",
112
+ raw.trim(),
113
+ ].join("\n");
114
+ }
115
+
89
116
  /**
90
117
  * Build a {@link SystemContextBuilder} for the gateway dispatcher.
91
118
  *
@@ -97,10 +124,13 @@ export function createDaemonSystemContextBuilder(
97
124
  deps: SystemContextDeps,
98
125
  ): (message: GatewayInboundMessage) => Promise<string | undefined> | string | undefined {
99
126
  const gatherSyncBlocks = (message: GatewayInboundMessage): {
127
+ identity: string | null;
100
128
  ownerScene: string | null;
101
129
  memory: string | null;
102
130
  digest: string | null;
103
131
  } => {
132
+ const identity = buildIdentityPrompt(deps.agentId);
133
+
104
134
  const ownerScene =
105
135
  classifyActivitySender(message).kind === "owner"
106
136
  ? buildOwnerChatSceneContext()
@@ -118,7 +148,7 @@ export function createDaemonSystemContextBuilder(
118
148
  }) || null
119
149
  : null;
120
150
 
121
- return { ownerScene, memory, digest };
151
+ return { identity, ownerScene, memory, digest };
122
152
  };
123
153
 
124
154
  const assemble = (parts: Array<string | null | undefined>): string | undefined => {
@@ -144,11 +174,12 @@ export function createDaemonSystemContextBuilder(
144
174
 
145
175
  if (!deps.roomContextBuilder) {
146
176
  const syncBuilder = (message: GatewayInboundMessage): string | undefined => {
147
- const { ownerScene, memory, digest } = gatherSyncBlocks(message);
177
+ const { identity, ownerScene, memory, digest } = gatherSyncBlocks(message);
148
178
  // Loop-risk sits at the end so its "reply NO_REPLY unless…" guidance
149
179
  // is the last thing the model sees before the user turn body.
180
+ // Identity sits at the very front so it frames every other block.
150
181
  const loopRisk = runLoopRisk(message);
151
- return assemble([ownerScene, memory, digest, loopRisk]);
182
+ return assemble([identity, ownerScene, memory, digest, loopRisk]);
152
183
  };
153
184
  // Compile-time witness that the narrower sync signature still satisfies
154
185
  // `SystemContextBuilder` (which allows async). Prevents the two contracts
@@ -162,11 +193,12 @@ export function createDaemonSystemContextBuilder(
162
193
  const asyncBuilder = async (
163
194
  message: GatewayInboundMessage,
164
195
  ): Promise<string | undefined> => {
165
- const { ownerScene, memory, digest } = gatherSyncBlocks(message);
196
+ const { identity, ownerScene, memory, digest } = gatherSyncBlocks(message);
166
197
  // Room context landing order: after owner-scene / memory, before digest —
167
198
  // "what room am I in" belongs with the session's own identity, while the
168
199
  // cross-room digest deliberately describes OTHER rooms and should stay
169
200
  // last so it doesn't get confused with the current room.
201
+ // Identity stays at the very front; see syncBuilder for rationale.
170
202
  let roomBlock: string | null = null;
171
203
  try {
172
204
  roomBlock = await roomBuilder(message);
@@ -178,7 +210,7 @@ export function createDaemonSystemContextBuilder(
178
210
  });
179
211
  }
180
212
  const loopRisk = runLoopRisk(message);
181
- return assemble([ownerScene, memory, roomBlock, digest, loopRisk]);
213
+ return assemble([identity, ownerScene, memory, roomBlock, digest, loopRisk]);
182
214
  };
183
215
  const _typecheck: SystemContextBuilder = asyncBuilder;
184
216
  void _typecheck;