@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,123 @@
1
+ import { type GatewayInboundMessage, type GatewayLogger, type GatewayRuntimeSnapshot } from "./gateway/index.js";
2
+ import type { DaemonConfig } from "./config.js";
3
+ import { type BootAgentsResult } from "./agent-discovery.js";
4
+ import { ensureAgentWorkspace } from "./agent-workspace.js";
5
+ import { UserAuthManager } from "./user-auth.js";
6
+ import { classifyActivitySender } from "./sender-classify.js";
7
+ export { classifyActivitySender };
8
+ /** Minimal activity-tracker surface the inbound observer uses. */
9
+ interface ActivityRecorderTarget {
10
+ record: (entry: {
11
+ agentId: string;
12
+ roomId: string;
13
+ roomName?: string;
14
+ topic: string | null;
15
+ lastInboundPreview: string;
16
+ lastSenderKind: "agent" | "human" | "owner";
17
+ lastSender: string;
18
+ }) => void;
19
+ }
20
+ /**
21
+ * Build the `onInbound` observer wired to the given activity tracker.
22
+ * Exported for tests.
23
+ *
24
+ * The recorded `agentId` is taken from the inbound message's `accountId`
25
+ * — so a multi-agent daemon files activity under whichever configured
26
+ * agent actually received the message. An optional `fallbackAgentId`
27
+ * covers pathological inputs where `accountId` is empty (should never
28
+ * happen from the gateway, but defensive).
29
+ */
30
+ export declare function createActivityRecorder(opts: {
31
+ activityTracker: ActivityRecorderTarget;
32
+ fallbackAgentId?: string;
33
+ }): (msg: GatewayInboundMessage) => void;
34
+ /**
35
+ * Minimal send-capable surface used by {@link pushRuntimeSnapshot}.
36
+ * Exists so the helper is trivially mockable from unit tests without needing
37
+ * a full `ControlChannel` + user-auth harness.
38
+ */
39
+ export interface RuntimeSnapshotSink {
40
+ send: (frame: {
41
+ id: string;
42
+ type: string;
43
+ params?: Record<string, unknown>;
44
+ ts?: number;
45
+ }) => boolean;
46
+ }
47
+ /**
48
+ * Emit one `runtime_snapshot` event frame on the control channel. Plan §8.5
49
+ * P0: first-connect push only — reconnect-push and diffing are P1. A send
50
+ * failure is non-fatal (the Hub will re-query via `list_runtimes` on demand
51
+ * or wait for the next daemon restart). Exported for unit tests.
52
+ */
53
+ export declare function pushRuntimeSnapshot(sink: RuntimeSnapshotSink): boolean;
54
+ /** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
55
+ export interface DaemonRuntimeOptions {
56
+ config: DaemonConfig;
57
+ /** Informational only; surfaced in startup logs. */
58
+ configPath: string;
59
+ /** Override the JSON session store location; defaults to `~/.botcord/daemon/sessions.json`. */
60
+ sessionStorePath?: string;
61
+ /** Override the snapshot file path; defaults to `~/.botcord/daemon/snapshot.json`. */
62
+ snapshotPath?: string;
63
+ /** Override snapshot write cadence in ms; defaults to 5s or `BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS`. */
64
+ snapshotIntervalMs?: number;
65
+ log?: GatewayLogger;
66
+ /** Override Hub base URL; defaults to the one stored in credentials. */
67
+ hubBaseUrl?: string;
68
+ /** Override credentials JSON path; defaults to `~/.botcord/credentials/<agentId>.json`. */
69
+ credentialsPath?: string;
70
+ /**
71
+ * Inject a pre-resolved boot-agent list (e.g. from tests). When omitted,
72
+ * `startDaemon` resolves boot agents from `config.agents`/`config.agentId`
73
+ * or falls back to credential discovery.
74
+ */
75
+ bootAgents?: BootAgentsResult;
76
+ /**
77
+ * Inject a pre-built user-auth manager. Typically only set by tests; in
78
+ * production the daemon calls `UserAuthManager.load()` internally, and
79
+ * only starts the control channel when a user-auth record exists.
80
+ */
81
+ userAuth?: UserAuthManager | null;
82
+ /** Skip the control channel even when user-auth is available. Test hook. */
83
+ disableControlChannel?: boolean;
84
+ }
85
+ /** Handle returned by {@link startDaemon}. */
86
+ export interface DaemonHandle {
87
+ /** Graceful shutdown — idempotent. */
88
+ stop: (reason?: string) => Promise<void>;
89
+ /** Channel + turn status snapshot, straight from `Gateway.snapshot()`. */
90
+ snapshot: () => GatewayRuntimeSnapshot;
91
+ }
92
+ /**
93
+ * Boot the gateway using a daemon-shaped config. This is the P0.5 compat
94
+ * entry point: the on-disk config at `~/.botcord/daemon/config.json` keeps
95
+ * its existing shape, and `Gateway` handles channels/dispatch/sessions
96
+ * under the hood.
97
+ *
98
+ * Only the BotCord channel is supported today; `channels[]` in the
99
+ * translated gateway config has exactly one entry (`botcord-main`).
100
+ */
101
+ export declare function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHandle>;
102
+ /**
103
+ * Result of {@link backfillBootAgents}: the maps the boot flow needs to
104
+ * plumb into `toGatewayConfig` + the channel factory.
105
+ */
106
+ export interface BootBackfillResult {
107
+ credentialPathByAgentId: Map<string, string>;
108
+ agentRuntimes: Record<string, {
109
+ runtime?: string;
110
+ cwd?: string;
111
+ }>;
112
+ }
113
+ /**
114
+ * Walk the boot-agent list and (a) populate the credential-path + runtime
115
+ * caches used downstream, and (b) idempotently create each agent's on-disk
116
+ * workspace tree (plan §9). One agent's failing workspace must not block
117
+ * the others — errors are warned and swallowed per agent. Exported for
118
+ * unit tests; `startDaemon` calls this inline.
119
+ */
120
+ export declare function backfillBootAgents(agents: BootAgentsResult["agents"], opts: {
121
+ logger: GatewayLogger;
122
+ ensure?: typeof ensureAgentWorkspace;
123
+ }): BootBackfillResult;
package/dist/daemon.js ADDED
@@ -0,0 +1,349 @@
1
+ import { CONTROL_FRAME_TYPES } from "@botcord/protocol-core";
2
+ import { Gateway, createBotCordChannel, sanitizeUntrustedContent, } from "./gateway/index.js";
3
+ import { ActivityTracker } from "./activity-tracker.js";
4
+ import { SESSIONS_PATH, SNAPSHOT_PATH } from "./config.js";
5
+ import { resolveBootAgents } from "./agent-discovery.js";
6
+ import { ensureAgentWorkspace } from "./agent-workspace.js";
7
+ import { ControlChannel } from "./control-channel.js";
8
+ import { toGatewayConfig } from "./daemon-config-map.js";
9
+ import { log as daemonLog } from "./log.js";
10
+ import { collectRuntimeSnapshot, createProvisioner } from "./provision.js";
11
+ import { SnapshotWriter } from "./snapshot-writer.js";
12
+ import { createDaemonSystemContextBuilder } from "./system-context.js";
13
+ import { createRoomStaticContextBuilder } from "./room-context.js";
14
+ import { createRoomContextFetcher } from "./room-context-fetcher.js";
15
+ import { composeBotCordUserTurn } from "./turn-text.js";
16
+ import { UserAuthManager } from "./user-auth.js";
17
+ /**
18
+ * Matches the 10-minute turn timeout the legacy daemon dispatcher used, so
19
+ * long-running CLI turns behave the same way under the gateway core.
20
+ */
21
+ const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
22
+ /**
23
+ * Default cadence for writing `gateway.snapshot()` to disk. Override via
24
+ * `BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS`.
25
+ */
26
+ const DEFAULT_SNAPSHOT_INTERVAL_MS = 5_000;
27
+ function resolveSnapshotIntervalMs() {
28
+ const raw = process.env.BOTCORD_DAEMON_SNAPSHOT_INTERVAL_MS;
29
+ if (!raw)
30
+ return DEFAULT_SNAPSHOT_INTERVAL_MS;
31
+ const n = Number(raw);
32
+ if (!Number.isFinite(n) || n <= 0)
33
+ return DEFAULT_SNAPSHOT_INTERVAL_MS;
34
+ return n;
35
+ }
36
+ // Sender classification lives in `./sender-classify.ts` so it can be shared
37
+ // with the user-turn composer without a daemon.ts ↔ turn-text.ts cycle.
38
+ import { classifyActivitySender } from "./sender-classify.js";
39
+ export { classifyActivitySender };
40
+ /**
41
+ * Build the `onInbound` observer wired to the given activity tracker.
42
+ * Exported for tests.
43
+ *
44
+ * The recorded `agentId` is taken from the inbound message's `accountId`
45
+ * — so a multi-agent daemon files activity under whichever configured
46
+ * agent actually received the message. An optional `fallbackAgentId`
47
+ * covers pathological inputs where `accountId` is empty (should never
48
+ * happen from the gateway, but defensive).
49
+ */
50
+ export function createActivityRecorder(opts) {
51
+ return (msg) => {
52
+ const { kind, label } = classifyActivitySender(msg);
53
+ const rawText = typeof msg.text === "string" ? msg.text : "";
54
+ // Owner text passes through verbatim; everything else gets the same
55
+ // sanitization the legacy dispatcher applied before recording a preview.
56
+ const preview = kind === "owner" ? rawText : sanitizeUntrustedContent(rawText);
57
+ const agentId = msg.accountId || opts.fallbackAgentId || "";
58
+ opts.activityTracker.record({
59
+ agentId,
60
+ roomId: msg.conversation.id,
61
+ roomName: msg.conversation.title,
62
+ topic: msg.conversation.threadId ?? null,
63
+ lastInboundPreview: preview,
64
+ lastSenderKind: kind,
65
+ lastSender: label,
66
+ });
67
+ };
68
+ }
69
+ /**
70
+ * Emit one `runtime_snapshot` event frame on the control channel. Plan §8.5
71
+ * P0: first-connect push only — reconnect-push and diffing are P1. A send
72
+ * failure is non-fatal (the Hub will re-query via `list_runtimes` on demand
73
+ * or wait for the next daemon restart). Exported for unit tests.
74
+ */
75
+ export function pushRuntimeSnapshot(sink) {
76
+ const snap = collectRuntimeSnapshot();
77
+ const ok = sink.send({
78
+ id: `rt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
79
+ type: CONTROL_FRAME_TYPES.RUNTIME_SNAPSHOT,
80
+ params: snap,
81
+ ts: Date.now(),
82
+ });
83
+ if (!ok) {
84
+ daemonLog.warn("runtime-snapshot: control-channel send returned false", {
85
+ runtimes: snap.runtimes.length,
86
+ });
87
+ }
88
+ return ok;
89
+ }
90
+ /**
91
+ * Adapt daemon's file-based `log` module into the gateway logger contract.
92
+ * Writes go to `~/.botcord/logs/daemon.log` + stderr, preserving the format
93
+ * existing `logs -f` watchers rely on. Debug lines stay gated by
94
+ * `BOTCORD_DAEMON_DEBUG`, mirroring pre-migration behavior.
95
+ */
96
+ function buildDaemonLogger() {
97
+ return {
98
+ info: (msg, meta) => daemonLog.info(msg, meta),
99
+ warn: (msg, meta) => daemonLog.warn(msg, meta),
100
+ error: (msg, meta) => daemonLog.error(msg, meta),
101
+ debug: (msg, meta) => daemonLog.debug(msg, meta),
102
+ };
103
+ }
104
+ /**
105
+ * Boot the gateway using a daemon-shaped config. This is the P0.5 compat
106
+ * entry point: the on-disk config at `~/.botcord/daemon/config.json` keeps
107
+ * its existing shape, and `Gateway` handles channels/dispatch/sessions
108
+ * under the hood.
109
+ *
110
+ * Only the BotCord channel is supported today; `channels[]` in the
111
+ * translated gateway config has exactly one entry (`botcord-main`).
112
+ */
113
+ export async function startDaemon(opts) {
114
+ const logger = opts.log ?? buildDaemonLogger();
115
+ // Resolve boot agents: explicit `agents` config wins; otherwise scan the
116
+ // credentials directory. A zero-agent result is valid in P1 — the daemon
117
+ // still starts with zero channels so operators can drop credentials in
118
+ // and restart without re-running `init`.
119
+ const boot = opts.bootAgents ?? resolveBootAgents(opts.config);
120
+ for (const w of boot.warnings) {
121
+ logger.warn("daemon.discovery.warning", { message: w });
122
+ }
123
+ const agentIds = boot.agents.map((a) => a.agentId);
124
+ const { credentialPathByAgentId, agentRuntimes } = backfillBootAgents(boot.agents, { logger });
125
+ const gwConfig = toGatewayConfig(opts.config, { agentIds, agentRuntimes });
126
+ // ActivityTracker lives at the daemon layer (not the gateway core). We
127
+ // expose it to the gateway via (a) the `buildSystemContext` hook so the
128
+ // cross-room digest reflects current activity, and (b) the `onInbound`
129
+ // observer so incoming messages get recorded before the turn runs —
130
+ // mirroring the pre-P0.5 dispatcher's "record-before-adapter-run" ordering.
131
+ const activityTracker = new ActivityTracker();
132
+ // Shared room-context fetcher — one BotCordClient per accountId, created
133
+ // lazily and reused across turns so JWT refreshes amortize. The builder
134
+ // wrapping it adds a TTL cache on top so group rooms don't hit Hub every
135
+ // turn.
136
+ const roomContextFetcher = createRoomContextFetcher({
137
+ credentialPathByAgentId,
138
+ ...(opts.credentialsPath ? { defaultCredentialsPath: opts.credentialsPath } : {}),
139
+ ...(opts.hubBaseUrl ? { hubBaseUrl: opts.hubBaseUrl } : {}),
140
+ log: logger,
141
+ });
142
+ const roomContextBuilder = createRoomStaticContextBuilder({
143
+ fetchRoomInfo: roomContextFetcher,
144
+ log: logger,
145
+ });
146
+ const scBuilders = new Map();
147
+ for (const aid of agentIds) {
148
+ scBuilders.set(aid, createDaemonSystemContextBuilder({
149
+ agentId: aid,
150
+ activityTracker,
151
+ roomContextBuilder,
152
+ }));
153
+ }
154
+ const buildSystemContext = (message) => {
155
+ const b = scBuilders.get(message.accountId);
156
+ if (b)
157
+ return b(message);
158
+ // Unknown accountId (shouldn't happen in practice): fall back to the
159
+ // first configured agent so we still emit *something* rather than
160
+ // silently dropping the context block. When no agents are bound the
161
+ // daemon has no context to surface — return undefined.
162
+ const first = agentIds[0];
163
+ if (!first)
164
+ return undefined;
165
+ const fallback = scBuilders.get(first);
166
+ return fallback ? fallback(message) : undefined;
167
+ };
168
+ // Observer runs after ack + before runtime.run. Keeping the side effect
169
+ // outside the system-context builder (option A) means the builder stays
170
+ // pure — a cleaner contract the gateway can also expose to non-daemon
171
+ // callers in the future.
172
+ const onInbound = createActivityRecorder({
173
+ activityTracker,
174
+ ...(agentIds[0] ? { fallbackAgentId: agentIds[0] } : {}),
175
+ });
176
+ const gateway = new Gateway({
177
+ config: gwConfig,
178
+ sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
179
+ createChannel: (chCfg) => {
180
+ const agentId = typeof chCfg.agentId === "string" ? chCfg.agentId : chCfg.accountId;
181
+ return createBotCordChannel({
182
+ id: chCfg.id,
183
+ accountId: chCfg.accountId,
184
+ agentId,
185
+ credentialsPath: credentialPathByAgentId.get(agentId) ?? opts.credentialsPath,
186
+ hubBaseUrl: opts.hubBaseUrl,
187
+ });
188
+ },
189
+ log: logger,
190
+ turnTimeoutMs: DEFAULT_TURN_TIMEOUT_MS,
191
+ buildSystemContext,
192
+ onInbound,
193
+ composeUserTurn: composeBotCordUserTurn,
194
+ });
195
+ logger.info("daemon starting", {
196
+ agents: agentIds,
197
+ source: boot.source,
198
+ credentialsDir: boot.credentialsDir,
199
+ configPath: opts.configPath,
200
+ sessionsPath: opts.sessionStorePath ?? SESSIONS_PATH,
201
+ channels: gwConfig.channels.map((c) => c.id),
202
+ routeCount: gwConfig.routes?.length ?? 0,
203
+ });
204
+ if (agentIds.length === 0) {
205
+ logger.warn("daemon starting with no channels", {
206
+ source: boot.source,
207
+ credentialsDir: boot.credentialsDir,
208
+ hint: "drop a credentials JSON in the discovery dir and restart, or run `botcord-daemon init --agent <ag_xxx>`",
209
+ });
210
+ }
211
+ await gateway.start();
212
+ logger.info("daemon started", { agents: agentIds });
213
+ // Control channel is optional — daemon still runs (data-plane only)
214
+ // when user-auth hasn't been set up yet. Operators can `login` later
215
+ // without restarting, but for P0 we require a restart to pick it up.
216
+ let controlChannel = null;
217
+ const userAuth = opts.userAuth === undefined
218
+ ? tryLoadUserAuth(logger)
219
+ : opts.userAuth;
220
+ if (userAuth?.current && !opts.disableControlChannel) {
221
+ logger.info("control-channel: enabling", {
222
+ userId: userAuth.current.userId,
223
+ hubUrl: userAuth.current.hubUrl,
224
+ });
225
+ const provisioner = createProvisioner({ gateway });
226
+ controlChannel = new ControlChannel({
227
+ auth: userAuth,
228
+ handle: provisioner,
229
+ });
230
+ try {
231
+ await controlChannel.start();
232
+ // Plan §8.5 P0 — push one runtime snapshot immediately after connect
233
+ // so Hub's `daemon_instances.runtimes_json` is populated for the
234
+ // dashboard even before any user action. No periodic refresh in P0.
235
+ const pushed = pushRuntimeSnapshot(controlChannel);
236
+ logger.info("control-channel: initial runtime_snapshot push", {
237
+ ok: pushed,
238
+ });
239
+ }
240
+ catch (err) {
241
+ logger.warn("control-channel failed to start; continuing without it", {
242
+ error: err instanceof Error ? err.message : String(err),
243
+ });
244
+ // start() schedules its own reconnect; we swallow the initial
245
+ // failure so the daemon boots either way.
246
+ }
247
+ }
248
+ else if (!userAuth?.current) {
249
+ logger.info("control-channel skipped: no user-auth record", {
250
+ hint: "run `botcord-daemon start` to enable Hub control plane (device-code login)",
251
+ });
252
+ }
253
+ const snapshotWriter = new SnapshotWriter({
254
+ path: opts.snapshotPath ?? SNAPSHOT_PATH,
255
+ intervalMs: opts.snapshotIntervalMs ?? resolveSnapshotIntervalMs(),
256
+ snapshot: () => gateway.snapshot(),
257
+ log: logger,
258
+ });
259
+ snapshotWriter.start();
260
+ let stopping = null;
261
+ const stop = (reason) => {
262
+ if (stopping)
263
+ return stopping;
264
+ logger.info("daemon stopping", { reason: reason ?? null });
265
+ snapshotWriter.stop();
266
+ // Write one final snapshot so `status` doesn't briefly see stale data,
267
+ // then delete the file on the way out.
268
+ snapshotWriter.writeFinal();
269
+ const controlStopP = controlChannel
270
+ ? controlChannel.stop().catch(() => undefined)
271
+ : Promise.resolve();
272
+ stopping = Promise.all([controlStopP, gateway.stop(reason)]).then(() => undefined).finally(() => {
273
+ snapshotWriter.remove();
274
+ logger.info("daemon stopped", { reason: reason ?? null });
275
+ });
276
+ return stopping;
277
+ };
278
+ return {
279
+ stop,
280
+ snapshot: () => gateway.snapshot(),
281
+ };
282
+ }
283
+ /**
284
+ * Walk the boot-agent list and (a) populate the credential-path + runtime
285
+ * caches used downstream, and (b) idempotently create each agent's on-disk
286
+ * workspace tree (plan §9). One agent's failing workspace must not block
287
+ * the others — errors are warned and swallowed per agent. Exported for
288
+ * unit tests; `startDaemon` calls this inline.
289
+ */
290
+ export function backfillBootAgents(agents, opts) {
291
+ const ensure = opts.ensure ?? ensureAgentWorkspace;
292
+ const credentialPathByAgentId = new Map();
293
+ const agentRuntimes = {};
294
+ const failed = [];
295
+ for (const a of agents) {
296
+ if (a.credentialsFile)
297
+ credentialPathByAgentId.set(a.agentId, a.credentialsFile);
298
+ if (a.runtime || a.cwd) {
299
+ agentRuntimes[a.agentId] = {
300
+ ...(a.runtime ? { runtime: a.runtime } : {}),
301
+ ...(a.cwd ? { cwd: a.cwd } : {}),
302
+ };
303
+ }
304
+ // Seed files are written only when missing (see `ensureAgentWorkspace`),
305
+ // so a legacy agent whose workspace dir doesn't exist yet gets one on
306
+ // the next boot — with zero risk of overwriting the user's edits.
307
+ try {
308
+ ensure(a.agentId, {
309
+ ...(a.displayName ? { displayName: a.displayName } : {}),
310
+ ...(a.runtime ? { runtime: a.runtime } : {}),
311
+ ...(a.keyId ? { keyId: a.keyId } : {}),
312
+ ...(a.savedAt ? { savedAt: a.savedAt } : {}),
313
+ // `bio` is not surfaced on BootAgent — identity.md renders a
314
+ // placeholder the user can fill in.
315
+ });
316
+ }
317
+ catch (err) {
318
+ failed.push(a.agentId);
319
+ opts.logger.warn("ensureAgentWorkspace failed at boot; continuing", {
320
+ agentId: a.agentId,
321
+ error: err instanceof Error ? err.message : String(err),
322
+ });
323
+ }
324
+ }
325
+ if (failed.length > 0) {
326
+ opts.logger.warn("ensureAgentWorkspace: boot backfill incomplete", {
327
+ count: failed.length,
328
+ agentIds: failed,
329
+ });
330
+ }
331
+ return { credentialPathByAgentId, agentRuntimes };
332
+ }
333
+ /**
334
+ * Load the user-auth record if present, swallowing "file missing" as the
335
+ * expected not-logged-in state. A parse / permission error is logged and
336
+ * treated as "no record" so a broken user-auth.json can't block the
337
+ * data-plane from coming up.
338
+ */
339
+ function tryLoadUserAuth(logger) {
340
+ try {
341
+ return UserAuthManager.load();
342
+ }
343
+ catch (err) {
344
+ logger.warn("failed to load user-auth", {
345
+ error: err instanceof Error ? err.message : String(err),
346
+ });
347
+ return null;
348
+ }
349
+ }
@@ -0,0 +1,89 @@
1
+ import type { RuntimeProbeEntry } from "./adapters/runtimes.js";
2
+ import type { DaemonConfig } from "./config.js";
3
+ /** Summary of a single channel's readiness, printable by the doctor command. */
4
+ export interface ChannelProbeResult {
5
+ id: string;
6
+ type: string;
7
+ accountId: string;
8
+ credentialsOk: boolean;
9
+ credentialsMessage: string;
10
+ hubUrl: string | null;
11
+ hubOk: boolean;
12
+ hubMessage: string;
13
+ }
14
+ /** Minimal filesystem surface needed by {@link probeChannel}; injectable for tests. */
15
+ export interface DoctorFileReader {
16
+ readFile(path: string): string | null;
17
+ }
18
+ /** HTTP GET surface needed by {@link probeChannel}; injectable for tests. */
19
+ export interface DoctorHttpFetcher {
20
+ (url: string, timeoutMs: number): Promise<DoctorHttpResult>;
21
+ }
22
+ /** Response shape returned by {@link DoctorHttpFetcher}. */
23
+ export interface DoctorHttpResult {
24
+ ok: boolean;
25
+ status?: number;
26
+ error?: string;
27
+ }
28
+ /** Input for the rendered doctor output. */
29
+ export interface DoctorInput {
30
+ runtimes: RuntimeProbeEntry[];
31
+ channels: ChannelProbeResult[];
32
+ }
33
+ /** Per-channel config entry accepted by {@link probeChannel}. */
34
+ export interface ChannelProbeConfig {
35
+ id: string;
36
+ type: string;
37
+ accountId: string;
38
+ /**
39
+ * Optional explicit credential file path. When set, wins over the
40
+ * default `~/.botcord/credentials/<accountId>.json` used by
41
+ * `probeChannel`'s fallback. Populated by discovery to surface the
42
+ * exact file that would be loaded at start.
43
+ */
44
+ credentialsFile?: string;
45
+ }
46
+ /** Top-level options for {@link probeChannels}. */
47
+ export interface ProbeChannelsOptions {
48
+ channels: ChannelProbeConfig[];
49
+ credentialsPath: (accountId: string) => string;
50
+ fileReader: DoctorFileReader;
51
+ fetcher: DoctorHttpFetcher;
52
+ timeoutMs?: number;
53
+ }
54
+ /**
55
+ * Build the implicit channel list for a daemon config. One channel per
56
+ * configured or discovered agent, keyed by agentId (matches
57
+ * `toGatewayConfig`). Mirrors the daemon's boot-agent resolution so
58
+ * `doctor` reports channels even when the config file omits `agents`.
59
+ */
60
+ export declare function channelsFromDaemonConfig(cfg: DaemonConfig): ChannelProbeConfig[];
61
+ /**
62
+ * Inspect credentials + Hub reachability for one channel. Pure modulo the
63
+ * injected file reader and fetcher.
64
+ */
65
+ export declare function probeChannel(ch: ChannelProbeConfig, opts: {
66
+ credentialsPath: (accountId: string) => string;
67
+ fileReader: DoctorFileReader;
68
+ fetcher: DoctorHttpFetcher;
69
+ timeoutMs: number;
70
+ }): Promise<ChannelProbeResult>;
71
+ /** Probe a list of channels sequentially. Sequential keeps output stable. */
72
+ export declare function probeChannels(opts: ProbeChannelsOptions): Promise<ChannelProbeResult[]>;
73
+ /** Default HTTP fetcher using `fetch` + `AbortController` timeout. */
74
+ export declare const defaultHttpFetcher: DoctorHttpFetcher;
75
+ /**
76
+ * Render runtime + channel probe output. Pure — all IO happened already.
77
+ * Used by the CLI `doctor` command and by unit tests.
78
+ */
79
+ export declare function renderDoctor(input: DoctorInput): string;
80
+ /**
81
+ * Thin orchestrator: runs runtime + channel probes and returns the rendered
82
+ * text. Keeps `index.ts` free of probe wiring.
83
+ */
84
+ export declare function runDoctor(runtimes: RuntimeProbeEntry[], channels: ChannelProbeConfig[], opts: {
85
+ credentialsPath: (accountId: string) => string;
86
+ fileReader: DoctorFileReader;
87
+ fetcher: DoctorHttpFetcher;
88
+ timeoutMs?: number;
89
+ }): Promise<DoctorInput>;