@botcord/daemon 0.1.1

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 (149) hide show
  1. package/dist/activity-tracker.d.ts +43 -0
  2. package/dist/activity-tracker.js +110 -0
  3. package/dist/adapters/runtimes.d.ts +14 -0
  4. package/dist/adapters/runtimes.js +18 -0
  5. package/dist/agent-discovery.d.ts +81 -0
  6. package/dist/agent-discovery.js +181 -0
  7. package/dist/agent-workspace.d.ts +31 -0
  8. package/dist/agent-workspace.js +221 -0
  9. package/dist/config.d.ts +116 -0
  10. package/dist/config.js +180 -0
  11. package/dist/control-channel.d.ts +99 -0
  12. package/dist/control-channel.js +388 -0
  13. package/dist/cross-room.d.ts +23 -0
  14. package/dist/cross-room.js +55 -0
  15. package/dist/daemon-config-map.d.ts +61 -0
  16. package/dist/daemon-config-map.js +153 -0
  17. package/dist/daemon.d.ts +123 -0
  18. package/dist/daemon.js +349 -0
  19. package/dist/doctor.d.ts +89 -0
  20. package/dist/doctor.js +191 -0
  21. package/dist/gateway/channel-manager.d.ts +54 -0
  22. package/dist/gateway/channel-manager.js +292 -0
  23. package/dist/gateway/channels/botcord.d.ts +93 -0
  24. package/dist/gateway/channels/botcord.js +510 -0
  25. package/dist/gateway/channels/index.d.ts +2 -0
  26. package/dist/gateway/channels/index.js +1 -0
  27. package/dist/gateway/channels/sanitize.d.ts +20 -0
  28. package/dist/gateway/channels/sanitize.js +56 -0
  29. package/dist/gateway/dispatcher.d.ts +73 -0
  30. package/dist/gateway/dispatcher.js +431 -0
  31. package/dist/gateway/gateway.d.ts +87 -0
  32. package/dist/gateway/gateway.js +158 -0
  33. package/dist/gateway/index.d.ts +15 -0
  34. package/dist/gateway/index.js +15 -0
  35. package/dist/gateway/log.d.ts +9 -0
  36. package/dist/gateway/log.js +20 -0
  37. package/dist/gateway/router.d.ts +10 -0
  38. package/dist/gateway/router.js +48 -0
  39. package/dist/gateway/runtimes/claude-code.d.ts +30 -0
  40. package/dist/gateway/runtimes/claude-code.js +162 -0
  41. package/dist/gateway/runtimes/codex.d.ts +83 -0
  42. package/dist/gateway/runtimes/codex.js +272 -0
  43. package/dist/gateway/runtimes/gemini.d.ts +15 -0
  44. package/dist/gateway/runtimes/gemini.js +29 -0
  45. package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
  46. package/dist/gateway/runtimes/ndjson-stream.js +169 -0
  47. package/dist/gateway/runtimes/probe.d.ts +17 -0
  48. package/dist/gateway/runtimes/probe.js +54 -0
  49. package/dist/gateway/runtimes/registry.d.ts +59 -0
  50. package/dist/gateway/runtimes/registry.js +94 -0
  51. package/dist/gateway/session-store.d.ts +39 -0
  52. package/dist/gateway/session-store.js +133 -0
  53. package/dist/gateway/types.d.ts +265 -0
  54. package/dist/gateway/types.js +1 -0
  55. package/dist/index.d.ts +2 -0
  56. package/dist/index.js +854 -0
  57. package/dist/log.d.ts +7 -0
  58. package/dist/log.js +44 -0
  59. package/dist/provision.d.ts +88 -0
  60. package/dist/provision.js +749 -0
  61. package/dist/room-context-fetcher.d.ts +18 -0
  62. package/dist/room-context-fetcher.js +101 -0
  63. package/dist/room-context.d.ts +53 -0
  64. package/dist/room-context.js +112 -0
  65. package/dist/sender-classify.d.ts +30 -0
  66. package/dist/sender-classify.js +32 -0
  67. package/dist/snapshot-writer.d.ts +37 -0
  68. package/dist/snapshot-writer.js +84 -0
  69. package/dist/status-render.d.ts +28 -0
  70. package/dist/status-render.js +97 -0
  71. package/dist/system-context.d.ts +57 -0
  72. package/dist/system-context.js +91 -0
  73. package/dist/turn-text.d.ts +36 -0
  74. package/dist/turn-text.js +57 -0
  75. package/dist/user-auth.d.ts +75 -0
  76. package/dist/user-auth.js +245 -0
  77. package/dist/working-memory.d.ts +46 -0
  78. package/dist/working-memory.js +274 -0
  79. package/package.json +39 -0
  80. package/src/__tests__/activity-tracker.test.ts +130 -0
  81. package/src/__tests__/agent-discovery.test.ts +191 -0
  82. package/src/__tests__/agent-workspace.test.ts +147 -0
  83. package/src/__tests__/control-channel.test.ts +327 -0
  84. package/src/__tests__/cross-room.test.ts +116 -0
  85. package/src/__tests__/daemon-config-map.test.ts +416 -0
  86. package/src/__tests__/daemon.test.ts +300 -0
  87. package/src/__tests__/device-code.test.ts +152 -0
  88. package/src/__tests__/doctor.test.ts +218 -0
  89. package/src/__tests__/protocol-core-reexport.test.ts +24 -0
  90. package/src/__tests__/provision.test.ts +922 -0
  91. package/src/__tests__/room-context.test.ts +233 -0
  92. package/src/__tests__/runtime-discovery.test.ts +173 -0
  93. package/src/__tests__/snapshot-writer.test.ts +141 -0
  94. package/src/__tests__/status-render.test.ts +137 -0
  95. package/src/__tests__/system-context.test.ts +315 -0
  96. package/src/__tests__/turn-text.test.ts +116 -0
  97. package/src/__tests__/user-auth.test.ts +125 -0
  98. package/src/__tests__/working-memory.test.ts +240 -0
  99. package/src/activity-tracker.ts +140 -0
  100. package/src/adapters/runtimes.ts +30 -0
  101. package/src/agent-discovery.ts +262 -0
  102. package/src/agent-workspace.ts +247 -0
  103. package/src/config.ts +290 -0
  104. package/src/control-channel.ts +455 -0
  105. package/src/cross-room.ts +89 -0
  106. package/src/daemon-config-map.ts +200 -0
  107. package/src/daemon.ts +478 -0
  108. package/src/doctor.ts +282 -0
  109. package/src/gateway/__tests__/.gitkeep +0 -0
  110. package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
  111. package/src/gateway/__tests__/channel-manager.test.ts +475 -0
  112. package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
  113. package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
  114. package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
  115. package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
  116. package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
  117. package/src/gateway/__tests__/gateway.test.ts +222 -0
  118. package/src/gateway/__tests__/router.test.ts +247 -0
  119. package/src/gateway/__tests__/sanitize.test.ts +193 -0
  120. package/src/gateway/__tests__/session-store.test.ts +235 -0
  121. package/src/gateway/channel-manager.ts +349 -0
  122. package/src/gateway/channels/botcord.ts +605 -0
  123. package/src/gateway/channels/index.ts +6 -0
  124. package/src/gateway/channels/sanitize.ts +68 -0
  125. package/src/gateway/dispatcher.ts +554 -0
  126. package/src/gateway/gateway.ts +211 -0
  127. package/src/gateway/index.ts +29 -0
  128. package/src/gateway/log.ts +30 -0
  129. package/src/gateway/router.ts +60 -0
  130. package/src/gateway/runtimes/claude-code.ts +180 -0
  131. package/src/gateway/runtimes/codex.ts +312 -0
  132. package/src/gateway/runtimes/gemini.ts +43 -0
  133. package/src/gateway/runtimes/ndjson-stream.ts +225 -0
  134. package/src/gateway/runtimes/probe.ts +73 -0
  135. package/src/gateway/runtimes/registry.ts +143 -0
  136. package/src/gateway/session-store.ts +157 -0
  137. package/src/gateway/types.ts +325 -0
  138. package/src/index.ts +961 -0
  139. package/src/log.ts +47 -0
  140. package/src/provision.ts +879 -0
  141. package/src/room-context-fetcher.ts +124 -0
  142. package/src/room-context.ts +167 -0
  143. package/src/sender-classify.ts +46 -0
  144. package/src/snapshot-writer.ts +103 -0
  145. package/src/status-render.ts +132 -0
  146. package/src/system-context.ts +162 -0
  147. package/src/turn-text.ts +93 -0
  148. package/src/user-auth.ts +295 -0
  149. package/src/working-memory.ts +352 -0
@@ -0,0 +1,200 @@
1
+ import type {
2
+ GatewayChannelConfig,
3
+ GatewayConfig,
4
+ GatewayRoute,
5
+ RouteMatch,
6
+ TrustLevel as GatewayTrustLevel,
7
+ } from "./gateway/index.js";
8
+ import type { DaemonConfig, RouteRule } from "./config.js";
9
+ import { resolveAgentIds } from "./config.js";
10
+ import { agentWorkspaceDir } from "./agent-workspace.js";
11
+ import { log as daemonLog } from "./log.js";
12
+
13
+ /** Options accepted by {@link toGatewayConfig}. */
14
+ export interface ToGatewayConfigOptions {
15
+ /**
16
+ * Explicit list of agent ids to bind channels to. When provided, overrides
17
+ * anything derivable from the daemon config itself. P1 passes discovered
18
+ * credentials in via this field so `toGatewayConfig` stays pure.
19
+ */
20
+ agentIds?: string[];
21
+ /**
22
+ * Per-agent runtime/cwd cached from credentials (see
23
+ * `docs/agent-runtime-property-plan.md`). When present for an agent id,
24
+ * `toGatewayConfig` synthesizes a terminal route pinning that agent's
25
+ * turns to its runtime. Explicit `cfg.routes` entries still win because
26
+ * synthesized routes are appended after them.
27
+ */
28
+ agentRuntimes?: Record<string, { runtime?: string; cwd?: string }>;
29
+ }
30
+
31
+ /**
32
+ * Historical channel id used when the daemon bound a single agent. Kept as a
33
+ * named export for any downstream reader that still references it; no new
34
+ * code paths in the daemon emit this id — channels are now keyed by agentId.
35
+ *
36
+ * @deprecated Channel ids are now the agentId itself.
37
+ */
38
+ export const DEFAULT_BOTCORD_CHANNEL_ID = "botcord-main";
39
+
40
+ /** Channel `type` tag used by `createBotCordChannel`. */
41
+ export const BOTCORD_CHANNEL_TYPE = "botcord";
42
+
43
+ /**
44
+ * Map daemon's historical narrower TrustLevel ("owner" | "untrusted") onto
45
+ * gateway's ("owner" | "trusted" | "public"). Matches the adapter-level
46
+ * mapping in `adapters/runtimes.ts`: "untrusted" collapses to "public".
47
+ * Accepts `undefined` → `undefined` so callers can pass through.
48
+ */
49
+ function mapTrustLevel(
50
+ level: "owner" | "untrusted" | undefined,
51
+ ): GatewayTrustLevel | undefined {
52
+ if (level === undefined) return undefined;
53
+ return level === "owner" ? "owner" : "public";
54
+ }
55
+
56
+ /**
57
+ * Translate a single daemon route rule into a gateway route. Gateway matches
58
+ * on the channel-agnostic fields; the daemon surface keeps the legacy
59
+ * `roomId`/`roomPrefix` aliases for backward compatibility. When both a
60
+ * legacy alias and its canonical field are present, the canonical field
61
+ * wins and a warning is logged.
62
+ */
63
+ function mapRoute(r: RouteRule): GatewayRoute {
64
+ const match: RouteMatch = {};
65
+ if (r.match.channel) match.channel = r.match.channel;
66
+ if (r.match.accountId) match.accountId = r.match.accountId;
67
+
68
+ if (r.match.conversationId && r.match.roomId && r.match.conversationId !== r.match.roomId) {
69
+ daemonLog.warn("daemon.config.route.conflict", {
70
+ field: "conversationId",
71
+ roomId: r.match.roomId,
72
+ conversationId: r.match.conversationId,
73
+ resolution: "conversationId wins",
74
+ });
75
+ }
76
+ const conversationId = r.match.conversationId ?? r.match.roomId;
77
+ if (conversationId) match.conversationId = conversationId;
78
+
79
+ if (
80
+ r.match.conversationPrefix &&
81
+ r.match.roomPrefix &&
82
+ r.match.conversationPrefix !== r.match.roomPrefix
83
+ ) {
84
+ daemonLog.warn("daemon.config.route.conflict", {
85
+ field: "conversationPrefix",
86
+ roomPrefix: r.match.roomPrefix,
87
+ conversationPrefix: r.match.conversationPrefix,
88
+ resolution: "conversationPrefix wins",
89
+ });
90
+ }
91
+ const conversationPrefix = r.match.conversationPrefix ?? r.match.roomPrefix;
92
+ if (conversationPrefix) match.conversationPrefix = conversationPrefix;
93
+
94
+ if (r.match.conversationKind) match.conversationKind = r.match.conversationKind;
95
+ if (r.match.senderId) match.senderId = r.match.senderId;
96
+ if (typeof r.match.mentioned === "boolean") match.mentioned = r.match.mentioned;
97
+
98
+ const rawTrust = (r as { trustLevel?: "owner" | "untrusted" }).trustLevel;
99
+ return {
100
+ match,
101
+ runtime: r.adapter,
102
+ cwd: r.cwd,
103
+ extraArgs: r.extraArgs,
104
+ trustLevel: mapTrustLevel(rawTrust),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Convert the daemon's on-disk config into a gateway runtime config. Only
110
+ * used in-process at daemon boot; the daemon config file itself is the
111
+ * user-facing contract.
112
+ *
113
+ * When `opts.agentIds` is provided (discovery or explicit override), the
114
+ * mapper trusts that list verbatim. Otherwise it falls back to the legacy
115
+ * `resolveAgentIds(cfg)` path so callers that haven't been updated for P1
116
+ * keep working.
117
+ */
118
+ export function toGatewayConfig(
119
+ cfg: DaemonConfig,
120
+ opts: ToGatewayConfigOptions = {},
121
+ ): GatewayConfig {
122
+ // One channel per configured agent. Channel id = agentId so session keys
123
+ // (`runtime:channel:accountId:kind:convId`) and activity records carry
124
+ // the agent identity end-to-end. Pre-multi-agent single-agent installs
125
+ // previously used the fixed id "botcord-main"; existing on-disk session
126
+ // entries keyed by that id are silently dropped on the first message
127
+ // after upgrade — a one-time reset, not a bug.
128
+ const agentIds = opts.agentIds ?? resolveAgentIds(cfg);
129
+ const channels: GatewayChannelConfig[] = agentIds.map((agentId) => ({
130
+ id: agentId,
131
+ type: BOTCORD_CHANNEL_TYPE,
132
+ accountId: agentId,
133
+ agentId,
134
+ }));
135
+
136
+ // DaemonConfig's typed surface doesn't carry `trustLevel`, but we read it
137
+ // defensively so future config extensions can propagate without a shape bump.
138
+ const rawDefaultTrust = (cfg.defaultRoute as { trustLevel?: "owner" | "untrusted" })
139
+ .trustLevel;
140
+ const defaultRoute: GatewayRoute = {
141
+ runtime: cfg.defaultRoute.adapter,
142
+ cwd: cfg.defaultRoute.cwd,
143
+ extraArgs: cfg.defaultRoute.extraArgs,
144
+ // queueMode: omitted — dispatcher's kind-based default wins
145
+ // (direct → cancel-previous, group → serial).
146
+ trustLevel: mapTrustLevel(rawDefaultTrust),
147
+ };
148
+
149
+ const routes: GatewayRoute[] = (cfg.routes ?? []).map(mapRoute);
150
+
151
+ // Synthesize a per-agent route for every bound agent and hand it to the
152
+ // gateway via the managed-routes bucket (plan §10.1). User-authored
153
+ // `cfg.routes[]` stay untouched so an explicit operator override still
154
+ // wins on conflict — the gateway matches `routes[] → managedRoutes →
155
+ // defaultRoute` in that order.
156
+ const managedMap = buildManagedRoutes(
157
+ agentIds,
158
+ opts.agentRuntimes ?? {},
159
+ defaultRoute,
160
+ );
161
+
162
+ return {
163
+ channels,
164
+ defaultRoute,
165
+ routes,
166
+ managedRoutes: Array.from(managedMap.values()),
167
+ streamBlocks: cfg.streamBlocks,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Build the daemon's managed per-agent routes. Emits exactly one route per
173
+ * `agentId`, keyed by `accountId`. `runtime` comes from the agent's cached
174
+ * metadata when present (credentials file), otherwise falls back to
175
+ * `defaultRoute.runtime`. `cwd` prefers the cached value but falls back to
176
+ * the agent's workspace directory (see plan §10) so every agent runs inside
177
+ * its own dedicated tree by default.
178
+ *
179
+ * Iteration order of `agentIds` is preserved in the resulting Map for test
180
+ * determinism; the gateway router does not depend on map order.
181
+ *
182
+ * Exported so `reload_config` and `provisionAgent` hot-add can share the
183
+ * same synthesis logic (plan §10.5).
184
+ */
185
+ export function buildManagedRoutes(
186
+ agentIds: string[],
187
+ agentRuntimes: Record<string, { runtime?: string; cwd?: string }>,
188
+ defaultRoute: GatewayRoute,
189
+ ): Map<string, GatewayRoute> {
190
+ const out = new Map<string, GatewayRoute>();
191
+ for (const agentId of agentIds) {
192
+ const meta = agentRuntimes[agentId] ?? {};
193
+ out.set(agentId, {
194
+ match: { accountId: agentId },
195
+ runtime: meta.runtime ?? defaultRoute.runtime,
196
+ cwd: meta.cwd || agentWorkspaceDir(agentId),
197
+ });
198
+ }
199
+ return out;
200
+ }
package/src/daemon.ts ADDED
@@ -0,0 +1,478 @@
1
+ import { CONTROL_FRAME_TYPES } from "@botcord/protocol-core";
2
+ import {
3
+ Gateway,
4
+ createBotCordChannel,
5
+ sanitizeUntrustedContent,
6
+ type ChannelAdapter,
7
+ type GatewayChannelConfig,
8
+ type GatewayInboundMessage,
9
+ type GatewayLogger,
10
+ type GatewayRuntimeSnapshot,
11
+ } from "./gateway/index.js";
12
+ import { ActivityTracker } from "./activity-tracker.js";
13
+ import type { DaemonConfig } from "./config.js";
14
+ import { SESSIONS_PATH, SNAPSHOT_PATH } from "./config.js";
15
+ import { resolveBootAgents, type BootAgentsResult } from "./agent-discovery.js";
16
+ import { ensureAgentWorkspace } from "./agent-workspace.js";
17
+ import { ControlChannel } from "./control-channel.js";
18
+ import { toGatewayConfig } from "./daemon-config-map.js";
19
+ import { log as daemonLog } from "./log.js";
20
+ import { collectRuntimeSnapshot, createProvisioner } from "./provision.js";
21
+ import { SnapshotWriter } from "./snapshot-writer.js";
22
+ import { createDaemonSystemContextBuilder } from "./system-context.js";
23
+ import { createRoomStaticContextBuilder } from "./room-context.js";
24
+ import { createRoomContextFetcher } from "./room-context-fetcher.js";
25
+ import { composeBotCordUserTurn } from "./turn-text.js";
26
+ import { UserAuthManager } from "./user-auth.js";
27
+
28
+ /**
29
+ * Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
30
+ * long-running CLI turns behave the same way under the gateway core.
31
+ */
32
+ const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
33
+
34
+ /**
35
+ * Default cadence for writing `gateway.snapshot()` to disk. Override via
36
+ * `BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS`.
37
+ */
38
+ const DEFAULT_SNAPSHOT_INTERVAL_MS = 5_000;
39
+
40
+ function resolveSnapshotIntervalMs(): number {
41
+ const raw = process.env.BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS;
42
+ if (!raw) return DEFAULT_SNAPSHOT_INTERVAL_MS;
43
+ const n = Number(raw);
44
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_SNAPSHOT_INTERVAL_MS;
45
+ return n;
46
+ }
47
+
48
+ // Sender classification lives in `./sender-classify.ts` so it can be shared
49
+ // with the user-turn composer without a daemon.ts ↔ turn-text.ts cycle.
50
+ import { classifyActivitySender } from "./sender-classify.js";
51
+ export { classifyActivitySender };
52
+
53
+ /** Minimal activity-tracker surface the inbound observer uses. */
54
+ interface ActivityRecorderTarget {
55
+ record: (entry: {
56
+ agentId: string;
57
+ roomId: string;
58
+ roomName?: string;
59
+ topic: string | null;
60
+ lastInboundPreview: string;
61
+ lastSenderKind: "agent" | "human" | "owner";
62
+ lastSender: string;
63
+ }) => void;
64
+ }
65
+
66
+ /**
67
+ * Build the `onInbound` observer wired to the given activity tracker.
68
+ * Exported for tests.
69
+ *
70
+ * The recorded `agentId` is taken from the inbound message's `accountId`
71
+ * — so a multi-agent daemon files activity under whichever configured
72
+ * agent actually received the message. An optional `fallbackAgentId`
73
+ * covers pathological inputs where `accountId` is empty (should never
74
+ * happen from the gateway, but defensive).
75
+ */
76
+ export function createActivityRecorder(opts: {
77
+ activityTracker: ActivityRecorderTarget;
78
+ fallbackAgentId?: string;
79
+ }): (msg: GatewayInboundMessage) => void {
80
+ return (msg: GatewayInboundMessage): void => {
81
+ const { kind, label } = classifyActivitySender(msg);
82
+ const rawText = typeof msg.text === "string" ? msg.text : "";
83
+ // Owner text passes through verbatim; everything else gets the same
84
+ // sanitization the legacy dispatcher applied before recording a preview.
85
+ const preview = kind === "owner" ? rawText : sanitizeUntrustedContent(rawText);
86
+ const agentId = msg.accountId || opts.fallbackAgentId || "";
87
+ opts.activityTracker.record({
88
+ agentId,
89
+ roomId: msg.conversation.id,
90
+ roomName: msg.conversation.title,
91
+ topic: msg.conversation.threadId ?? null,
92
+ lastInboundPreview: preview,
93
+ lastSenderKind: kind,
94
+ lastSender: label,
95
+ });
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Minimal send-capable surface used by {@link pushRuntimeSnapshot}.
101
+ * Exists so the helper is trivially mockable from unit tests without needing
102
+ * a full `ControlChannel` + user-auth harness.
103
+ */
104
+ export interface RuntimeSnapshotSink {
105
+ send: (frame: {
106
+ id: string;
107
+ type: string;
108
+ params?: Record<string, unknown>;
109
+ ts?: number;
110
+ }) => boolean;
111
+ }
112
+
113
+ /**
114
+ * Emit one `runtime_snapshot` event frame on the control channel. Plan §8.5
115
+ * P0: first-connect push only — reconnect-push and diffing are P1. A send
116
+ * failure is non-fatal (the Hub will re-query via `list_runtimes` on demand
117
+ * or wait for the next daemon restart). Exported for unit tests.
118
+ */
119
+ export function pushRuntimeSnapshot(sink: RuntimeSnapshotSink): boolean {
120
+ const snap = collectRuntimeSnapshot();
121
+ const ok = sink.send({
122
+ id: `rt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
123
+ type: CONTROL_FRAME_TYPES.RUNTIME_SNAPSHOT,
124
+ params: snap as unknown as Record<string, unknown>,
125
+ ts: Date.now(),
126
+ });
127
+ if (!ok) {
128
+ daemonLog.warn("runtime-snapshot: control-channel send returned false", {
129
+ runtimes: snap.runtimes.length,
130
+ });
131
+ }
132
+ return ok;
133
+ }
134
+
135
+ /** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
136
+ export interface DaemonRuntimeOptions {
137
+ config: DaemonConfig;
138
+ /** Informational only; surfaced in startup logs. */
139
+ configPath: string;
140
+ /** Override the JSON session store location; defaults to `~/.botcord/daemon/sessions.json`. */
141
+ sessionStorePath?: string;
142
+ /** Override the snapshot file path; defaults to `~/.botcord/daemon/snapshot.json`. */
143
+ snapshotPath?: string;
144
+ /** Override snapshot write cadence in ms; defaults to 5s or `BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS`. */
145
+ snapshotIntervalMs?: number;
146
+ log?: GatewayLogger;
147
+ /** Override Hub base URL; defaults to the one stored in credentials. */
148
+ hubBaseUrl?: string;
149
+ /** Override credentials JSON path; defaults to `~/.botcord/credentials/<agentId>.json`. */
150
+ credentialsPath?: string;
151
+ /**
152
+ * Inject a pre-resolved boot-agent list (e.g. from tests). When omitted,
153
+ * `startDaemon` resolves boot agents from `config.agents`/`config.agentId`
154
+ * or falls back to credential discovery.
155
+ */
156
+ bootAgents?: BootAgentsResult;
157
+ /**
158
+ * Inject a pre-built user-auth manager. Typically only set by tests; in
159
+ * production the daemon calls `UserAuthManager.load()` internally, and
160
+ * only starts the control channel when a user-auth record exists.
161
+ */
162
+ userAuth?: UserAuthManager | null;
163
+ /** Skip the control channel even when user-auth is available. Test hook. */
164
+ disableControlChannel?: boolean;
165
+ }
166
+
167
+ /** Handle returned by {@link startDaemon}. */
168
+ export interface DaemonHandle {
169
+ /** Graceful shutdown — idempotent. */
170
+ stop: (reason?: string) => Promise<void>;
171
+ /** Channel + turn status snapshot, straight from `Gateway.snapshot()`. */
172
+ snapshot: () => GatewayRuntimeSnapshot;
173
+ }
174
+
175
+ /**
176
+ * Adapt daemon's file-based `log` module into the gateway logger contract.
177
+ * Writes go to `~/.botcord/logs/daemon.log` + stderr, preserving the format
178
+ * existing `logs -f` watchers rely on. Debug lines stay gated by
179
+ * `BOTCORD_DAEMON_DEBUG`, mirroring pre-migration behavior.
180
+ */
181
+ function buildDaemonLogger(): GatewayLogger {
182
+ return {
183
+ info: (msg, meta) => daemonLog.info(msg, meta),
184
+ warn: (msg, meta) => daemonLog.warn(msg, meta),
185
+ error: (msg, meta) => daemonLog.error(msg, meta),
186
+ debug: (msg, meta) => daemonLog.debug(msg, meta),
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Boot the gateway using a daemon-shaped config. This is the P0.5 compat
192
+ * entry point: the on-disk config at `~/.botcord/daemon/config.json` keeps
193
+ * its existing shape, and `Gateway` handles channels/dispatch/sessions
194
+ * under the hood.
195
+ *
196
+ * Only the BotCord channel is supported today; `channels[]` in the
197
+ * translated gateway config has exactly one entry (`botcord-main`).
198
+ */
199
+ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHandle> {
200
+ const logger = opts.log ?? buildDaemonLogger();
201
+
202
+ // Resolve boot agents: explicit `agents` config wins; otherwise scan the
203
+ // credentials directory. A zero-agent result is valid in P1 — the daemon
204
+ // still starts with zero channels so operators can drop credentials in
205
+ // and restart without re-running `init`.
206
+ const boot = opts.bootAgents ?? resolveBootAgents(opts.config);
207
+ for (const w of boot.warnings) {
208
+ logger.warn("daemon.discovery.warning", { message: w });
209
+ }
210
+ const agentIds = boot.agents.map((a) => a.agentId);
211
+ const { credentialPathByAgentId, agentRuntimes } = backfillBootAgents(
212
+ boot.agents,
213
+ { logger },
214
+ );
215
+
216
+ const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
217
+
218
+ // ActivityTracker lives at the daemon layer (not the gateway core). We
219
+ // expose it to the gateway via (a) the `buildSystemContext` hook so the
220
+ // cross-room digest reflects current activity, and (b) the `onInbound`
221
+ // observer so incoming messages get recorded before the turn runs —
222
+ // mirroring the pre-P0.5 dispatcher's "record-before-adapter-run" ordering.
223
+ const activityTracker = new ActivityTracker();
224
+
225
+ // Shared room-context fetcher — one BotCordClient per accountId, created
226
+ // lazily and reused across turns so JWT refreshes amortize. The builder
227
+ // wrapping it adds a TTL cache on top so group rooms don't hit Hub every
228
+ // turn.
229
+ const roomContextFetcher = createRoomContextFetcher({
230
+ credentialPathByAgentId,
231
+ ...(opts.credentialsPath ? { defaultCredentialsPath: opts.credentialsPath } : {}),
232
+ ...(opts.hubBaseUrl ? { hubBaseUrl: opts.hubBaseUrl } : {}),
233
+ log: logger,
234
+ });
235
+ const roomContextBuilder = createRoomStaticContextBuilder({
236
+ fetchRoomInfo: roomContextFetcher,
237
+ log: logger,
238
+ });
239
+
240
+ // Cache one system-context builder per configured agentId. The gateway
241
+ // calls this with each inbound message and we pick the right builder by
242
+ // `message.accountId` — so per-agent working memory + activity digests
243
+ // stay scoped when a single daemon hosts multiple agents.
244
+ type PerAgentBuilder = (
245
+ msg: GatewayInboundMessage,
246
+ ) => Promise<string | undefined> | string | undefined;
247
+ const scBuilders = new Map<string, PerAgentBuilder>();
248
+ for (const aid of agentIds) {
249
+ scBuilders.set(
250
+ aid,
251
+ createDaemonSystemContextBuilder({
252
+ agentId: aid,
253
+ activityTracker,
254
+ roomContextBuilder,
255
+ }),
256
+ );
257
+ }
258
+ const buildSystemContext = (
259
+ message: GatewayInboundMessage,
260
+ ): Promise<string | undefined> | string | undefined => {
261
+ const b = scBuilders.get(message.accountId);
262
+ if (b) return b(message);
263
+ // Unknown accountId (shouldn't happen in practice): fall back to the
264
+ // first configured agent so we still emit *something* rather than
265
+ // silently dropping the context block. When no agents are bound the
266
+ // daemon has no context to surface — return undefined.
267
+ const first = agentIds[0];
268
+ if (!first) return undefined;
269
+ const fallback = scBuilders.get(first);
270
+ return fallback ? fallback(message) : undefined;
271
+ };
272
+
273
+ // Observer runs after ack + before runtime.run. Keeping the side effect
274
+ // outside the system-context builder (option A) means the builder stays
275
+ // pure — a cleaner contract the gateway can also expose to non-daemon
276
+ // callers in the future.
277
+ const onInbound = createActivityRecorder({
278
+ activityTracker,
279
+ ...(agentIds[0] ? { fallbackAgentId: agentIds[0] } : {}),
280
+ });
281
+
282
+ const gateway = new Gateway({
283
+ config: gwConfig,
284
+ sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
285
+ createChannel: (chCfg: GatewayChannelConfig): ChannelAdapter => {
286
+ const agentId =
287
+ typeof chCfg.agentId === "string" ? chCfg.agentId : chCfg.accountId;
288
+ return createBotCordChannel({
289
+ id: chCfg.id,
290
+ accountId: chCfg.accountId,
291
+ agentId,
292
+ credentialsPath:
293
+ credentialPathByAgentId.get(agentId) ?? opts.credentialsPath,
294
+ hubBaseUrl: opts.hubBaseUrl,
295
+ });
296
+ },
297
+ log: logger,
298
+ turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
299
+ buildSystemContext,
300
+ onInbound,
301
+ composeUserTurn: composeBotCordUserTurn,
302
+ });
303
+
304
+ logger.info("daemon starting", {
305
+ agents: agentIds,
306
+ source: boot.source,
307
+ credentialsDir: boot.credentialsDir,
308
+ configPath: opts.configPath,
309
+ sessionsPath: opts.sessionStorePath ?? SESSIONS_PATH,
310
+ channels: gwConfig.channels.map((c) => c.id),
311
+ routeCount: gwConfig.routes?.length ?? 0,
312
+ });
313
+
314
+ if (agentIds.length === 0) {
315
+ logger.warn("daemon starting with no channels", {
316
+ source: boot.source,
317
+ credentialsDir: boot.credentialsDir,
318
+ hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon init --agent <ag_xxx>`",
319
+ });
320
+ }
321
+
322
+ await gateway.start();
323
+ logger.info("daemon started", { agents: agentIds });
324
+
325
+ // Control channel is optional — daemon still runs (data-plane only)
326
+ // when user-auth hasn't been set up yet. Operators can `login` later
327
+ // without restarting, but for P0 we require a restart to pick it up.
328
+ let controlChannel: ControlChannel | null = null;
329
+ const userAuth =
330
+ opts.userAuth === undefined
331
+ ? tryLoadUserAuth(logger)
332
+ : opts.userAuth;
333
+ if (userAuth?.current && !opts.disableControlChannel) {
334
+ logger.info("control-channel: enabling", {
335
+ userId: userAuth.current.userId,
336
+ hubUrl: userAuth.current.hubUrl,
337
+ });
338
+ const provisioner = createProvisioner({ gateway });
339
+ controlChannel = new ControlChannel({
340
+ auth: userAuth,
341
+ handle: provisioner,
342
+ });
343
+ try {
344
+ await controlChannel.start();
345
+ // Plan §8.5 P0 — push one runtime snapshot immediately after connect
346
+ // so Hub's `daemon_instances.runtimes_json` is populated for the
347
+ // dashboard even before any user action. No periodic refresh in P0.
348
+ const pushed = pushRuntimeSnapshot(controlChannel);
349
+ logger.info("control-channel: initial runtime_snapshot push", {
350
+ ok: pushed,
351
+ });
352
+ } catch (err) {
353
+ logger.warn("control-channel failed to start; continuing without it", {
354
+ error: err instanceof Error ? err.message : String(err),
355
+ });
356
+ // start() schedules its own reconnect; we swallow the initial
357
+ // failure so the daemon boots either way.
358
+ }
359
+ } else if (!userAuth?.current) {
360
+ logger.info("control-channel skipped: no user-auth record", {
361
+ hint: "run `botcord-daemon start` to enable Hub control plane (device-code login)",
362
+ });
363
+ }
364
+
365
+ const snapshotWriter = new SnapshotWriter({
366
+ path: opts.snapshotPath ?? SNAPSHOT_PATH,
367
+ intervalMs: opts.snapshotIntervalMs ?? resolveSnapshotIntervalMs(),
368
+ snapshot: () => gateway.snapshot(),
369
+ log: logger,
370
+ });
371
+ snapshotWriter.start();
372
+
373
+ let stopping: Promise<void> | null = null;
374
+ const stop = (reason?: string): Promise<void> => {
375
+ if (stopping) return stopping;
376
+ logger.info("daemon stopping", { reason: reason ?? null });
377
+ snapshotWriter.stop();
378
+ // Write one final snapshot so `status` doesn't briefly see stale data,
379
+ // then delete the file on the way out.
380
+ snapshotWriter.writeFinal();
381
+ const controlStopP = controlChannel
382
+ ? controlChannel.stop().catch(() => undefined)
383
+ : Promise.resolve();
384
+ stopping = Promise.all([controlStopP, gateway.stop(reason)]).then(
385
+ () => undefined,
386
+ ).finally(() => {
387
+ snapshotWriter.remove();
388
+ logger.info("daemon stopped", { reason: reason ?? null });
389
+ });
390
+ return stopping;
391
+ };
392
+
393
+ return {
394
+ stop,
395
+ snapshot: () => gateway.snapshot(),
396
+ };
397
+ }
398
+
399
+ /**
400
+ * Result of {@link backfillBootAgents}: the maps the boot flow needs to
401
+ * plumb into `toGatewayConfig` + the channel factory.
402
+ */
403
+ export interface BootBackfillResult {
404
+ credentialPathByAgentId: Map<string, string>;
405
+ agentRuntimes: Record<string, { runtime?: string; cwd?: string }>;
406
+ }
407
+
408
+ /**
409
+ * Walk the boot-agent list and (a) populate the credential-path + runtime
410
+ * caches used downstream, and (b) idempotently create each agent's on-disk
411
+ * workspace tree (plan §9). One agent's failing workspace must not block
412
+ * the others — errors are warned and swallowed per agent. Exported for
413
+ * unit tests; `startDaemon` calls this inline.
414
+ */
415
+ export function backfillBootAgents(
416
+ agents: BootAgentsResult["agents"],
417
+ opts: {
418
+ logger: GatewayLogger;
419
+ ensure?: typeof ensureAgentWorkspace;
420
+ },
421
+ ): BootBackfillResult {
422
+ const ensure = opts.ensure ?? ensureAgentWorkspace;
423
+ const credentialPathByAgentId = new Map<string, string>();
424
+ const agentRuntimes: Record<string, { runtime?: string; cwd?: string }> = {};
425
+ const failed: string[] = [];
426
+ for (const a of agents) {
427
+ if (a.credentialsFile) credentialPathByAgentId.set(a.agentId, a.credentialsFile);
428
+ if (a.runtime || a.cwd) {
429
+ agentRuntimes[a.agentId] = {
430
+ ...(a.runtime ? { runtime: a.runtime } : {}),
431
+ ...(a.cwd ? { cwd: a.cwd } : {}),
432
+ };
433
+ }
434
+ // Seed files are written only when missing (see `ensureAgentWorkspace`),
435
+ // so a legacy agent whose workspace dir doesn't exist yet gets one on
436
+ // the next boot — with zero risk of overwriting the user's edits.
437
+ try {
438
+ ensure(a.agentId, {
439
+ ...(a.displayName ? { displayName: a.displayName } : {}),
440
+ ...(a.runtime ? { runtime: a.runtime } : {}),
441
+ ...(a.keyId ? { keyId: a.keyId } : {}),
442
+ ...(a.savedAt ? { savedAt: a.savedAt } : {}),
443
+ // `bio` is not surfaced on BootAgent — identity.md renders a
444
+ // placeholder the user can fill in.
445
+ });
446
+ } catch (err) {
447
+ failed.push(a.agentId);
448
+ opts.logger.warn("ensureAgentWorkspace failed at boot; continuing", {
449
+ agentId: a.agentId,
450
+ error: err instanceof Error ? err.message : String(err),
451
+ });
452
+ }
453
+ }
454
+ if (failed.length > 0) {
455
+ opts.logger.warn("ensureAgentWorkspace: boot backfill incomplete", {
456
+ count: failed.length,
457
+ agentIds: failed,
458
+ });
459
+ }
460
+ return { credentialPathByAgentId, agentRuntimes };
461
+ }
462
+
463
+ /**
464
+ * Load the user-auth record if present, swallowing "file missing" as the
465
+ * expected not-logged-in state. A parse / permission error is logged and
466
+ * treated as "no record" so a broken user-auth.json can't block the
467
+ * data-plane from coming up.
468
+ */
469
+ function tryLoadUserAuth(logger: GatewayLogger): UserAuthManager | null {
470
+ try {
471
+ return UserAuthManager.load();
472
+ } catch (err) {
473
+ logger.warn("failed to load user-auth", {
474
+ error: err instanceof Error ? err.message : String(err),
475
+ });
476
+ return null;
477
+ }
478
+ }