@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.
- package/dist/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- 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
|
+
}
|