@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/dist/provision.js CHANGED
@@ -9,8 +9,8 @@ import { homedir } from "node:os";
9
9
  import path from "node:path";
10
10
  import { BotCordClient, CONTROL_FRAME_TYPES, defaultCredentialsFile, derivePublicKey, loadStoredCredentials, writeCredentialsFile, } from "@botcord/protocol-core";
11
11
  import { loadConfig, resolveConfiguredAgentIds, saveConfig, } from "./config.js";
12
- import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes } from "./daemon-config-map.js";
13
- import { agentHomeDir, agentStateDir, agentWorkspaceDir, ensureAgentWorkspace, } from "./agent-workspace.js";
12
+ import { BOTCORD_CHANNEL_TYPE, buildManagedRoutes, prepareGatewayProfile, } from "./daemon-config-map.js";
13
+ import { agentHomeDir, agentStateDir, agentWorkspaceDir, applyAgentIdentity, ensureAgentWorkspace, } from "./agent-workspace.js";
14
14
  import { detectRuntimes, getAdapterModule } from "./adapters/runtimes.js";
15
15
  import { log as daemonLog } from "./log.js";
16
16
  /**
@@ -21,11 +21,42 @@ import { log as daemonLog } from "./log.js";
21
21
  export function createProvisioner(opts) {
22
22
  const gateway = opts.gateway;
23
23
  const register = opts.register ?? BotCordClient.register;
24
+ const policyResolver = opts.policyResolver;
24
25
  return async (frame) => {
25
26
  daemonLog.debug("provision.dispatch", { type: frame.type, id: frame.id });
26
27
  switch (frame.type) {
27
28
  case CONTROL_FRAME_TYPES.PING:
28
29
  return { ok: true, result: { pong: true, ts: Date.now() } };
30
+ case CONTROL_FRAME_TYPES.HELLO: {
31
+ const params = (frame.params ?? {});
32
+ const result = applyHelloIdentitySnapshot(params.agents);
33
+ daemonLog.debug("hello: identity snapshot applied", {
34
+ frameId: frame.id,
35
+ received: params.agents?.length ?? 0,
36
+ updated: result.updated,
37
+ skipped: result.skipped,
38
+ });
39
+ return { ok: true, result };
40
+ }
41
+ case CONTROL_FRAME_TYPES.UPDATE_AGENT: {
42
+ const params = (frame.params ?? {});
43
+ if (!params.agentId) {
44
+ return {
45
+ ok: false,
46
+ error: { code: "bad_params", message: "update_agent requires params.agentId" },
47
+ };
48
+ }
49
+ const result = applyAgentIdentity(params.agentId, {
50
+ displayName: params.displayName,
51
+ bio: params.bio,
52
+ });
53
+ daemonLog.info("update_agent applied", {
54
+ agentId: params.agentId,
55
+ changed: result.changed,
56
+ skipped: result.skipped ?? null,
57
+ });
58
+ return { ok: true, result };
59
+ }
29
60
  case CONTROL_FRAME_TYPES.PROVISION_AGENT: {
30
61
  const params = (frame.params ?? {});
31
62
  daemonLog.info("provision_agent: start", {
@@ -35,6 +66,18 @@ export function createProvisioner(opts) {
35
66
  name: params.name ?? null,
36
67
  });
37
68
  const agent = await provisionAgent(params, { gateway, register });
69
+ // Seed the policy resolver from the optional `defaultAttention` /
70
+ // `attentionKeywords` fields (PR3, control-frame.ts). Hub builds that
71
+ // don't yet emit these stay backwards-compatible — the resolver just
72
+ // falls back to `mode=always` until a `policy_updated` frame arrives.
73
+ if (policyResolver && params.defaultAttention) {
74
+ policyResolver.put(agent.agentId, null, {
75
+ mode: params.defaultAttention,
76
+ keywords: Array.isArray(params.attentionKeywords)
77
+ ? params.attentionKeywords.slice()
78
+ : [],
79
+ });
80
+ }
38
81
  return {
39
82
  ok: true,
40
83
  result: {
@@ -69,8 +112,58 @@ export function createProvisioner(opts) {
69
112
  const res = setRoute(frame.params ?? {});
70
113
  return { ok: true, result: res };
71
114
  }
115
+ case CONTROL_FRAME_TYPES.POLICY_UPDATED: {
116
+ const params = (frame.params ?? {});
117
+ const agentId = params.agent_id;
118
+ if (typeof agentId !== "string" || !agentId) {
119
+ return {
120
+ ok: false,
121
+ error: { code: "bad_params", message: "policy_updated requires agent_id" },
122
+ };
123
+ }
124
+ if (!policyResolver) {
125
+ // No resolver wired — quietly succeed; the daemon may be running
126
+ // without the gateway-level attention gate (e.g. legacy boot path).
127
+ daemonLog.debug("policy_updated: no resolver — noop", { agentId });
128
+ return { ok: true, result: { agent_id: agentId, applied: false } };
129
+ }
130
+ const roomId = typeof params.room_id === "string" ? params.room_id : undefined;
131
+ if (params.policy) {
132
+ // Embedded policy payload — install directly to avoid a refetch.
133
+ policyResolver.put(agentId, roomId ?? null, {
134
+ mode: params.policy.mode,
135
+ keywords: Array.isArray(params.policy.keywords)
136
+ ? params.policy.keywords.slice()
137
+ : [],
138
+ ...(typeof params.policy.muted_until === "number"
139
+ ? { muted_until: params.policy.muted_until }
140
+ : {}),
141
+ });
142
+ }
143
+ else {
144
+ policyResolver.invalidate(agentId, roomId);
145
+ }
146
+ daemonLog.info("policy_updated: applied", {
147
+ agentId,
148
+ roomId: roomId ?? null,
149
+ embedded: !!params.policy,
150
+ });
151
+ return {
152
+ ok: true,
153
+ result: { agent_id: agentId, applied: true, embedded: !!params.policy },
154
+ };
155
+ }
72
156
  case CONTROL_FRAME_TYPES.LIST_RUNTIMES: {
73
- const snapshot = collectRuntimeSnapshot();
157
+ // Async path so the openclaw-acp endpoints get probed inline; gateway
158
+ // / WS errors are swallowed inside `collectRuntimeSnapshotAsync`.
159
+ let cfgForProbe;
160
+ try {
161
+ cfgForProbe = loadConfig();
162
+ }
163
+ catch {
164
+ cfgForProbe = undefined;
165
+ }
166
+ const snapshot = await collectRuntimeSnapshotAsync({ cfg: cfgForProbe });
74
167
  daemonLog.debug("list_runtimes", { count: snapshot.runtimes.length });
75
168
  return { ok: true, result: snapshot };
76
169
  }
@@ -174,11 +267,35 @@ async function provisionAgent(params, ctx) {
174
267
  // Hot-add the synthesized per-agent managed route so the next turn picks
175
268
  // the agent's runtime + workspace cwd without waiting for reload_config.
176
269
  try {
177
- ctx.gateway.upsertManagedRoute(credentials.agentId, {
270
+ const synthRoute = {
178
271
  match: { accountId: credentials.agentId },
179
272
  runtime: credentials.runtime ?? cfg.defaultRoute.adapter,
180
273
  cwd: credentials.cwd ?? agentWorkspaceDir(credentials.agentId),
181
- });
274
+ };
275
+ if (synthRoute.runtime === "openclaw-acp") {
276
+ // Resolve gateway from the freshly written credentials + the live
277
+ // openclawGateways registry. A missing/unknown gateway here yields a
278
+ // disabled route (set_route style); next turn for this agent falls
279
+ // back to defaultRoute. Caller already validated via reload semantics.
280
+ const profile = (cfg.openclawGateways ?? []).find((g) => g.name === credentials.openclawGateway);
281
+ if (profile) {
282
+ // Run the same tokenFile-aware resolver `toGatewayConfig` uses so the
283
+ // first turn after provisioning doesn't auth-fail when the gateway
284
+ // ships its bearer via `tokenFile` instead of an inline `token`.
285
+ const prepared = prepareGatewayProfile(profile);
286
+ synthRoute.gateway = {
287
+ name: prepared.name,
288
+ url: prepared.url,
289
+ ...(prepared.resolvedToken ? { token: prepared.resolvedToken } : {}),
290
+ ...(credentials.openclawAgent
291
+ ? { openclawAgent: credentials.openclawAgent }
292
+ : prepared.defaultAgent
293
+ ? { openclawAgent: prepared.defaultAgent }
294
+ : {}),
295
+ };
296
+ }
297
+ }
298
+ ctx.gateway.upsertManagedRoute(credentials.agentId, synthRoute);
182
299
  }
183
300
  catch (err) {
184
301
  // Rollback the channel + config + credentials on managed-route failure
@@ -259,6 +376,11 @@ async function materializeCredentials(params, cfg, ctx, explicitCwd) {
259
376
  if (runtime)
260
377
  record.runtime = runtime;
261
378
  record.cwd = cwd;
379
+ const openclawSel = pickOpenclawSelection(params);
380
+ if (openclawSel.gateway)
381
+ record.openclawGateway = openclawSel.gateway;
382
+ if (openclawSel.agent)
383
+ record.openclawAgent = openclawSel.agent;
262
384
  return record;
263
385
  }
264
386
  // Slow path: daemon registers a fresh identity against Hub. We need a
@@ -286,8 +408,40 @@ async function materializeCredentials(params, cfg, ctx, explicitCwd) {
286
408
  if (runtime)
287
409
  record.runtime = runtime;
288
410
  record.cwd = cwd;
411
+ const openclawSel = pickOpenclawSelection(params);
412
+ if (openclawSel.gateway)
413
+ record.openclawGateway = openclawSel.gateway;
414
+ if (openclawSel.agent)
415
+ record.openclawAgent = openclawSel.agent;
289
416
  return record;
290
417
  }
418
+ /**
419
+ * Resolve OpenClaw routing selection from a `provision_agent` frame. Top-level
420
+ * `params.openclaw` (nested) wins over the flat `credentials.openclaw*` mirror.
421
+ * Returning `{}` is fine — only meaningful when the agent's runtime is
422
+ * `openclaw-acp`, and `buildManagedRoutes` falls back to defaultRoute.gateway
423
+ * when both are missing.
424
+ */
425
+ function pickOpenclawSelection(params) {
426
+ const out = {};
427
+ const top = params.openclaw;
428
+ if (top && typeof top.gateway === "string" && top.gateway.length > 0) {
429
+ out.gateway = top.gateway;
430
+ if (typeof top.agent === "string" && top.agent.length > 0)
431
+ out.agent = top.agent;
432
+ return out;
433
+ }
434
+ const flat = params.credentials;
435
+ if (flat) {
436
+ if (typeof flat.openclawGateway === "string" && flat.openclawGateway.length > 0) {
437
+ out.gateway = flat.openclawGateway;
438
+ }
439
+ if (typeof flat.openclawAgent === "string" && flat.openclawAgent.length > 0) {
440
+ out.agent = flat.openclawAgent;
441
+ }
442
+ }
443
+ return out;
444
+ }
291
445
  async function revokeAgent(params, ctx) {
292
446
  if (!params.agentId) {
293
447
  throw new Error("revoke_agent requires params.agentId");
@@ -477,6 +631,201 @@ export function collectRuntimeSnapshot() {
477
631
  });
478
632
  return { runtimes, probedAt: Date.now() };
479
633
  }
634
+ /** Maximum number of `endpoints[]` entries persisted per runtime (RFC §3.8.2). */
635
+ export const RUNTIME_ENDPOINTS_CAP = 32;
636
+ /**
637
+ * Default L2 + L3 probe — opens a WS handshake against the OpenClaw gateway
638
+ * and, when the connection is up, issues a JSON-RPC `agents.list` request to
639
+ * enumerate configured agent profiles. Best-effort: a successful WS open with
640
+ * a failed `agents.list` still reports `ok: true` (just without `agents`),
641
+ * matching the RFC's "agents populated only when listing succeeded" rule.
642
+ *
643
+ * Method name and result shape follow OpenClaw:
644
+ * `~/claws/openclaw/src/gateway/server-methods/agents.ts:416` and
645
+ * `~/claws/openclaw/src/gateway/session-utils.ts:783` —
646
+ * `{ defaultId, mainKey, scope, agents: [{ id, name?, identity?, workspace, model? }] }`.
647
+ */
648
+ async function defaultWsProbe(args) {
649
+ const { default: WebSocket } = await import("ws");
650
+ return new Promise((resolve) => {
651
+ let settled = false;
652
+ let ws;
653
+ let timer;
654
+ const settle = (v) => {
655
+ if (settled)
656
+ return;
657
+ settled = true;
658
+ if (timer)
659
+ clearTimeout(timer);
660
+ try {
661
+ ws?.terminate();
662
+ }
663
+ catch {
664
+ // ignore
665
+ }
666
+ resolve(v);
667
+ };
668
+ try {
669
+ const headers = {};
670
+ if (args.token)
671
+ headers["Authorization"] = `Bearer ${args.token}`;
672
+ ws = new WebSocket(args.url, { headers });
673
+ }
674
+ catch (err) {
675
+ resolve({ ok: false, error: err.message });
676
+ return;
677
+ }
678
+ timer = setTimeout(() => settle({ ok: false, error: "timeout" }), args.timeoutMs);
679
+ const requestId = "probe-agents-list";
680
+ ws.on("open", () => {
681
+ // L3: enumerate agent profiles. We don't fail the L2 result if this
682
+ // call fails — the gateway is reachable either way.
683
+ try {
684
+ ws.send(JSON.stringify({
685
+ jsonrpc: "2.0",
686
+ id: requestId,
687
+ method: "agents.list",
688
+ params: {},
689
+ }));
690
+ }
691
+ catch (err) {
692
+ settle({ ok: true, error: `agents.list send failed: ${err.message}` });
693
+ }
694
+ });
695
+ ws.on("message", (raw) => {
696
+ try {
697
+ const msg = JSON.parse(typeof raw === "string" ? raw : raw.toString("utf8"));
698
+ if (msg?.id !== requestId)
699
+ return; // ignore unrelated frames
700
+ if (msg.error) {
701
+ settle({ ok: true, error: String(msg.error?.message ?? "agents.list error") });
702
+ return;
703
+ }
704
+ const list = Array.isArray(msg.result?.agents) ? msg.result.agents : [];
705
+ const agents = [];
706
+ for (const a of list) {
707
+ if (!a || typeof a.id !== "string" || a.id.length === 0)
708
+ continue;
709
+ const row = { id: a.id };
710
+ if (typeof a.name === "string")
711
+ row.name = a.name;
712
+ if (typeof a.workspace === "string")
713
+ row.workspace = a.workspace;
714
+ if (a.model && typeof a.model === "object") {
715
+ const model = {};
716
+ if (typeof a.model.name === "string")
717
+ model.name = a.model.name;
718
+ if (typeof a.model.provider === "string")
719
+ model.provider = a.model.provider;
720
+ if (model.name || model.provider)
721
+ row.model = model;
722
+ }
723
+ agents.push(row);
724
+ }
725
+ settle({ ok: true, agents });
726
+ }
727
+ catch (err) {
728
+ settle({ ok: true, error: `agents.list parse failed: ${err.message}` });
729
+ }
730
+ });
731
+ ws.on("error", (err) => {
732
+ settle({ ok: false, error: err.message });
733
+ });
734
+ ws.on("close", () => {
735
+ // If the socket closes before `agents.list` resolved we still treat
736
+ // L2 as ok (open fired) and emit no agents.
737
+ settle({ ok: true });
738
+ });
739
+ });
740
+ }
741
+ /**
742
+ * Async variant that includes L2 (gateway reachability) and L3 (agent listing)
743
+ * probes for runtimes that talk to external services. Used by the production
744
+ * `list_runtimes` and first-connect snapshot paths.
745
+ *
746
+ * `cfg` is optional so existing callers without a loaded config (e.g. tests)
747
+ * can keep using the sync `collectRuntimeSnapshot()` — when absent, the result
748
+ * is identical to that function.
749
+ */
750
+ export async function collectRuntimeSnapshotAsync(opts = {}) {
751
+ const base = collectRuntimeSnapshot();
752
+ const gateways = opts.cfg?.openclawGateways ?? [];
753
+ if (gateways.length === 0)
754
+ return base;
755
+ const probe = opts.wsProbe ?? defaultWsProbe;
756
+ // Default daemon-side budget is 3s — it must stay below the Hub's
757
+ // `list_runtimes` ack wait (5s, see backend/hub/routers/daemon_control.py)
758
+ // so a single slow gateway can't blow the whole snapshot to a 504.
759
+ const timeoutMs = opts.timeoutMs ?? 3000;
760
+ const capped = gateways.slice(0, RUNTIME_ENDPOINTS_CAP);
761
+ const endpoints = await Promise.all(capped.map(async (g) => {
762
+ // Resolve `tokenFile` here so token-file-only profiles probe with auth
763
+ // and aren't falsely marked unreachable in the dashboard.
764
+ const prepared = prepareGatewayProfile(g);
765
+ try {
766
+ const res = await probe({ url: g.url, token: prepared.resolvedToken, timeoutMs });
767
+ const entry = { name: g.name, url: g.url, reachable: res.ok };
768
+ if (res.version)
769
+ entry.version = res.version;
770
+ if (res.error)
771
+ entry.error = res.error;
772
+ if (res.agents)
773
+ entry.agents = res.agents;
774
+ return entry;
775
+ }
776
+ catch (err) {
777
+ return {
778
+ name: g.name,
779
+ url: g.url,
780
+ reachable: false,
781
+ error: err.message,
782
+ };
783
+ }
784
+ }));
785
+ const out = { ...base };
786
+ out.runtimes = base.runtimes.map((r) => r.id === "openclaw-acp" ? { ...r, endpoints } : r);
787
+ return out;
788
+ }
789
+ /**
790
+ * Reconcile every agent identity carried by the `hello.agents` snapshot
791
+ * against the on-disk `identity.md`. Best-effort: a malformed entry or a
792
+ * file-system error for one agent never aborts the rest.
793
+ *
794
+ * Identity-snapshot semantics intentionally only touch the metadata
795
+ * line + Bio body — Role/Boundaries paragraphs the user authored locally
796
+ * are preserved (see `applyAgentIdentity`). Missing identity.md files
797
+ * (agent provisioned on a different daemon, or workspace cleared) are
798
+ * silently skipped.
799
+ */
800
+ export function applyHelloIdentitySnapshot(snapshot) {
801
+ const out = { updated: 0, skipped: 0 };
802
+ if (!Array.isArray(snapshot))
803
+ return out;
804
+ for (const entry of snapshot) {
805
+ if (!entry || typeof entry.agentId !== "string") {
806
+ out.skipped += 1;
807
+ continue;
808
+ }
809
+ try {
810
+ const result = applyAgentIdentity(entry.agentId, {
811
+ displayName: entry.displayName,
812
+ bio: entry.bio,
813
+ });
814
+ if (result.changed)
815
+ out.updated += 1;
816
+ else
817
+ out.skipped += 1;
818
+ }
819
+ catch (err) {
820
+ out.skipped += 1;
821
+ daemonLog.warn("hello.identity apply failed", {
822
+ agentId: entry.agentId,
823
+ error: err instanceof Error ? err.message : String(err),
824
+ });
825
+ }
826
+ }
827
+ return out;
828
+ }
480
829
  /**
481
830
  * Re-read `config.json` and reconcile the running gateway against it. New
482
831
  * agents in config but not in gateway snapshot → `addChannel`; agents in
@@ -564,7 +913,11 @@ function readAgentRuntimesFromCredentials(agentIds) {
564
913
  entry.runtime = creds.runtime;
565
914
  if (creds.cwd)
566
915
  entry.cwd = creds.cwd;
567
- if (entry.runtime || entry.cwd)
916
+ if (creds.openclawGateway)
917
+ entry.openclawGateway = creds.openclawGateway;
918
+ if (creds.openclawAgent)
919
+ entry.openclawAgent = creds.openclawAgent;
920
+ if (entry.runtime || entry.cwd || entry.openclawGateway || entry.openclawAgent)
568
921
  out[id] = entry;
569
922
  }
570
923
  catch {
@@ -652,11 +1005,21 @@ export function setRoute(params) {
652
1005
  if (p.pattern && typeof p.pattern === "string" && !match.conversationPrefix) {
653
1006
  match.conversationPrefix = p.pattern;
654
1007
  }
1008
+ // Fall back to defaultRoute.extraArgs (mirrors adapter/cwd inheritance
1009
+ // above) so dashboard-driven `set_route` calls that only carry agentId +
1010
+ // pattern still pick up operator-wide flags like `--permission-mode
1011
+ // bypassPermissions`. Without this, every newly provisioned agent lost
1012
+ // those flags and Bash/MCP tool calls would deadlock on permission prompts.
1013
+ const extraArgs = Array.isArray(route?.extraArgs)
1014
+ ? route.extraArgs.slice()
1015
+ : Array.isArray(cfg.defaultRoute.extraArgs)
1016
+ ? cfg.defaultRoute.extraArgs.slice()
1017
+ : undefined;
655
1018
  const newRule = {
656
1019
  match,
657
1020
  adapter,
658
1021
  cwd,
659
- ...(Array.isArray(route?.extraArgs) ? { extraArgs: route.extraArgs.slice() } : {}),
1022
+ ...(extraArgs ? { extraArgs } : {}),
660
1023
  };
661
1024
  const routes = Array.isArray(cfg.routes) ? cfg.routes.slice() : [];
662
1025
  // Replace an existing matching rule. We use the canonical signature
@@ -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
@@ -1,5 +1,6 @@
1
1
  import { buildCrossRoomDigest } from "./cross-room.js";
2
2
  import { buildWorkingMemoryPrompt, readWorkingMemory } from "./working-memory.js";
3
+ import { readIdentity } from "./agent-workspace.js";
3
4
  import { classifyActivitySender } from "./sender-classify.js";
4
5
  import { log } from "./log.js";
5
6
  /**
@@ -25,6 +26,32 @@ function safeReadWorkingMemory(agentId) {
25
26
  return null;
26
27
  }
27
28
  }
29
+ /**
30
+ * Read identity.md and wrap it as a system-context block. Placed before
31
+ * every other block so the agent answers "who are you" from this file
32
+ * rather than from the underlying CLI's default persona ("I am Claude
33
+ * Code"). Re-read every turn so dashboard reconcile (`applyAgentIdentity`)
34
+ * and self-edits take effect immediately, mirroring working-memory
35
+ * semantics.
36
+ */
37
+ function buildIdentityPrompt(agentId) {
38
+ let raw = null;
39
+ try {
40
+ raw = readIdentity(agentId);
41
+ }
42
+ catch (err) {
43
+ log.warn("identity read failed", { agentId, err: String(err) });
44
+ return null;
45
+ }
46
+ if (!raw)
47
+ return null;
48
+ return [
49
+ "[BotCord Identity]",
50
+ "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.",
51
+ "",
52
+ raw.trim(),
53
+ ].join("\n");
54
+ }
28
55
  /**
29
56
  * Build a {@link SystemContextBuilder} for the gateway dispatcher.
30
57
  *
@@ -34,6 +61,7 @@ function safeReadWorkingMemory(agentId) {
34
61
  */
35
62
  export function createDaemonSystemContextBuilder(deps) {
36
63
  const gatherSyncBlocks = (message) => {
64
+ const identity = buildIdentityPrompt(deps.agentId);
37
65
  const ownerScene = classifyActivitySender(message).kind === "owner"
38
66
  ? buildOwnerChatSceneContext()
39
67
  : null;
@@ -47,7 +75,7 @@ export function createDaemonSystemContextBuilder(deps) {
47
75
  currentTopic: message.conversation.threadId ?? null,
48
76
  }) || null
49
77
  : null;
50
- return { ownerScene, memory, digest };
78
+ return { identity, ownerScene, memory, digest };
51
79
  };
52
80
  const assemble = (parts) => {
53
81
  const filtered = parts.filter((p) => typeof p === "string" && p.length > 0);
@@ -70,11 +98,12 @@ export function createDaemonSystemContextBuilder(deps) {
70
98
  };
71
99
  if (!deps.roomContextBuilder) {
72
100
  const syncBuilder = (message) => {
73
- const { ownerScene, memory, digest } = gatherSyncBlocks(message);
101
+ const { identity, ownerScene, memory, digest } = gatherSyncBlocks(message);
74
102
  // Loop-risk sits at the end so its "reply NO_REPLY unless…" guidance
75
103
  // is the last thing the model sees before the user turn body.
104
+ // Identity sits at the very front so it frames every other block.
76
105
  const loopRisk = runLoopRisk(message);
77
- return assemble([ownerScene, memory, digest, loopRisk]);
106
+ return assemble([identity, ownerScene, memory, digest, loopRisk]);
78
107
  };
79
108
  // Compile-time witness that the narrower sync signature still satisfies
80
109
  // `SystemContextBuilder` (which allows async). Prevents the two contracts
@@ -85,11 +114,12 @@ export function createDaemonSystemContextBuilder(deps) {
85
114
  }
86
115
  const roomBuilder = deps.roomContextBuilder;
87
116
  const asyncBuilder = async (message) => {
88
- const { ownerScene, memory, digest } = gatherSyncBlocks(message);
117
+ const { identity, ownerScene, memory, digest } = gatherSyncBlocks(message);
89
118
  // Room context landing order: after owner-scene / memory, before digest —
90
119
  // "what room am I in" belongs with the session's own identity, while the
91
120
  // cross-room digest deliberately describes OTHER rooms and should stay
92
121
  // last so it doesn't get confused with the current room.
122
+ // Identity stays at the very front; see syncBuilder for rationale.
93
123
  let roomBlock = null;
94
124
  try {
95
125
  roomBlock = await roomBuilder(message);
@@ -102,7 +132,7 @@ export function createDaemonSystemContextBuilder(deps) {
102
132
  });
103
133
  }
104
134
  const loopRisk = runLoopRisk(message);
105
- return assemble([ownerScene, memory, roomBlock, digest, loopRisk]);
135
+ return assemble([identity, ownerScene, memory, roomBlock, digest, loopRisk]);
106
136
  };
107
137
  const _typecheck = asyncBuilder;
108
138
  void _typecheck;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Append a `next` query param to a URL. Used by the device-code flow to
3
+ * encode a post-auth redirect target into the Hub-issued verification URL,
4
+ * so the dashboard knows where to send the user after they click Authorize.
5
+ *
6
+ * Falls back to returning the original URL string if parsing fails — the
7
+ * device-code flow keeps working, just without the redirect convenience.
8
+ */
9
+ export declare function appendNextParam(url: string, next: string): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Append a `next` query param to a URL. Used by the device-code flow to
3
+ * encode a post-auth redirect target into the Hub-issued verification URL,
4
+ * so the dashboard knows where to send the user after they click Authorize.
5
+ *
6
+ * Falls back to returning the original URL string if parsing fails — the
7
+ * device-code flow keeps working, just without the redirect convenience.
8
+ */
9
+ export function appendNextParam(url, next) {
10
+ try {
11
+ const u = new URL(url);
12
+ u.searchParams.set("next", next);
13
+ return u.toString();
14
+ }
15
+ catch {
16
+ return url;
17
+ }
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
+ "@botcord/cli": "^0.1.7",
30
31
  "@botcord/protocol-core": "^0.2.0",
31
32
  "ws": "^8.18.0"
32
33
  },