@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
package/dist/index.js ADDED
@@ -0,0 +1,854 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
4
+ import { homedir, hostname } from "node:os";
5
+ import path from "node:path";
6
+ import { loadConfig, saveConfig, initDefaultConfig, resolveConfiguredAgentIds, PID_PATH, SNAPSHOT_PATH, CONFIG_FILE_PATH, } from "./config.js";
7
+ import { resolveBootAgents } from "./agent-discovery.js";
8
+ import { startDaemon } from "./daemon.js";
9
+ import { log, LOG_FILE_PATH } from "./log.js";
10
+ import { detectRuntimes, getAdapterModule, listAdapterIds } from "./adapters/runtimes.js";
11
+ import { pollDeviceToken, requestDeviceCode, } from "@botcord/protocol-core";
12
+ import { AUTH_EXPIRED_FLAG_PATH, clearAuthExpiredFlag, loadUserAuth, saveUserAuth, userAuthFromTokenResponse, } from "./user-auth.js";
13
+ import { renderStatus } from "./status-render.js";
14
+ import { channelsFromDaemonConfig, defaultHttpFetcher, renderDoctor, runDoctor, } from "./doctor.js";
15
+ import { clearWorkingMemory, readWorkingMemory, resolveMemoryDir, updateWorkingMemory, DEFAULT_SECTION, } from "./working-memory.js";
16
+ const ADAPTER_LIST = listAdapterIds().join("|");
17
+ const DEFAULT_HUB = "https://api.botcord.chat";
18
+ /**
19
+ * Fallback label when the operator doesn't pass `--label` at login.
20
+ * macOS hostnames often carry a `.local` mDNS suffix that's just noise in
21
+ * the dashboard — strip it. A null/empty hostname falls back to "daemon".
22
+ */
23
+ function defaultLoginLabel() {
24
+ const raw = (hostname() || "").trim().replace(/\.local$/i, "");
25
+ return raw.length > 0 ? raw : "daemon";
26
+ }
27
+ const HELP = `botcord-daemon — BotCord local daemon
28
+
29
+ Usage: botcord-daemon <command> [options]
30
+
31
+ Commands:
32
+ init [--agent <ag_xxx> ...] [--cwd <path>]
33
+ Create ~/.botcord/daemon/config.json.
34
+ Without --agent, the daemon discovers
35
+ identities from ~/.botcord/credentials
36
+ at startup (repeat --agent to pin).
37
+ start [--foreground] [--relogin] [--hub <url>] [--label <name>]
38
+ Start the daemon. Without credentials
39
+ and on a TTY, runs the interactive
40
+ device-code login first. --hub defaults
41
+ to ${DEFAULT_HUB} (or the URL stored in
42
+ a previous login). --relogin forces
43
+ re-login. --label is sent to the Hub
44
+ on connect for the dashboard device
45
+ list (defaults to hostname). Non-TTY
46
+ environments must mount a pre-existing
47
+ user-auth.json (plan §6.4).
48
+ stop Stop the running daemon (SIGTERM)
49
+ status Print daemon status (pid, agent)
50
+ logs [-f] Print log tail (use -f to follow)
51
+ route add [match flags] --adapter <${ADAPTER_LIST}> --cwd <path>
52
+ match flags (first match wins; at least one conversation/sender selector required):
53
+ --conversation-id <rm_xxx> (alias: --room <rm_xxx>)
54
+ --conversation-prefix <rm_oc_> (alias: --prefix <rm_oc_>)
55
+ --conversation-kind <direct|group>
56
+ --channel <channel_type> (default: botcord)
57
+ --account-id <ag_xxx>
58
+ --sender-id <ag_xxx>
59
+ --mentioned / --no-mentioned
60
+ route list
61
+ route remove --room <rm_xxx>|--prefix <rm_xxx>
62
+ config Print resolved config
63
+ doctor [--json] Scan local runtimes (${ADAPTER_LIST})
64
+ memory get [--agent <ag_xxx>] [--json] Show current working memory
65
+ memory set [--agent <ag_xxx>] --goal <text>
66
+ Pin/update the agent's work goal
67
+ memory set [--agent <ag_xxx>] --section <name> --content <text>
68
+ Upsert a section (empty --content deletes it)
69
+ memory delete [--agent <ag_xxx>] --section <name>
70
+ Remove a section
71
+ memory clear [--agent <ag_xxx>] Wipe all working memory
72
+ (--agent required if the daemon runs
73
+ more than one; optional otherwise)
74
+
75
+ Env:
76
+ BOTCORD_<RUNTIME>_BIN Override CLI path per runtime (e.g. BOTCORD_CODEX_BIN)
77
+ BOTCORD_DAEMON_DEBUG Enable debug logging
78
+ `;
79
+ /** Known boolean flags — never consume the following token as a value. */
80
+ const BOOLEAN_FLAGS = new Set([
81
+ "foreground",
82
+ "f",
83
+ "follow",
84
+ "json",
85
+ "help",
86
+ "h",
87
+ "mentioned",
88
+ "relogin",
89
+ ]);
90
+ /** Flags that may be repeated on the command line; all values are collected. */
91
+ const LIST_FLAGS = new Set(["agent"]);
92
+ function parseArgs(argv) {
93
+ const [cmd, maybeSub, ...rest] = argv;
94
+ const flags = {};
95
+ const lists = {};
96
+ const positional = [];
97
+ let sub;
98
+ if (maybeSub && !maybeSub.startsWith("-")) {
99
+ sub = maybeSub;
100
+ }
101
+ else if (maybeSub) {
102
+ positional.unshift(maybeSub);
103
+ }
104
+ const args = [...positional, ...rest];
105
+ for (let i = 0; i < args.length; i++) {
106
+ const a = args[i];
107
+ if (!a.startsWith("--") && !a.startsWith("-"))
108
+ continue;
109
+ const key = a.replace(/^-+/, "");
110
+ // `--no-<bool>` → explicit false for a known boolean flag.
111
+ if (key.startsWith("no-")) {
112
+ const base = key.slice(3);
113
+ if (BOOLEAN_FLAGS.has(base)) {
114
+ flags[base] = false;
115
+ continue;
116
+ }
117
+ }
118
+ if (BOOLEAN_FLAGS.has(key)) {
119
+ flags[key] = true;
120
+ continue;
121
+ }
122
+ const next = args[i + 1];
123
+ if (next && !next.startsWith("-")) {
124
+ if (LIST_FLAGS.has(key)) {
125
+ (lists[key] ||= []).push(next);
126
+ }
127
+ flags[key] = next;
128
+ i++;
129
+ }
130
+ else {
131
+ flags[key] = true;
132
+ }
133
+ }
134
+ return { cmd: cmd ?? "", sub, flags, lists };
135
+ }
136
+ function readPid() {
137
+ if (!existsSync(PID_PATH))
138
+ return null;
139
+ const raw = readFileSync(PID_PATH, "utf8").trim();
140
+ const pid = Number(raw);
141
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
142
+ }
143
+ function pidAlive(pid) {
144
+ try {
145
+ process.kill(pid, 0);
146
+ return true;
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ }
152
+ async function cmdInit(args) {
153
+ // `--agent` is optional as of P1: when omitted, the daemon discovers
154
+ // agent identities from `~/.botcord/credentials/*.json` at startup.
155
+ // Every repeated `--agent ag_xxx` still pins an explicit id (the
156
+ // canonical `agents: [...]` config shape).
157
+ const agents = args.lists.agent ?? [];
158
+ const cwd = typeof args.flags.cwd === "string" ? path.resolve(args.flags.cwd) : homedir();
159
+ log.info("cmd init", { agents, cwd });
160
+ const cfg = initDefaultConfig(agents, cwd);
161
+ saveConfig(cfg);
162
+ console.log(`wrote ${CONFIG_FILE_PATH}`);
163
+ if (agents.length === 0) {
164
+ console.log("no --agent provided; daemon will auto-discover identities from ~/.botcord/credentials at start");
165
+ }
166
+ }
167
+ /**
168
+ * Read the current user-auth record without throwing on parse / permission
169
+ * errors — those are returned as `null` so the caller treats them like a
170
+ * missing file (and the device-code flow re-runs).
171
+ */
172
+ function safeLoadUserAuth() {
173
+ try {
174
+ return loadUserAuth();
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
180
+ /** Sleep helper used by the device-code poll loop. */
181
+ function delay(ms) {
182
+ return new Promise((r) => setTimeout(r, ms));
183
+ }
184
+ /**
185
+ * Run the device-code login flow against the given Hub. Polls every
186
+ * `interval` seconds (the Hub may bump this) until the user authorizes
187
+ * from the dashboard, the device_code expires, or SIGINT is received.
188
+ * Persists the token envelope to `user-auth.json` and returns the record.
189
+ *
190
+ * Plan §6.1.
191
+ */
192
+ async function runDeviceCodeFlow(opts) {
193
+ log.info("device-code flow: requesting code", {
194
+ hubUrl: opts.hubUrl,
195
+ label: opts.label ?? null,
196
+ });
197
+ const dc = await requestDeviceCode(opts.hubUrl, opts.label ? { label: opts.label } : undefined);
198
+ const display = dc.verificationUriComplete ?? dc.verificationUri;
199
+ console.log("");
200
+ console.log(`Visit ${display}`);
201
+ console.log(`Code: ${dc.userCode}`);
202
+ console.log("Waiting for authorization (Ctrl-C to abort)...");
203
+ const expiresAt = Date.now() + dc.expiresIn * 1000;
204
+ let intervalSec = dc.interval;
205
+ while (Date.now() < expiresAt) {
206
+ await delay(intervalSec * 1000);
207
+ let res;
208
+ try {
209
+ res = await pollDeviceToken(opts.hubUrl, dc.deviceCode, opts.label ? { label: opts.label } : undefined);
210
+ }
211
+ catch (err) {
212
+ // Network blips shouldn't kill the loop — surface, then retry on
213
+ // the next tick. A persistent failure still ends at expiry.
214
+ console.error(`device-code poll error: ${err instanceof Error ? err.message : String(err)}`);
215
+ continue;
216
+ }
217
+ if (res.status === "pending")
218
+ continue;
219
+ if (res.status === "slow_down") {
220
+ intervalSec = Math.max(intervalSec, res.interval);
221
+ continue;
222
+ }
223
+ // Issued — persist and return.
224
+ const record = userAuthFromTokenResponse(res, opts.label ? { label: opts.label } : undefined);
225
+ saveUserAuth(record);
226
+ clearAuthExpiredFlag();
227
+ log.info("device-code flow: authorized", {
228
+ userId: record.userId,
229
+ hubUrl: record.hubUrl,
230
+ label: opts.label ?? null,
231
+ });
232
+ console.log(`Logged in as ${record.userId}`);
233
+ return record;
234
+ }
235
+ log.warn("device-code flow: expired without authorization", {
236
+ hubUrl: opts.hubUrl,
237
+ });
238
+ throw new Error("device-code expired without authorization");
239
+ }
240
+ /**
241
+ * Resolve / acquire a valid user-auth record before the daemon process
242
+ * forks. Returns `null` when the daemon should proceed without a control
243
+ * plane (legacy P0 behavior — caller may still log a warning).
244
+ *
245
+ * Decision tree (plan §4.4 + §6.4):
246
+ * 1. `--relogin` → device-code login.
247
+ * 2. Have valid creds (not near expiry) → return existing record.
248
+ * 3. Have stale creds → leave as-is; the control channel will refresh.
249
+ * 4. No creds + TTY → device-code login.
250
+ * 5. No creds + no TTY → exit 1 with the §6.4 hint.
251
+ */
252
+ async function ensureUserAuthForStart(args) {
253
+ const hubFlag = typeof args.flags.hub === "string" ? args.flags.hub : undefined;
254
+ const labelFlag = typeof args.flags.label === "string" ? args.flags.label : undefined;
255
+ const relogin = args.flags.relogin === true;
256
+ const existing = safeLoadUserAuth();
257
+ if (!relogin && existing) {
258
+ // A previously-set auth-expired flag is stale by definition once the
259
+ // operator runs `start` again — if creds genuinely don't work, the
260
+ // control channel will re-write the flag on the next 4401/4403.
261
+ // Clearing here keeps `status` from indefinitely warning about a
262
+ // recovery the daemon already made.
263
+ clearAuthExpiredFlag();
264
+ // Idempotent restart: if creds already exist, keep the stored label as
265
+ // the source of truth — operators wanting to rename must go through
266
+ // `--relogin`. Stale access tokens will be refreshed by
267
+ // UserAuthManager on first WS connect; nothing else to do here.
268
+ if (labelFlag && existing.label !== labelFlag) {
269
+ console.error(`note: --label "${labelFlag}" ignored (already logged in as "${existing.label ?? "<unset>"}"); pass --relogin to change it`);
270
+ }
271
+ return existing;
272
+ }
273
+ // Need a fresh login. Resolve hubUrl: explicit --hub > existing record > DEFAULT_HUB.
274
+ const hubUrl = hubFlag ?? existing?.hubUrl ?? DEFAULT_HUB;
275
+ if (!process.stdin.isTTY) {
276
+ // Plan §6.4 — non-interactive environment. Fail fast with actionable
277
+ // remediation; never block waiting for input that will never arrive.
278
+ console.error("error: not logged in and no TTY available");
279
+ console.error("hint: run `botcord-daemon start` once interactively to establish credentials,");
280
+ console.error(" or mount a valid `~/.botcord/daemon/user-auth.json`");
281
+ process.exit(1);
282
+ }
283
+ const label = labelFlag ?? defaultLoginLabel();
284
+ return runDeviceCodeFlow({ hubUrl, label });
285
+ }
286
+ async function cmdStart(args) {
287
+ const cfg = loadConfig();
288
+ const foreground = args.flags.foreground === true;
289
+ log.info("cmd start", {
290
+ foreground,
291
+ relogin: args.flags.relogin === true,
292
+ child: process.env.BOTCORD_DAEMON_CHILD === "1",
293
+ });
294
+ const existing = readPid();
295
+ if (existing && pidAlive(existing)) {
296
+ console.error(`daemon already running (pid ${existing})`);
297
+ process.exit(1);
298
+ }
299
+ // Login MUST happen before fork — once detached, stdio is gone and the
300
+ // user can't see the device code. We also run it for explicit
301
+ // --foreground so an interactive user can log in without the fork dance.
302
+ // The auto-spawned child (foreground re-exec) carries the marker env
303
+ // var so we don't try to re-prompt for credentials it already has.
304
+ if (process.env.BOTCORD_DAEMON_CHILD !== "1") {
305
+ await ensureUserAuthForStart(args);
306
+ }
307
+ if (!foreground) {
308
+ // Detached child re-exec in foreground mode. The child writes the PID
309
+ // file once it's up; the parent only polls to confirm startup so the
310
+ // two never race on the same file.
311
+ const child = spawn(process.execPath, [process.argv[1], "start", "--foreground"], {
312
+ detached: true,
313
+ stdio: "ignore",
314
+ env: { ...process.env, BOTCORD_DAEMON_CHILD: "1" },
315
+ });
316
+ child.unref();
317
+ const deadline = Date.now() + 500;
318
+ let observed = null;
319
+ while (Date.now() < deadline) {
320
+ const p = readPid();
321
+ if (p && pidAlive(p)) {
322
+ observed = p;
323
+ break;
324
+ }
325
+ await new Promise((r) => setTimeout(r, 50));
326
+ }
327
+ if (!observed) {
328
+ console.error(`daemon did not record pid within 500ms (expected child pid ${child.pid})`);
329
+ process.exit(1);
330
+ }
331
+ console.log(`daemon started (pid ${observed})`);
332
+ return;
333
+ }
334
+ // Foreground: we ARE the daemon.
335
+ writeFileSync(PID_PATH, String(process.pid), { mode: 0o600 });
336
+ const handle = await startDaemon({ config: cfg, configPath: CONFIG_FILE_PATH });
337
+ const shutdown = async (sig) => {
338
+ log.info("signal received", { sig });
339
+ await handle.stop(sig);
340
+ try {
341
+ unlinkSync(PID_PATH);
342
+ }
343
+ catch {
344
+ // ignore
345
+ }
346
+ process.exit(0);
347
+ };
348
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
349
+ process.on("SIGINT", () => shutdown("SIGINT"));
350
+ // Gateway.start() resolves after channels are started. Keep the process
351
+ // alive until a signal arrives; the channel manager owns its own loops.
352
+ await new Promise(() => {
353
+ // Deliberately never resolves; `shutdown()` calls process.exit(0).
354
+ });
355
+ }
356
+ async function cmdStop() {
357
+ const pid = readPid();
358
+ log.info("cmd stop", { pid });
359
+ if (!pid) {
360
+ console.error("no pid file found");
361
+ process.exit(1);
362
+ }
363
+ if (!pidAlive(pid)) {
364
+ console.error(`pid ${pid} not alive; removing stale pid file`);
365
+ try {
366
+ unlinkSync(PID_PATH);
367
+ }
368
+ catch {
369
+ // ignore
370
+ }
371
+ process.exit(1);
372
+ }
373
+ process.kill(pid, "SIGTERM");
374
+ console.log(`sent SIGTERM to ${pid}`);
375
+ }
376
+ function readSnapshotFile() {
377
+ if (!existsSync(SNAPSHOT_PATH))
378
+ return null;
379
+ try {
380
+ const raw = readFileSync(SNAPSHOT_PATH, "utf8");
381
+ const parsed = JSON.parse(raw);
382
+ if (parsed &&
383
+ typeof parsed === "object" &&
384
+ parsed.version === 1 &&
385
+ typeof parsed.writtenAt === "number" &&
386
+ parsed.snapshot) {
387
+ return parsed;
388
+ }
389
+ return null;
390
+ }
391
+ catch {
392
+ return null;
393
+ }
394
+ }
395
+ async function cmdStatus(args) {
396
+ const pid = readPid();
397
+ const alive = pid ? pidAlive(pid) : false;
398
+ let agents = [];
399
+ let agentsSource = null;
400
+ let configPath = null;
401
+ try {
402
+ const cfg = loadConfig();
403
+ const boot = resolveBootAgents(cfg);
404
+ agents = boot.agents.map((a) => a.agentId);
405
+ agentsSource = boot.source;
406
+ configPath = CONFIG_FILE_PATH;
407
+ }
408
+ catch {
409
+ // config may not exist pre-init — that's fine
410
+ }
411
+ let userAuth = null;
412
+ try {
413
+ userAuth = loadUserAuth();
414
+ }
415
+ catch {
416
+ // a broken user-auth shouldn't fail status; leave as null
417
+ }
418
+ const authExpired = existsSync(AUTH_EXPIRED_FLAG_PATH);
419
+ const file = readSnapshotFile();
420
+ const now = Date.now();
421
+ const snapshotAgeMs = file ? now - file.writtenAt : null;
422
+ if (args.flags.json === true) {
423
+ const payload = {
424
+ pid,
425
+ alive,
426
+ agents,
427
+ agentsSource,
428
+ // Preserve the legacy scalar field in JSON output when exactly one
429
+ // agent is bound, so consumers pinned to `agentId` keep working.
430
+ agentId: agents.length === 1 ? agents[0] : null,
431
+ config: configPath,
432
+ userAuth: userAuth
433
+ ? {
434
+ userId: userAuth.userId,
435
+ daemonInstanceId: userAuth.daemonInstanceId,
436
+ hubUrl: userAuth.hubUrl,
437
+ expiresAt: userAuth.expiresAt,
438
+ label: userAuth.label ?? null,
439
+ }
440
+ : null,
441
+ authExpired,
442
+ snapshot: file?.snapshot ?? null,
443
+ snapshotWrittenAt: file?.writtenAt ?? null,
444
+ snapshotAgeMs,
445
+ snapshotPath: SNAPSHOT_PATH,
446
+ };
447
+ console.log(JSON.stringify(payload, null, 2));
448
+ return;
449
+ }
450
+ const input = {
451
+ pid,
452
+ alive,
453
+ agents,
454
+ agentsSource,
455
+ configPath,
456
+ snapshot: file?.snapshot ?? null,
457
+ snapshotAgeMs,
458
+ };
459
+ console.log(renderStatus(input, now));
460
+ if (userAuth) {
461
+ console.log(`logged in as ${userAuth.userId}${userAuth.label ? ` (${userAuth.label})` : ""}`);
462
+ }
463
+ else {
464
+ console.log("not logged in (control plane disabled)");
465
+ }
466
+ if (authExpired) {
467
+ console.log("⚠ credentials revoked — run `botcord-daemon start --relogin` to re-authorize");
468
+ }
469
+ }
470
+ async function cmdLogs(args) {
471
+ const follow = args.flags.f === true || args.flags.follow === true;
472
+ if (!existsSync(LOG_FILE_PATH)) {
473
+ console.error(`no log file at ${LOG_FILE_PATH}`);
474
+ process.exit(1);
475
+ }
476
+ if (follow) {
477
+ // `tail -f` is simpler and more robust than watching fs events ourselves.
478
+ const child = spawn("tail", ["-n", "100", "-f", LOG_FILE_PATH], { stdio: "inherit" });
479
+ process.on("SIGINT", () => child.kill("SIGINT"));
480
+ await new Promise((resolve) => child.on("close", resolve));
481
+ return;
482
+ }
483
+ const data = readFileSync(LOG_FILE_PATH, "utf8");
484
+ const lines = data.split("\n");
485
+ console.log(lines.slice(-100).join("\n"));
486
+ }
487
+ function formatRouteMatch(m) {
488
+ const parts = [];
489
+ if (m.channel)
490
+ parts.push(`channel=${m.channel}`);
491
+ if (m.accountId)
492
+ parts.push(`accountId=${m.accountId}`);
493
+ const convId = m.conversationId ?? m.roomId;
494
+ if (convId)
495
+ parts.push(`conversationId=${convId}`);
496
+ const convPrefix = m.conversationPrefix ?? m.roomPrefix;
497
+ if (convPrefix)
498
+ parts.push(`conversationPrefix=${convPrefix}`);
499
+ if (m.conversationKind)
500
+ parts.push(`conversationKind=${m.conversationKind}`);
501
+ if (m.senderId)
502
+ parts.push(`senderId=${m.senderId}`);
503
+ if (typeof m.mentioned === "boolean")
504
+ parts.push(`mentioned=${m.mentioned}`);
505
+ return parts.length > 0 ? parts.join(", ") : "(any)";
506
+ }
507
+ async function cmdRoute(args) {
508
+ const cfg = loadConfig();
509
+ const sub = args.sub;
510
+ if (sub === "list") {
511
+ if (args.flags.json === true) {
512
+ console.log(JSON.stringify({ default: cfg.defaultRoute, routes: cfg.routes }, null, 2));
513
+ return;
514
+ }
515
+ const d = cfg.defaultRoute;
516
+ console.log(`default: runtime=${d.adapter} cwd=${d.cwd}${d.extraArgs?.length ? ` extraArgs=${JSON.stringify(d.extraArgs)}` : ""}`);
517
+ if (cfg.routes.length === 0) {
518
+ console.log("routes: (none)");
519
+ return;
520
+ }
521
+ console.log("routes:");
522
+ cfg.routes.forEach((r, i) => {
523
+ const tail = r.extraArgs?.length ? ` extraArgs=${JSON.stringify(r.extraArgs)}` : "";
524
+ console.log(` [${i}] runtime=${r.adapter} cwd=${r.cwd}${tail}`);
525
+ console.log(` match: ${formatRouteMatch(r.match)}`);
526
+ });
527
+ return;
528
+ }
529
+ if (sub === "add") {
530
+ // Legacy aliases: --room → --conversation-id, --prefix → --conversation-prefix.
531
+ // Prefer the canonical field if both are provided.
532
+ const roomFlag = typeof args.flags.room === "string" ? args.flags.room : undefined;
533
+ const convIdFlag = typeof args.flags["conversation-id"] === "string"
534
+ ? args.flags["conversation-id"]
535
+ : undefined;
536
+ const conversationId = convIdFlag ?? roomFlag;
537
+ const prefixFlag = typeof args.flags.prefix === "string" ? args.flags.prefix : undefined;
538
+ const convPrefixFlag = typeof args.flags["conversation-prefix"] === "string"
539
+ ? args.flags["conversation-prefix"]
540
+ : undefined;
541
+ const conversationPrefix = convPrefixFlag ?? prefixFlag;
542
+ const channel = typeof args.flags.channel === "string" ? args.flags.channel : undefined;
543
+ const accountId = typeof args.flags["account-id"] === "string"
544
+ ? args.flags["account-id"]
545
+ : undefined;
546
+ const senderId = typeof args.flags["sender-id"] === "string"
547
+ ? args.flags["sender-id"]
548
+ : undefined;
549
+ const kindRaw = typeof args.flags["conversation-kind"] === "string"
550
+ ? args.flags["conversation-kind"]
551
+ : undefined;
552
+ if (kindRaw !== undefined && kindRaw !== "direct" && kindRaw !== "group") {
553
+ console.error(`invalid --conversation-kind "${kindRaw}" (must be "direct" or "group")`);
554
+ process.exit(1);
555
+ }
556
+ const conversationKind = kindRaw;
557
+ const mentioned = typeof args.flags.mentioned === "boolean"
558
+ ? args.flags.mentioned
559
+ : undefined;
560
+ const adapter = (typeof args.flags.adapter === "string" ? args.flags.adapter : "claude-code");
561
+ const cwd = typeof args.flags.cwd === "string" ? path.resolve(args.flags.cwd) : "";
562
+ const hasAnyMatch = !!conversationId ||
563
+ !!conversationPrefix ||
564
+ !!channel ||
565
+ !!accountId ||
566
+ !!senderId ||
567
+ !!conversationKind ||
568
+ mentioned !== undefined;
569
+ if (!hasAnyMatch) {
570
+ console.error("at least one match flag required (--conversation-id/--room, --conversation-prefix/--prefix, --channel, --account-id, --sender-id, --conversation-kind, --mentioned)");
571
+ process.exit(1);
572
+ }
573
+ if (!cwd) {
574
+ console.error("--cwd required");
575
+ process.exit(1);
576
+ }
577
+ if (!getAdapterModule(adapter)) {
578
+ console.error(`unknown --adapter "${adapter}". Registered: ${ADAPTER_LIST}`);
579
+ process.exit(1);
580
+ }
581
+ // Persist the canonical fields (conversationId/conversationPrefix) even
582
+ // when the user passed the legacy aliases, to avoid config drift.
583
+ const match = {};
584
+ if (channel)
585
+ match.channel = channel;
586
+ if (accountId)
587
+ match.accountId = accountId;
588
+ if (conversationId)
589
+ match.conversationId = conversationId;
590
+ if (conversationPrefix)
591
+ match.conversationPrefix = conversationPrefix;
592
+ if (conversationKind)
593
+ match.conversationKind = conversationKind;
594
+ if (senderId)
595
+ match.senderId = senderId;
596
+ if (mentioned !== undefined)
597
+ match.mentioned = mentioned;
598
+ cfg.routes.push({ match, adapter, cwd });
599
+ saveConfig(cfg);
600
+ console.log("route added");
601
+ return;
602
+ }
603
+ if (sub === "remove") {
604
+ const roomFlag = typeof args.flags.room === "string" ? args.flags.room : undefined;
605
+ const convIdFlag = typeof args.flags["conversation-id"] === "string"
606
+ ? args.flags["conversation-id"]
607
+ : undefined;
608
+ const roomId = convIdFlag ?? roomFlag;
609
+ const prefixFlag = typeof args.flags.prefix === "string" ? args.flags.prefix : undefined;
610
+ const convPrefixFlag = typeof args.flags["conversation-prefix"] === "string"
611
+ ? args.flags["conversation-prefix"]
612
+ : undefined;
613
+ const prefix = convPrefixFlag ?? prefixFlag;
614
+ const before = cfg.routes.length;
615
+ cfg.routes = cfg.routes.filter((r) => {
616
+ const rConv = r.match.conversationId ?? r.match.roomId;
617
+ const rPrefix = r.match.conversationPrefix ?? r.match.roomPrefix;
618
+ if (roomId && rConv === roomId)
619
+ return false;
620
+ if (prefix && rPrefix === prefix)
621
+ return false;
622
+ return true;
623
+ });
624
+ saveConfig(cfg);
625
+ console.log(`removed ${before - cfg.routes.length} route(s)`);
626
+ return;
627
+ }
628
+ console.error(HELP);
629
+ process.exit(1);
630
+ }
631
+ async function cmdConfig() {
632
+ const cfg = loadConfig();
633
+ // Surface the effective boot-agent list alongside the raw on-disk config
634
+ // so operators running `config` can tell which identities the daemon will
635
+ // actually bind — explicit list vs discovered credentials.
636
+ const explicit = resolveConfiguredAgentIds(cfg);
637
+ let boot = null;
638
+ try {
639
+ boot = resolveBootAgents(cfg);
640
+ }
641
+ catch {
642
+ boot = null;
643
+ }
644
+ const payload = {
645
+ config: cfg,
646
+ effective: boot
647
+ ? {
648
+ agents: boot.agents.map((a) => ({
649
+ agentId: a.agentId,
650
+ credentialsFile: a.credentialsFile,
651
+ ...(a.displayName ? { displayName: a.displayName } : {}),
652
+ })),
653
+ source: explicit ? "config" : "credentials",
654
+ credentialsDir: boot.credentialsDir,
655
+ warnings: boot.warnings,
656
+ }
657
+ : null,
658
+ };
659
+ console.log(JSON.stringify(payload, null, 2));
660
+ }
661
+ /**
662
+ * Select which agent a `memory` subcommand targets.
663
+ *
664
+ * - `--agent <ag_xxx>` explicitly chooses an agent; it must be one listed
665
+ * in the resolved config.
666
+ * - If the daemon is bound to exactly one agent, `--agent` is optional and
667
+ * defaults to that agent.
668
+ * - If multiple agents are configured and no `--agent` is passed, we bail
669
+ * with an explicit message listing the options — too easy to footgun a
670
+ * memory write against the wrong agent otherwise.
671
+ */
672
+ function resolveMemoryTargetAgent(args, cfg) {
673
+ const boot = resolveBootAgents(cfg);
674
+ const agents = boot.agents.map((a) => a.agentId);
675
+ if (agents.length === 0) {
676
+ console.error("memory: no agents configured or discovered (add `--agent` to `init` or drop a credentials JSON in the discovery dir)");
677
+ process.exit(1);
678
+ }
679
+ const flagAgent = typeof args.flags.agent === "string" ? args.flags.agent : undefined;
680
+ if (flagAgent) {
681
+ if (!agents.includes(flagAgent)) {
682
+ console.error(`--agent "${flagAgent}" is not configured. Configured agents: ${agents.join(", ")}`);
683
+ process.exit(1);
684
+ }
685
+ return flagAgent;
686
+ }
687
+ if (agents.length === 1)
688
+ return agents[0];
689
+ console.error(`memory: --agent <ag_xxx> is required when the daemon is bound to multiple agents. Configured: ${agents.join(", ")}`);
690
+ process.exit(1);
691
+ }
692
+ async function cmdMemory(args) {
693
+ const cfg = loadConfig();
694
+ const agentId = resolveMemoryTargetAgent(args, cfg);
695
+ const sub = args.sub;
696
+ if (!sub || sub === "get") {
697
+ const memory = readWorkingMemory(agentId);
698
+ if (args.flags.json === true) {
699
+ console.log(JSON.stringify({ agentId, memory, dir: resolveMemoryDir(agentId) }, null, 2));
700
+ return;
701
+ }
702
+ if (!memory) {
703
+ console.log(`(empty — no working memory for ${agentId})`);
704
+ console.log(`path: ${resolveMemoryDir(agentId)}/working-memory.json`);
705
+ return;
706
+ }
707
+ if (memory.goal)
708
+ console.log(`goal: ${memory.goal}`);
709
+ const entries = Object.entries(memory.sections);
710
+ if (entries.length === 0) {
711
+ console.log("(no sections)");
712
+ }
713
+ else {
714
+ for (const [name, content] of entries) {
715
+ console.log(`\n[section: ${name}]`);
716
+ console.log(content);
717
+ }
718
+ }
719
+ console.log(`\nupdatedAt: ${memory.updatedAt}`);
720
+ return;
721
+ }
722
+ if (sub === "set") {
723
+ const goal = typeof args.flags.goal === "string" ? args.flags.goal : undefined;
724
+ const section = typeof args.flags.section === "string" ? args.flags.section : undefined;
725
+ const content = typeof args.flags.content === "string" ? args.flags.content : undefined;
726
+ if (goal === undefined && content === undefined) {
727
+ console.error("memory set: provide --goal or --content");
728
+ process.exit(1);
729
+ }
730
+ try {
731
+ const res = updateWorkingMemory(agentId, { goal, section, content });
732
+ const status = { ok: true, totalChars: res.totalChars };
733
+ if (goal !== undefined)
734
+ status.goal = goal === "" ? null : goal;
735
+ if (content !== undefined) {
736
+ status.section = section ?? DEFAULT_SECTION;
737
+ status.sectionPresent = res.sectionPresent;
738
+ }
739
+ console.log(JSON.stringify(status, null, 2));
740
+ }
741
+ catch (err) {
742
+ console.error(err instanceof Error ? err.message : String(err));
743
+ process.exit(1);
744
+ }
745
+ return;
746
+ }
747
+ if (sub === "delete") {
748
+ const section = typeof args.flags.section === "string" ? args.flags.section : undefined;
749
+ if (!section) {
750
+ console.error("memory delete: --section required");
751
+ process.exit(1);
752
+ }
753
+ try {
754
+ updateWorkingMemory(agentId, { section, content: "" });
755
+ console.log(`section "${section}" removed`);
756
+ }
757
+ catch (err) {
758
+ console.error(err instanceof Error ? err.message : String(err));
759
+ process.exit(1);
760
+ }
761
+ return;
762
+ }
763
+ if (sub === "clear") {
764
+ clearWorkingMemory(agentId);
765
+ console.log(`cleared working memory for ${agentId}`);
766
+ return;
767
+ }
768
+ console.error(HELP);
769
+ process.exit(1);
770
+ }
771
+ const fsFileReader = {
772
+ readFile(p) {
773
+ if (!existsSync(p))
774
+ return null;
775
+ try {
776
+ return readFileSync(p, "utf8");
777
+ }
778
+ catch {
779
+ return null;
780
+ }
781
+ },
782
+ };
783
+ async function cmdDoctor(args) {
784
+ const entries = detectRuntimes();
785
+ // Doctor should not hard-fail when no config exists yet; channel probes
786
+ // simply produce an empty list in that case.
787
+ let channels = [];
788
+ try {
789
+ const cfg = loadConfig();
790
+ channels = channelsFromDaemonConfig(cfg);
791
+ }
792
+ catch {
793
+ channels = [];
794
+ }
795
+ const credentialsPath = (accountId) => path.join(homedir(), ".botcord", "credentials", `${accountId}.json`);
796
+ const input = await runDoctor(entries, channels, {
797
+ credentialsPath,
798
+ fileReader: fsFileReader,
799
+ fetcher: defaultHttpFetcher,
800
+ timeoutMs: 5_000,
801
+ });
802
+ if (args.flags.json === true) {
803
+ console.log(JSON.stringify(input, null, 2));
804
+ return;
805
+ }
806
+ console.log(renderDoctor(input));
807
+ }
808
+ async function main() {
809
+ const args = parseArgs(process.argv.slice(2));
810
+ if (!args.cmd || args.flags.help === true || args.flags.h === true) {
811
+ console.log(HELP);
812
+ process.exit(args.cmd ? 0 : 1);
813
+ }
814
+ try {
815
+ switch (args.cmd) {
816
+ case "init":
817
+ await cmdInit(args);
818
+ break;
819
+ case "start":
820
+ await cmdStart(args);
821
+ break;
822
+ case "stop":
823
+ await cmdStop();
824
+ break;
825
+ case "status":
826
+ await cmdStatus(args);
827
+ break;
828
+ case "logs":
829
+ await cmdLogs(args);
830
+ break;
831
+ case "route":
832
+ await cmdRoute(args);
833
+ break;
834
+ case "config":
835
+ await cmdConfig();
836
+ break;
837
+ case "doctor":
838
+ await cmdDoctor(args);
839
+ break;
840
+ case "memory":
841
+ await cmdMemory(args);
842
+ break;
843
+ default:
844
+ console.error(`unknown command: ${args.cmd}`);
845
+ console.error(HELP);
846
+ process.exit(1);
847
+ }
848
+ }
849
+ catch (err) {
850
+ console.error(err instanceof Error ? err.message : String(err));
851
+ process.exit(1);
852
+ }
853
+ }
854
+ main();