@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,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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
}
|