@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,221 @@
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 { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import path from "node:path";
16
+ // Accepted agent id pattern. Enforced at every path-builder entry so a
17
+ // malicious / malformed agentId (e.g. "../../etc") cannot escape
18
+ // ~/.botcord/agents/ and end up under `rmSync(..., { recursive: true })`
19
+ // in revokeAgent(deleteWorkspace: true).
20
+ const AGENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
21
+ function assertSafeAgentId(agentId) {
22
+ if (!agentId)
23
+ throw new Error("agentId is required");
24
+ if (!AGENT_ID_PATTERN.test(agentId)) {
25
+ throw new Error(`unsafe agentId: ${JSON.stringify(agentId)}`);
26
+ }
27
+ }
28
+ export function agentHomeDir(agentId) {
29
+ assertSafeAgentId(agentId);
30
+ return path.join(homedir(), ".botcord", "agents", agentId);
31
+ }
32
+ export function agentWorkspaceDir(agentId) {
33
+ return path.join(agentHomeDir(agentId), "workspace");
34
+ }
35
+ export function agentStateDir(agentId) {
36
+ return path.join(agentHomeDir(agentId), "state");
37
+ }
38
+ /**
39
+ * Per-agent CODEX_HOME. The codex adapter sets the `CODEX_HOME` env var
40
+ * to this path so codex reads a daemon-managed `AGENTS.md` (written fresh
41
+ * each turn with the agent's systemContext) and stores its `sessions/`
42
+ * here — neither touching `~/.codex/` nor the agent's `workspace/` cwd.
43
+ */
44
+ export function agentCodexHomeDir(agentId) {
45
+ return path.join(agentHomeDir(agentId), "codex-home");
46
+ }
47
+ const AGENTS_MD = `# Agent Workspace
48
+
49
+ This directory is your persistent workspace. You run with \`cwd\` set here.
50
+
51
+ ## Files you own
52
+
53
+ - \`identity.md\` — who you are, your role, your boundaries. Read before responding.
54
+ - \`memory.md\` — long-lived facts, user preferences, past decisions. Update when
55
+ you learn something durable. Prune when it grows stale.
56
+ - \`task.md\` — current task and plan. Update as you make progress. Clear when done.
57
+ - \`notes/\` — free-form scratch space.
58
+
59
+ ## Boundaries
60
+
61
+ - Do not modify files outside this workspace unless the user explicitly asks.
62
+ - \`../state/\` (sibling directory, outside this workspace) is managed by the
63
+ daemon — do not read or edit it directly.
64
+
65
+ ## How to use this
66
+
67
+ You are **instructed** to skim \`identity.md\`, \`memory.md\`, \`task.md\` before each
68
+ response and to write back what changed after meaningful turns. Nothing in the
69
+ runtime enforces this — the daemon does not auto-load these files into your
70
+ context. Treat AGENTS.md as a convention, not a mechanism.
71
+ `;
72
+ const MEMORY_MD = `# Memory
73
+
74
+ <!--
75
+ Long-lived facts about the user, past decisions, and preferences that should
76
+ survive across conversations. Organize by topic. Keep entries short. Prune
77
+ regularly — AGENTS.md instructs the runtime to consult this file before each
78
+ response, but nothing loads it automatically; keep it short enough to be
79
+ worth re-reading.
80
+ -->
81
+ `;
82
+ const TASK_MD = `# Current Task
83
+
84
+ <!--
85
+ What are you working on right now? What is the plan? What is blocked?
86
+ Clear this file when the task is done.
87
+ -->
88
+ `;
89
+ const BIO_PLACEHOLDER = "_(none provided at provision time — edit this section)_";
90
+ const FIELD_PLACEHOLDER = "_(not set)_";
91
+ function renderIdentity(agentId, seed) {
92
+ const bio = seed.bio && seed.bio.trim().length > 0 ? seed.bio : BIO_PLACEHOLDER;
93
+ return `# Identity
94
+
95
+ - **Agent ID**: ${agentId}
96
+ - **Display name**: ${seed.displayName ?? FIELD_PLACEHOLDER}
97
+ - **Runtime**: ${seed.runtime ?? FIELD_PLACEHOLDER}
98
+ - **Key ID**: ${seed.keyId ?? FIELD_PLACEHOLDER}
99
+ - **Created**: ${seed.savedAt ?? FIELD_PLACEHOLDER}
100
+
101
+ ## Bio
102
+
103
+ ${bio}
104
+
105
+ ## Role
106
+
107
+ _(Describe what you do and for whom. Edit this section.)_
108
+
109
+ ## Boundaries
110
+
111
+ _(What you will and will not do. Edit this section.)_
112
+ `;
113
+ }
114
+ function mkdirTolerant(dir) {
115
+ try {
116
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
117
+ }
118
+ catch (err) {
119
+ if (err.code !== "EEXIST")
120
+ throw err;
121
+ }
122
+ // mkdirSync with `recursive: true` only applies `mode` to directories it
123
+ // creates. If the agent home / workspace / state already existed with
124
+ // looser perms (a very common case on upgrades), tighten them now.
125
+ // Best-effort: some filesystems (e.g. certain Windows / SMB mounts) reject
126
+ // chmod and that is acceptable.
127
+ try {
128
+ chmodSync(dir, 0o700);
129
+ }
130
+ catch {
131
+ /* best-effort */
132
+ }
133
+ }
134
+ function writeIfMissing(filePath, content) {
135
+ if (existsSync(filePath))
136
+ return;
137
+ writeFileSync(filePath, content, { mode: 0o600 });
138
+ }
139
+ /**
140
+ * Best-effort link user's `~/.codex/auth.json` into the per-agent CODEX_HOME.
141
+ * Prefers a symlink (auto-follows `codex login` refreshes) and falls back to
142
+ * a copy on filesystems that reject symlinks. A no-op if the user has never
143
+ * run `codex login` — codex will then prompt on first use.
144
+ */
145
+ function linkCodexAuth(codexHome) {
146
+ const source = path.join(homedir(), ".codex", "auth.json");
147
+ if (!existsSync(source))
148
+ return;
149
+ const target = path.join(codexHome, "auth.json");
150
+ try {
151
+ if (existsSync(target) || isSymlink(target)) {
152
+ if (isSymlink(target) && readlinkSync(target) === source)
153
+ return;
154
+ unlinkSync(target);
155
+ }
156
+ }
157
+ catch {
158
+ // Unlink failure is rare but tolerable — symlink/copy below will fail
159
+ // loudly if the collision is real.
160
+ }
161
+ try {
162
+ symlinkSync(source, target);
163
+ return;
164
+ }
165
+ catch {
166
+ // Fall through to copy on filesystems without symlink support.
167
+ }
168
+ try {
169
+ copyFileSync(source, target);
170
+ chmodSync(target, 0o600);
171
+ }
172
+ catch {
173
+ /* best-effort */
174
+ }
175
+ }
176
+ function isSymlink(p) {
177
+ try {
178
+ return lstatSync(p).isSymbolicLink();
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ }
184
+ /**
185
+ * Idempotently create the per-agent CODEX_HOME directory and link the
186
+ * user's codex `auth.json` into it. Does NOT write an initial `AGENTS.md`
187
+ * — the codex adapter writes it fresh per turn from `systemContext`.
188
+ */
189
+ export function ensureAgentCodexHome(agentId) {
190
+ const dir = agentCodexHomeDir(agentId);
191
+ mkdirTolerant(dir);
192
+ linkCodexAuth(dir);
193
+ return dir;
194
+ }
195
+ /**
196
+ * Idempotently create the agent's home / workspace / state directories and
197
+ * seed the workspace Markdown files. Existing files are never overwritten —
198
+ * users' edits to AGENTS.md, memory.md, etc. are preserved across calls.
199
+ * State files are not touched here; working-memory.ts owns `state/`.
200
+ */
201
+ export function ensureAgentWorkspace(agentId, seed) {
202
+ if (!agentId)
203
+ throw new Error("ensureAgentWorkspace: agentId is required");
204
+ const home = agentHomeDir(agentId);
205
+ const workspace = agentWorkspaceDir(agentId);
206
+ const notes = path.join(workspace, "notes");
207
+ const state = agentStateDir(agentId);
208
+ mkdirTolerant(home);
209
+ mkdirTolerant(workspace);
210
+ mkdirTolerant(notes);
211
+ mkdirTolerant(state);
212
+ ensureAgentCodexHome(agentId);
213
+ const agentsMdPath = path.join(workspace, "AGENTS.md");
214
+ const claudeMdPath = path.join(workspace, "CLAUDE.md");
215
+ writeIfMissing(agentsMdPath, AGENTS_MD);
216
+ writeIfMissing(claudeMdPath, AGENTS_MD);
217
+ writeIfMissing(path.join(workspace, "identity.md"), renderIdentity(agentId, seed));
218
+ writeIfMissing(path.join(workspace, "memory.md"), MEMORY_MD);
219
+ writeIfMissing(path.join(workspace, "task.md"), TASK_MD);
220
+ writeIfMissing(path.join(notes, ".gitkeep"), "");
221
+ }
@@ -0,0 +1,116 @@
1
+ export declare const PID_PATH: string;
2
+ export declare const SESSIONS_PATH: string;
3
+ export declare const SNAPSHOT_PATH: string;
4
+ /**
5
+ * Adapter ids. Built-in adapters are enumerated for editor hints; any string
6
+ * accepted by the registry is valid at runtime.
7
+ */
8
+ export type AdapterName = "claude-code" | "codex" | "gemini" | (string & {});
9
+ /**
10
+ * Predicates selecting messages for a route. `roomId` / `roomPrefix` are
11
+ * legacy aliases retained for backward compatibility with pre-P1 daemon
12
+ * configs; they map to `conversationId` / `conversationPrefix` at boot.
13
+ * First match wins.
14
+ */
15
+ export interface RouteRuleMatch {
16
+ /** @deprecated alias for `conversationId`. */
17
+ roomId?: string;
18
+ /** @deprecated alias for `conversationPrefix`. */
19
+ roomPrefix?: string;
20
+ channel?: string;
21
+ accountId?: string;
22
+ conversationId?: string;
23
+ conversationPrefix?: string;
24
+ conversationKind?: "direct" | "group";
25
+ senderId?: string;
26
+ mentioned?: boolean;
27
+ }
28
+ export interface RouteRule {
29
+ match: RouteRuleMatch;
30
+ adapter: AdapterName;
31
+ cwd: string;
32
+ /** Extra CLI flags appended to the adapter invocation. */
33
+ extraArgs?: string[];
34
+ }
35
+ export interface DaemonRouteDefault {
36
+ adapter: AdapterName;
37
+ cwd: string;
38
+ extraArgs?: string[];
39
+ }
40
+ /**
41
+ * Daemon-layer hook controlling credential auto-discovery at boot. Kept
42
+ * optional so most users never need to touch it; absence means "use the
43
+ * default rule" (enabled unless an explicit `agents`/`agentId` list is
44
+ * already present).
45
+ */
46
+ export interface AgentDiscoveryConfig {
47
+ enabled?: boolean;
48
+ credentialsDir?: string;
49
+ }
50
+ export interface DaemonConfig {
51
+ /**
52
+ * @deprecated Kept for backward compatibility with pre-multi-agent configs.
53
+ * Normalized in-memory to `agents: [agentId]` when present. New configs
54
+ * written by `init` use `agents` exclusively.
55
+ */
56
+ agentId?: string;
57
+ /**
58
+ * BotCord agent ids this daemon binds to. Each id maps to its own channel
59
+ * whose `id` equals the agentId. Credentials are loaded from
60
+ * `~/.botcord/credentials/{agentId}.json`. Canonical — prefer this over
61
+ * `agentId`.
62
+ *
63
+ * Optional: when both `agents` and `agentId` are absent, the daemon
64
+ * discovers identities from the credentials directory at boot.
65
+ */
66
+ agents?: string[];
67
+ /**
68
+ * Opt-in controls for credential discovery. When omitted, discovery runs
69
+ * iff no explicit `agents`/`agentId` list is present (P1 default).
70
+ */
71
+ agentDiscovery?: AgentDiscoveryConfig;
72
+ /** Default adapter + cwd used when no route matches. */
73
+ defaultRoute: DaemonRouteDefault;
74
+ routes: RouteRule[];
75
+ /** If true, stream blocks (only meaningful for rm_oc_* rooms). */
76
+ streamBlocks: boolean;
77
+ }
78
+ /**
79
+ * Return the explicit agent-id list written to disk, or `null` when the
80
+ * config has none. P1 gives discovery a chance at boot before failing,
81
+ * so the resolver no longer throws — callers that need a guaranteed list
82
+ * should fall back to `resolveAgentIds` (which still throws) or run the
83
+ * discovery layer first.
84
+ *
85
+ * - `agents` is present and non-empty → use it verbatim.
86
+ * - `agents` empty/missing + `agentId` present → synthesize `[agentId]`.
87
+ * - Both present: if `agentId` is not in `agents`, log a warn and let
88
+ * `agents` win.
89
+ * - Neither present → return `null` (discovery-eligible).
90
+ *
91
+ * De-duplicates while preserving order.
92
+ */
93
+ export declare function resolveConfiguredAgentIds(cfg: DaemonConfig): string[] | null;
94
+ /**
95
+ * Legacy strict resolver — preserved for callers that still assume an
96
+ * explicit list on disk (e.g. internal tests, or codepaths that run
97
+ * before discovery). Throws when neither `agents` nor `agentId` is set.
98
+ */
99
+ export declare function resolveAgentIds(cfg: DaemonConfig): string[];
100
+ export declare function loadConfig(): DaemonConfig;
101
+ export declare function saveConfig(cfg: DaemonConfig): void;
102
+ /**
103
+ * Build a default config. Always writes the new `agents: [...]` shape when
104
+ * explicit ids are provided; the legacy scalar `agentId` field is never
105
+ * emitted by fresh `init` runs. When called with an empty list, `agents`
106
+ * is omitted entirely so the daemon auto-discovers identities at boot.
107
+ */
108
+ export declare function initDefaultConfig(agentIds: string | string[] | null | undefined, cwd?: string): DaemonConfig;
109
+ export declare const CONFIG_FILE_PATH: string;
110
+ export declare const DAEMON_DIR_PATH: string;
111
+ /**
112
+ * Make the daemon directory (`~/.botcord/daemon`) exist with mode `0700`.
113
+ * Tolerant of the common case where it already exists. Exported so
114
+ * user-auth/snapshot code can stay independent of `saveConfig`.
115
+ */
116
+ export declare function ensureDaemonDir(): void;
package/dist/config.js ADDED
@@ -0,0 +1,180 @@
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
+ const DAEMON_DIR = path.join(homedir(), ".botcord", "daemon");
6
+ const CONFIG_PATH = path.join(DAEMON_DIR, "config.json");
7
+ export const PID_PATH = path.join(DAEMON_DIR, "daemon.pid");
8
+ export const SESSIONS_PATH = path.join(DAEMON_DIR, "sessions.json");
9
+ export const SNAPSHOT_PATH = path.join(DAEMON_DIR, "snapshot.json");
10
+ /**
11
+ * Return the explicit agent-id list written to disk, or `null` when the
12
+ * config has none. P1 gives discovery a chance at boot before failing,
13
+ * so the resolver no longer throws — callers that need a guaranteed list
14
+ * should fall back to `resolveAgentIds` (which still throws) or run the
15
+ * discovery layer first.
16
+ *
17
+ * - `agents` is present and non-empty → use it verbatim.
18
+ * - `agents` empty/missing + `agentId` present → synthesize `[agentId]`.
19
+ * - Both present: if `agentId` is not in `agents`, log a warn and let
20
+ * `agents` win.
21
+ * - Neither present → return `null` (discovery-eligible).
22
+ *
23
+ * De-duplicates while preserving order.
24
+ */
25
+ export function resolveConfiguredAgentIds(cfg) {
26
+ const agents = Array.isArray(cfg.agents) ? cfg.agents.filter((s) => typeof s === "string" && s.length > 0) : [];
27
+ const legacy = typeof cfg.agentId === "string" && cfg.agentId.length > 0 ? cfg.agentId : null;
28
+ if (agents.length > 0) {
29
+ if (legacy && !agents.includes(legacy)) {
30
+ // Conflicting shapes on disk; `agents` wins per spec. Logged via
31
+ // stderr so users running `config` or `start` can spot the drift —
32
+ // the `log` module isn't imported here to avoid the side-effect of
33
+ // opening the log file from config validation.
34
+ // eslint-disable-next-line no-console
35
+ console.warn(`daemon config: legacy agentId "${legacy}" not listed in agents [${agents.join(", ")}]; preferring agents`);
36
+ }
37
+ return dedupe(agents);
38
+ }
39
+ if (legacy)
40
+ return [legacy];
41
+ return null;
42
+ }
43
+ /**
44
+ * Legacy strict resolver — preserved for callers that still assume an
45
+ * explicit list on disk (e.g. internal tests, or codepaths that run
46
+ * before discovery). Throws when neither `agents` nor `agentId` is set.
47
+ */
48
+ export function resolveAgentIds(cfg) {
49
+ const configured = resolveConfiguredAgentIds(cfg);
50
+ if (configured)
51
+ return configured;
52
+ throw new Error(`daemon config missing agents (or legacy agentId) (${CONFIG_PATH})`);
53
+ }
54
+ function dedupe(xs) {
55
+ const seen = new Set();
56
+ const out = [];
57
+ for (const x of xs) {
58
+ if (seen.has(x))
59
+ continue;
60
+ seen.add(x);
61
+ out.push(x);
62
+ }
63
+ return out;
64
+ }
65
+ function ensureDir() {
66
+ try {
67
+ mkdirSync(DAEMON_DIR, { recursive: true, mode: 0o700 });
68
+ }
69
+ catch {
70
+ // best-effort
71
+ }
72
+ }
73
+ export function loadConfig() {
74
+ if (!existsSync(CONFIG_PATH)) {
75
+ throw new Error(`daemon config not found at ${CONFIG_PATH}. Run \`botcord-daemon init\` first.`);
76
+ }
77
+ const raw = readFileSync(CONFIG_PATH, "utf8");
78
+ const parsed = JSON.parse(raw);
79
+ const hasAgents = Array.isArray(parsed.agents) && parsed.agents.some((s) => typeof s === "string" && s.length > 0);
80
+ const hasLegacy = typeof parsed.agentId === "string" && parsed.agentId.length > 0;
81
+ const discovery = parsed.agentDiscovery;
82
+ const discoveryExplicitlyDisabled = !!discovery && typeof discovery === "object" && discovery.enabled === false;
83
+ if (!hasAgents && !hasLegacy && discoveryExplicitlyDisabled) {
84
+ throw new Error(`daemon config has no agents/agentId and agentDiscovery.enabled=false (${CONFIG_PATH})`);
85
+ }
86
+ if (hasAgents) {
87
+ for (const [i, a] of parsed.agents.entries()) {
88
+ if (typeof a !== "string" || a.length === 0) {
89
+ throw new Error(`daemon config agents[${i}] must be a non-empty string (${CONFIG_PATH})`);
90
+ }
91
+ }
92
+ }
93
+ if (!parsed.defaultRoute ||
94
+ typeof parsed.defaultRoute.adapter !== "string" ||
95
+ typeof parsed.defaultRoute.cwd !== "string") {
96
+ throw new Error(`daemon config missing defaultRoute.adapter/cwd (${CONFIG_PATH})`);
97
+ }
98
+ validateAdapter(parsed.defaultRoute.adapter, "defaultRoute.adapter");
99
+ const routesRaw = parsed.routes ?? [];
100
+ if (!Array.isArray(routesRaw)) {
101
+ throw new Error(`daemon config "routes" must be an array (${CONFIG_PATH})`);
102
+ }
103
+ for (const [i, r] of routesRaw.entries()) {
104
+ if (!r || typeof r !== "object") {
105
+ throw new Error(`daemon config routes[${i}] is not an object (${CONFIG_PATH})`);
106
+ }
107
+ if (typeof r.adapter !== "string" || typeof r.cwd !== "string") {
108
+ throw new Error(`daemon config routes[${i}] missing string adapter/cwd (${CONFIG_PATH})`);
109
+ }
110
+ validateAdapter(r.adapter, `routes[${i}].adapter`);
111
+ }
112
+ // Preserve the on-disk shape as-is so `config` prints what the user wrote.
113
+ // Resolution of agents vs agentId happens at the consumption boundary
114
+ // (`resolveAgentIds`, `toGatewayConfig`).
115
+ const out = {
116
+ defaultRoute: parsed.defaultRoute,
117
+ routes: routesRaw,
118
+ streamBlocks: parsed.streamBlocks ?? true,
119
+ };
120
+ if (hasAgents)
121
+ out.agents = parsed.agents.slice();
122
+ if (hasLegacy)
123
+ out.agentId = parsed.agentId;
124
+ if (discovery && typeof discovery === "object") {
125
+ const copy = {};
126
+ if (typeof discovery.enabled === "boolean")
127
+ copy.enabled = discovery.enabled;
128
+ if (typeof discovery.credentialsDir === "string" && discovery.credentialsDir.length > 0) {
129
+ copy.credentialsDir = discovery.credentialsDir;
130
+ }
131
+ out.agentDiscovery = copy;
132
+ }
133
+ return out;
134
+ }
135
+ function validateAdapter(id, field) {
136
+ const mod = getAdapterModule(id);
137
+ if (!mod) {
138
+ throw new Error(`unknown ${field} "${id}". Registered: ${listAdapterIds().join(", ")}`);
139
+ }
140
+ if (mod.supportsRun === false) {
141
+ throw new Error(`${field} "${id}" is a probe-only stub and cannot handle turns yet`);
142
+ }
143
+ }
144
+ export function saveConfig(cfg) {
145
+ ensureDir();
146
+ const tmp = CONFIG_PATH + ".tmp";
147
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2), { mode: 0o600 });
148
+ renameSync(tmp, CONFIG_PATH);
149
+ }
150
+ /**
151
+ * Build a default config. Always writes the new `agents: [...]` shape when
152
+ * explicit ids are provided; the legacy scalar `agentId` field is never
153
+ * emitted by fresh `init` runs. When called with an empty list, `agents`
154
+ * is omitted entirely so the daemon auto-discovers identities at boot.
155
+ */
156
+ export function initDefaultConfig(agentIds, cwd = homedir()) {
157
+ const list = Array.isArray(agentIds)
158
+ ? agentIds
159
+ : typeof agentIds === "string" && agentIds.length > 0
160
+ ? [agentIds]
161
+ : [];
162
+ const out = {
163
+ defaultRoute: { adapter: "claude-code", cwd },
164
+ routes: [],
165
+ streamBlocks: true,
166
+ };
167
+ if (list.length > 0)
168
+ out.agents = dedupe(list);
169
+ return out;
170
+ }
171
+ export const CONFIG_FILE_PATH = CONFIG_PATH;
172
+ export const DAEMON_DIR_PATH = DAEMON_DIR;
173
+ /**
174
+ * Make the daemon directory (`~/.botcord/daemon`) exist with mode `0700`.
175
+ * Tolerant of the common case where it already exists. Exported so
176
+ * user-auth/snapshot code can stay independent of `saveConfig`.
177
+ */
178
+ export function ensureDaemonDir() {
179
+ ensureDir();
180
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Daemon ↔ Hub control-plane WebSocket.
3
+ *
4
+ * One long-lived connection, carrying JSON {@link ControlFrame} messages.
5
+ * Independent from the agent data-plane WS: different auth (user access
6
+ * token vs agent JWT), different endpoint (`/daemon/ws`), different
7
+ * lifecycle (alive even when zero agents are bound).
8
+ *
9
+ * See `docs/daemon-control-plane-plan.md` §4.1, §4.3, §8.
10
+ */
11
+ import WebSocket from "ws";
12
+ import { type ControlAck, type ControlFrame } from "@botcord/protocol-core";
13
+ import { type UserAuthManager } from "./user-auth.js";
14
+ /**
15
+ * Build the canonical signing input for a control frame: RFC 8785 (JCS)
16
+ * canonicalization of `{id, type, params, ts}`. Per
17
+ * `docs/daemon-control-plane-api-contract.md` §3.3 — the Hub uses Python
18
+ * `jcs.canonicalize` over the same object before signing.
19
+ *
20
+ * Excludes `sig` by definition. `params` defaults to `{}` (empty object)
21
+ * to match the Hub-side default for paramless types like `ping`.
22
+ */
23
+ export declare function controlSigningInput(frame: {
24
+ id: string;
25
+ type: string;
26
+ ts?: number;
27
+ params?: unknown;
28
+ }): string;
29
+ /** Handler invoked for each inbound frame. Return value is the ack payload. */
30
+ export type ControlFrameHandler = (frame: ControlFrame) => Promise<Omit<ControlAck, "id"> | void> | Omit<ControlAck, "id"> | void;
31
+ /** Options accepted by {@link ControlChannel}. */
32
+ export interface ControlChannelOptions {
33
+ /** User-auth manager driving the access token. */
34
+ auth: UserAuthManager;
35
+ /** Dispatcher for inbound frames. Unknown types should return an error ack. */
36
+ handle: ControlFrameHandler;
37
+ /** Override the WS endpoint path; defaults to `/daemon/ws`. */
38
+ path?: string;
39
+ /**
40
+ * Optional human label sent to Hub on connect (`?label=...`). Hub uses it
41
+ * to populate `daemon_instances.label` for the dashboard listing. Plan §11.3.
42
+ */
43
+ label?: string;
44
+ /**
45
+ * Override the embedded Hub control-plane public key (raw 32-byte, base64).
46
+ * When omitted the channel falls back to {@link resolveHubControlPublicKey},
47
+ * which honors `BOTCORD_HUB_CONTROL_PUBLIC_KEY`.
48
+ */
49
+ hubPublicKey?: string | null;
50
+ /** Test hook — inject a WebSocket constructor. */
51
+ webSocketCtor?: typeof WebSocket;
52
+ /** Test hook — override the backoff schedule. */
53
+ backoffMs?: number[];
54
+ /** Test hook — override the keepalive interval. */
55
+ keepaliveIntervalMs?: number;
56
+ }
57
+ /**
58
+ * Long-lived, self-healing WS connection that carries control frames
59
+ * between the Hub and the local daemon. Owns reconnect/backoff and
60
+ * dedupe; delegates frame semantics to a caller-supplied handler.
61
+ */
62
+ export declare class ControlChannel {
63
+ private readonly auth;
64
+ private readonly handle;
65
+ private readonly path;
66
+ private readonly label;
67
+ private readonly hubPublicKey;
68
+ private readonly webSocketCtor;
69
+ private readonly backoff;
70
+ private readonly keepaliveMs;
71
+ private ws;
72
+ private stopRequested;
73
+ private reconnectAttempts;
74
+ private reconnectTimer;
75
+ private keepaliveTimer;
76
+ private readonly seenFrameIds;
77
+ private connectInflight;
78
+ private connected;
79
+ constructor(opts: ControlChannelOptions);
80
+ /** True once the initial WS handshake succeeded. Flipped back on close. */
81
+ get isConnected(): boolean;
82
+ /**
83
+ * Open the WS. Resolves after the first `open` event — transient
84
+ * reconnects after that run in the background until `stop()` is
85
+ * called. Throws immediately if no user-auth record is loaded.
86
+ */
87
+ start(): Promise<void>;
88
+ /** Close the WS and stop reconnecting. Idempotent. */
89
+ stop(): Promise<void>;
90
+ /** Actively send a frame (used for event reports like `agent_provisioned`). */
91
+ send(frame: ControlFrame): boolean;
92
+ private connect;
93
+ private startKeepalive;
94
+ private stopKeepalive;
95
+ private onClose;
96
+ private scheduleReconnect;
97
+ private onMessage;
98
+ private sendAck;
99
+ }