@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,247 @@
1
+ /**
2
+ * Per-agent on-disk workspace. Each provisioned agent gets a dedicated
3
+ * directory tree under `~/.botcord/agents/{agentId}/`:
4
+ *
5
+ * workspace/ — runtime cwd; seed Markdown files live here (LLM-owned)
6
+ * state/ — daemon-owned JSON (e.g. working-memory.json)
7
+ * codex-home/ — per-agent CODEX_HOME used by the codex adapter so codex
8
+ * reads a daemon-written AGENTS.md (systemContext carrier)
9
+ * and stores its sessions/ without touching ~/.codex.
10
+ *
11
+ * See docs/daemon-agent-workspace-plan.md §4 for the full layout rationale.
12
+ */
13
+ import {
14
+ chmodSync,
15
+ copyFileSync,
16
+ existsSync,
17
+ lstatSync,
18
+ mkdirSync,
19
+ readlinkSync,
20
+ symlinkSync,
21
+ unlinkSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import path from "node:path";
26
+
27
+ // Accepted agent id pattern. Enforced at every path-builder entry so a
28
+ // malicious / malformed agentId (e.g. "../../etc") cannot escape
29
+ // ~/.botcord/agents/ and end up under `rmSync(..., { recursive: true })`
30
+ // in revokeAgent(deleteWorkspace: true).
31
+ const AGENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
32
+
33
+ function assertSafeAgentId(agentId: string): void {
34
+ if (!agentId) throw new Error("agentId is required");
35
+ if (!AGENT_ID_PATTERN.test(agentId)) {
36
+ throw new Error(`unsafe agentId: ${JSON.stringify(agentId)}`);
37
+ }
38
+ }
39
+
40
+ export function agentHomeDir(agentId: string): string {
41
+ assertSafeAgentId(agentId);
42
+ return path.join(homedir(), ".botcord", "agents", agentId);
43
+ }
44
+
45
+ export function agentWorkspaceDir(agentId: string): string {
46
+ return path.join(agentHomeDir(agentId), "workspace");
47
+ }
48
+
49
+ export function agentStateDir(agentId: string): string {
50
+ return path.join(agentHomeDir(agentId), "state");
51
+ }
52
+
53
+ /**
54
+ * Per-agent CODEX_HOME. The codex adapter sets the `CODEX_HOME` env var
55
+ * to this path so codex reads a daemon-managed `AGENTS.md` (written fresh
56
+ * each turn with the agent's systemContext) and stores its `sessions/`
57
+ * here — neither touching `~/.codex/` nor the agent's `workspace/` cwd.
58
+ */
59
+ export function agentCodexHomeDir(agentId: string): string {
60
+ return path.join(agentHomeDir(agentId), "codex-home");
61
+ }
62
+
63
+ export interface WorkspaceSeed {
64
+ displayName?: string;
65
+ bio?: string;
66
+ runtime?: string;
67
+ keyId?: string;
68
+ /** ISO timestamp. */
69
+ savedAt?: string;
70
+ }
71
+
72
+ const AGENTS_MD = `# Agent Workspace
73
+
74
+ This directory is your persistent workspace. You run with \`cwd\` set here.
75
+
76
+ ## Files you own
77
+
78
+ - \`identity.md\` — who you are, your role, your boundaries. Read before responding.
79
+ - \`memory.md\` — long-lived facts, user preferences, past decisions. Update when
80
+ you learn something durable. Prune when it grows stale.
81
+ - \`task.md\` — current task and plan. Update as you make progress. Clear when done.
82
+ - \`notes/\` — free-form scratch space.
83
+
84
+ ## Boundaries
85
+
86
+ - Do not modify files outside this workspace unless the user explicitly asks.
87
+ - \`../state/\` (sibling directory, outside this workspace) is managed by the
88
+ daemon — do not read or edit it directly.
89
+
90
+ ## How to use this
91
+
92
+ You are **instructed** to skim \`identity.md\`, \`memory.md\`, \`task.md\` before each
93
+ response and to write back what changed after meaningful turns. Nothing in the
94
+ runtime enforces this — the daemon does not auto-load these files into your
95
+ context. Treat AGENTS.md as a convention, not a mechanism.
96
+ `;
97
+
98
+ const MEMORY_MD = `# Memory
99
+
100
+ <!--
101
+ Long-lived facts about the user, past decisions, and preferences that should
102
+ survive across conversations. Organize by topic. Keep entries short. Prune
103
+ regularly — AGENTS.md instructs the runtime to consult this file before each
104
+ response, but nothing loads it automatically; keep it short enough to be
105
+ worth re-reading.
106
+ -->
107
+ `;
108
+
109
+ const TASK_MD = `# Current Task
110
+
111
+ <!--
112
+ What are you working on right now? What is the plan? What is blocked?
113
+ Clear this file when the task is done.
114
+ -->
115
+ `;
116
+
117
+ const BIO_PLACEHOLDER = "_(none provided at provision time — edit this section)_";
118
+ const FIELD_PLACEHOLDER = "_(not set)_";
119
+
120
+ function renderIdentity(agentId: string, seed: WorkspaceSeed): string {
121
+ const bio = seed.bio && seed.bio.trim().length > 0 ? seed.bio : BIO_PLACEHOLDER;
122
+ return `# Identity
123
+
124
+ - **Agent ID**: ${agentId}
125
+ - **Display name**: ${seed.displayName ?? FIELD_PLACEHOLDER}
126
+ - **Runtime**: ${seed.runtime ?? FIELD_PLACEHOLDER}
127
+ - **Key ID**: ${seed.keyId ?? FIELD_PLACEHOLDER}
128
+ - **Created**: ${seed.savedAt ?? FIELD_PLACEHOLDER}
129
+
130
+ ## Bio
131
+
132
+ ${bio}
133
+
134
+ ## Role
135
+
136
+ _(Describe what you do and for whom. Edit this section.)_
137
+
138
+ ## Boundaries
139
+
140
+ _(What you will and will not do. Edit this section.)_
141
+ `;
142
+ }
143
+
144
+ function mkdirTolerant(dir: string): void {
145
+ try {
146
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
147
+ } catch (err) {
148
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
149
+ }
150
+ // mkdirSync with `recursive: true` only applies `mode` to directories it
151
+ // creates. If the agent home / workspace / state already existed with
152
+ // looser perms (a very common case on upgrades), tighten them now.
153
+ // Best-effort: some filesystems (e.g. certain Windows / SMB mounts) reject
154
+ // chmod and that is acceptable.
155
+ try {
156
+ chmodSync(dir, 0o700);
157
+ } catch {
158
+ /* best-effort */
159
+ }
160
+ }
161
+
162
+ function writeIfMissing(filePath: string, content: string): void {
163
+ if (existsSync(filePath)) return;
164
+ writeFileSync(filePath, content, { mode: 0o600 });
165
+ }
166
+
167
+ /**
168
+ * Best-effort link user's `~/.codex/auth.json` into the per-agent CODEX_HOME.
169
+ * Prefers a symlink (auto-follows `codex login` refreshes) and falls back to
170
+ * a copy on filesystems that reject symlinks. A no-op if the user has never
171
+ * run `codex login` — codex will then prompt on first use.
172
+ */
173
+ function linkCodexAuth(codexHome: string): void {
174
+ const source = path.join(homedir(), ".codex", "auth.json");
175
+ if (!existsSync(source)) return;
176
+ const target = path.join(codexHome, "auth.json");
177
+ try {
178
+ if (existsSync(target) || isSymlink(target)) {
179
+ if (isSymlink(target) && readlinkSync(target) === source) return;
180
+ unlinkSync(target);
181
+ }
182
+ } catch {
183
+ // Unlink failure is rare but tolerable — symlink/copy below will fail
184
+ // loudly if the collision is real.
185
+ }
186
+ try {
187
+ symlinkSync(source, target);
188
+ return;
189
+ } catch {
190
+ // Fall through to copy on filesystems without symlink support.
191
+ }
192
+ try {
193
+ copyFileSync(source, target);
194
+ chmodSync(target, 0o600);
195
+ } catch {
196
+ /* best-effort */
197
+ }
198
+ }
199
+
200
+ function isSymlink(p: string): boolean {
201
+ try {
202
+ return lstatSync(p).isSymbolicLink();
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Idempotently create the per-agent CODEX_HOME directory and link the
210
+ * user's codex `auth.json` into it. Does NOT write an initial `AGENTS.md`
211
+ * — the codex adapter writes it fresh per turn from `systemContext`.
212
+ */
213
+ export function ensureAgentCodexHome(agentId: string): string {
214
+ const dir = agentCodexHomeDir(agentId);
215
+ mkdirTolerant(dir);
216
+ linkCodexAuth(dir);
217
+ return dir;
218
+ }
219
+
220
+ /**
221
+ * Idempotently create the agent's home / workspace / state directories and
222
+ * seed the workspace Markdown files. Existing files are never overwritten —
223
+ * users' edits to AGENTS.md, memory.md, etc. are preserved across calls.
224
+ * State files are not touched here; working-memory.ts owns `state/`.
225
+ */
226
+ export function ensureAgentWorkspace(agentId: string, seed: WorkspaceSeed): void {
227
+ if (!agentId) throw new Error("ensureAgentWorkspace: agentId is required");
228
+ const home = agentHomeDir(agentId);
229
+ const workspace = agentWorkspaceDir(agentId);
230
+ const notes = path.join(workspace, "notes");
231
+ const state = agentStateDir(agentId);
232
+
233
+ mkdirTolerant(home);
234
+ mkdirTolerant(workspace);
235
+ mkdirTolerant(notes);
236
+ mkdirTolerant(state);
237
+ ensureAgentCodexHome(agentId);
238
+
239
+ const agentsMdPath = path.join(workspace, "AGENTS.md");
240
+ const claudeMdPath = path.join(workspace, "CLAUDE.md");
241
+ writeIfMissing(agentsMdPath, AGENTS_MD);
242
+ writeIfMissing(claudeMdPath, AGENTS_MD);
243
+ writeIfMissing(path.join(workspace, "identity.md"), renderIdentity(agentId, seed));
244
+ writeIfMissing(path.join(workspace, "memory.md"), MEMORY_MD);
245
+ writeIfMissing(path.join(workspace, "task.md"), TASK_MD);
246
+ writeIfMissing(path.join(notes, ".gitkeep"), "");
247
+ }
package/src/config.ts ADDED
@@ -0,0 +1,290 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { getAdapterModule, listAdapterIds } from "./adapters/runtimes.js";
5
+
6
+ const DAEMON_DIR = path.join(homedir(), ".botcord", "daemon");
7
+ const CONFIG_PATH = path.join(DAEMON_DIR, "config.json");
8
+ export const PID_PATH = path.join(DAEMON_DIR, "daemon.pid");
9
+ export const SESSIONS_PATH = path.join(DAEMON_DIR, "sessions.json");
10
+ export const SNAPSHOT_PATH = path.join(DAEMON_DIR, "snapshot.json");
11
+
12
+ /**
13
+ * Adapter ids. Built-in adapters are enumerated for editor hints; any string
14
+ * accepted by the registry is valid at runtime.
15
+ */
16
+ export type AdapterName = "claude-code" | "codex" | "gemini" | (string & {});
17
+
18
+ /**
19
+ * Predicates selecting messages for a route. `roomId` / `roomPrefix` are
20
+ * legacy aliases retained for backward compatibility with pre-P1 daemon
21
+ * configs; they map to `conversationId` / `conversationPrefix` at boot.
22
+ * First match wins.
23
+ */
24
+ export interface RouteRuleMatch {
25
+ /** @deprecated alias for `conversationId`. */
26
+ roomId?: string;
27
+ /** @deprecated alias for `conversationPrefix`. */
28
+ roomPrefix?: string;
29
+ channel?: string;
30
+ accountId?: string;
31
+ conversationId?: string;
32
+ conversationPrefix?: string;
33
+ conversationKind?: "direct" | "group";
34
+ senderId?: string;
35
+ mentioned?: boolean;
36
+ }
37
+
38
+ export interface RouteRule {
39
+ match: RouteRuleMatch;
40
+ adapter: AdapterName;
41
+ cwd: string;
42
+ /** Extra CLI flags appended to the adapter invocation. */
43
+ extraArgs?: string[];
44
+ }
45
+
46
+ export interface DaemonRouteDefault {
47
+ adapter: AdapterName;
48
+ cwd: string;
49
+ extraArgs?: string[];
50
+ }
51
+
52
+ /**
53
+ * Daemon-layer hook controlling credential auto-discovery at boot. Kept
54
+ * optional so most users never need to touch it; absence means "use the
55
+ * default rule" (enabled unless an explicit `agents`/`agentId` list is
56
+ * already present).
57
+ */
58
+ export interface AgentDiscoveryConfig {
59
+ enabled?: boolean;
60
+ credentialsDir?: string;
61
+ }
62
+
63
+ export interface DaemonConfig {
64
+ /**
65
+ * @deprecated Kept for backward compatibility with pre-multi-agent configs.
66
+ * Normalized in-memory to `agents: [agentId]` when present. New configs
67
+ * written by `init` use `agents` exclusively.
68
+ */
69
+ agentId?: string;
70
+
71
+ /**
72
+ * BotCord agent ids this daemon binds to. Each id maps to its own channel
73
+ * whose `id` equals the agentId. Credentials are loaded from
74
+ * `~/.botcord/credentials/{agentId}.json`. Canonical — prefer this over
75
+ * `agentId`.
76
+ *
77
+ * Optional: when both `agents` and `agentId` are absent, the daemon
78
+ * discovers identities from the credentials directory at boot.
79
+ */
80
+ agents?: string[];
81
+
82
+ /**
83
+ * Opt-in controls for credential discovery. When omitted, discovery runs
84
+ * iff no explicit `agents`/`agentId` list is present (P1 default).
85
+ */
86
+ agentDiscovery?: AgentDiscoveryConfig;
87
+
88
+ /** Default adapter + cwd used when no route matches. */
89
+ defaultRoute: DaemonRouteDefault;
90
+ routes: RouteRule[];
91
+ /** If true, stream blocks (only meaningful for rm_oc_* rooms). */
92
+ streamBlocks: boolean;
93
+ }
94
+
95
+ /**
96
+ * Return the explicit agent-id list written to disk, or `null` when the
97
+ * config has none. P1 gives discovery a chance at boot before failing,
98
+ * so the resolver no longer throws — callers that need a guaranteed list
99
+ * should fall back to `resolveAgentIds` (which still throws) or run the
100
+ * discovery layer first.
101
+ *
102
+ * - `agents` is present and non-empty → use it verbatim.
103
+ * - `agents` empty/missing + `agentId` present → synthesize `[agentId]`.
104
+ * - Both present: if `agentId` is not in `agents`, log a warn and let
105
+ * `agents` win.
106
+ * - Neither present → return `null` (discovery-eligible).
107
+ *
108
+ * De-duplicates while preserving order.
109
+ */
110
+ export function resolveConfiguredAgentIds(cfg: DaemonConfig): string[] | null {
111
+ const agents = Array.isArray(cfg.agents) ? cfg.agents.filter((s) => typeof s === "string" && s.length > 0) : [];
112
+ const legacy = typeof cfg.agentId === "string" && cfg.agentId.length > 0 ? cfg.agentId : null;
113
+
114
+ if (agents.length > 0) {
115
+ if (legacy && !agents.includes(legacy)) {
116
+ // Conflicting shapes on disk; `agents` wins per spec. Logged via
117
+ // stderr so users running `config` or `start` can spot the drift —
118
+ // the `log` module isn't imported here to avoid the side-effect of
119
+ // opening the log file from config validation.
120
+ // eslint-disable-next-line no-console
121
+ console.warn(
122
+ `daemon config: legacy agentId "${legacy}" not listed in agents [${agents.join(", ")}]; preferring agents`,
123
+ );
124
+ }
125
+ return dedupe(agents);
126
+ }
127
+ if (legacy) return [legacy];
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Legacy strict resolver — preserved for callers that still assume an
133
+ * explicit list on disk (e.g. internal tests, or codepaths that run
134
+ * before discovery). Throws when neither `agents` nor `agentId` is set.
135
+ */
136
+ export function resolveAgentIds(cfg: DaemonConfig): string[] {
137
+ const configured = resolveConfiguredAgentIds(cfg);
138
+ if (configured) return configured;
139
+ throw new Error(
140
+ `daemon config missing agents (or legacy agentId) (${CONFIG_PATH})`,
141
+ );
142
+ }
143
+
144
+ function dedupe(xs: string[]): string[] {
145
+ const seen = new Set<string>();
146
+ const out: string[] = [];
147
+ for (const x of xs) {
148
+ if (seen.has(x)) continue;
149
+ seen.add(x);
150
+ out.push(x);
151
+ }
152
+ return out;
153
+ }
154
+
155
+ function ensureDir(): void {
156
+ try {
157
+ mkdirSync(DAEMON_DIR, { recursive: true, mode: 0o700 });
158
+ } catch {
159
+ // best-effort
160
+ }
161
+ }
162
+
163
+ export function loadConfig(): DaemonConfig {
164
+ if (!existsSync(CONFIG_PATH)) {
165
+ throw new Error(
166
+ `daemon config not found at ${CONFIG_PATH}. Run \`botcord-daemon init\` first.`,
167
+ );
168
+ }
169
+ const raw = readFileSync(CONFIG_PATH, "utf8");
170
+ const parsed = JSON.parse(raw) as Partial<DaemonConfig>;
171
+
172
+ const hasAgents =
173
+ Array.isArray(parsed.agents) && parsed.agents.some((s) => typeof s === "string" && s.length > 0);
174
+ const hasLegacy = typeof parsed.agentId === "string" && parsed.agentId.length > 0;
175
+ const discovery = parsed.agentDiscovery;
176
+ const discoveryExplicitlyDisabled =
177
+ !!discovery && typeof discovery === "object" && discovery.enabled === false;
178
+ if (!hasAgents && !hasLegacy && discoveryExplicitlyDisabled) {
179
+ throw new Error(
180
+ `daemon config has no agents/agentId and agentDiscovery.enabled=false (${CONFIG_PATH})`,
181
+ );
182
+ }
183
+ if (hasAgents) {
184
+ for (const [i, a] of (parsed.agents as unknown[]).entries()) {
185
+ if (typeof a !== "string" || a.length === 0) {
186
+ throw new Error(`daemon config agents[${i}] must be a non-empty string (${CONFIG_PATH})`);
187
+ }
188
+ }
189
+ }
190
+ if (
191
+ !parsed.defaultRoute ||
192
+ typeof parsed.defaultRoute.adapter !== "string" ||
193
+ typeof parsed.defaultRoute.cwd !== "string"
194
+ ) {
195
+ throw new Error(`daemon config missing defaultRoute.adapter/cwd (${CONFIG_PATH})`);
196
+ }
197
+ validateAdapter(parsed.defaultRoute.adapter, "defaultRoute.adapter");
198
+
199
+ const routesRaw = parsed.routes ?? [];
200
+ if (!Array.isArray(routesRaw)) {
201
+ throw new Error(`daemon config "routes" must be an array (${CONFIG_PATH})`);
202
+ }
203
+ for (const [i, r] of routesRaw.entries()) {
204
+ if (!r || typeof r !== "object") {
205
+ throw new Error(`daemon config routes[${i}] is not an object (${CONFIG_PATH})`);
206
+ }
207
+ if (typeof r.adapter !== "string" || typeof r.cwd !== "string") {
208
+ throw new Error(
209
+ `daemon config routes[${i}] missing string adapter/cwd (${CONFIG_PATH})`,
210
+ );
211
+ }
212
+ validateAdapter(r.adapter, `routes[${i}].adapter`);
213
+ }
214
+ // Preserve the on-disk shape as-is so `config` prints what the user wrote.
215
+ // Resolution of agents vs agentId happens at the consumption boundary
216
+ // (`resolveAgentIds`, `toGatewayConfig`).
217
+ const out: DaemonConfig = {
218
+ defaultRoute: parsed.defaultRoute,
219
+ routes: routesRaw,
220
+ streamBlocks: parsed.streamBlocks ?? true,
221
+ };
222
+ if (hasAgents) out.agents = (parsed.agents as string[]).slice();
223
+ if (hasLegacy) out.agentId = parsed.agentId;
224
+ if (discovery && typeof discovery === "object") {
225
+ const copy: AgentDiscoveryConfig = {};
226
+ if (typeof discovery.enabled === "boolean") copy.enabled = discovery.enabled;
227
+ if (typeof discovery.credentialsDir === "string" && discovery.credentialsDir.length > 0) {
228
+ copy.credentialsDir = discovery.credentialsDir;
229
+ }
230
+ out.agentDiscovery = copy;
231
+ }
232
+ return out;
233
+ }
234
+
235
+ function validateAdapter(id: string, field: string): void {
236
+ const mod = getAdapterModule(id);
237
+ if (!mod) {
238
+ throw new Error(
239
+ `unknown ${field} "${id}". Registered: ${listAdapterIds().join(", ")}`,
240
+ );
241
+ }
242
+ if (mod.supportsRun === false) {
243
+ throw new Error(
244
+ `${field} "${id}" is a probe-only stub and cannot handle turns yet`,
245
+ );
246
+ }
247
+ }
248
+
249
+ export function saveConfig(cfg: DaemonConfig): void {
250
+ ensureDir();
251
+ const tmp = CONFIG_PATH + ".tmp";
252
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2), { mode: 0o600 });
253
+ renameSync(tmp, CONFIG_PATH);
254
+ }
255
+
256
+ /**
257
+ * Build a default config. Always writes the new `agents: [...]` shape when
258
+ * explicit ids are provided; the legacy scalar `agentId` field is never
259
+ * emitted by fresh `init` runs. When called with an empty list, `agents`
260
+ * is omitted entirely so the daemon auto-discovers identities at boot.
261
+ */
262
+ export function initDefaultConfig(
263
+ agentIds: string | string[] | null | undefined,
264
+ cwd: string = homedir(),
265
+ ): DaemonConfig {
266
+ const list = Array.isArray(agentIds)
267
+ ? agentIds
268
+ : typeof agentIds === "string" && agentIds.length > 0
269
+ ? [agentIds]
270
+ : [];
271
+ const out: DaemonConfig = {
272
+ defaultRoute: { adapter: "claude-code", cwd },
273
+ routes: [],
274
+ streamBlocks: true,
275
+ };
276
+ if (list.length > 0) out.agents = dedupe(list);
277
+ return out;
278
+ }
279
+
280
+ export const CONFIG_FILE_PATH = CONFIG_PATH;
281
+ export const DAEMON_DIR_PATH = DAEMON_DIR;
282
+
283
+ /**
284
+ * Make the daemon directory (`~/.botcord/daemon`) exist with mode `0700`.
285
+ * Tolerant of the common case where it already exists. Exported so
286
+ * user-auth/snapshot code can stay independent of `saveConfig`.
287
+ */
288
+ export function ensureDaemonDir(): void {
289
+ ensureDir();
290
+ }