@owloops/browserbird 1.0.2 → 1.0.3

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 (56) hide show
  1. package/bin/browserbird +7 -1
  2. package/dist/db-BsYEYsul.mjs +1011 -0
  3. package/dist/index.mjs +4748 -0
  4. package/package.json +6 -3
  5. package/src/channel/blocks.ts +0 -485
  6. package/src/channel/coalesce.ts +0 -79
  7. package/src/channel/commands.ts +0 -216
  8. package/src/channel/handler.ts +0 -272
  9. package/src/channel/slack.ts +0 -573
  10. package/src/channel/types.ts +0 -59
  11. package/src/cli/banner.ts +0 -10
  12. package/src/cli/birds.ts +0 -396
  13. package/src/cli/config.ts +0 -77
  14. package/src/cli/doctor.ts +0 -63
  15. package/src/cli/index.ts +0 -5
  16. package/src/cli/jobs.ts +0 -166
  17. package/src/cli/logs.ts +0 -67
  18. package/src/cli/run.ts +0 -148
  19. package/src/cli/sessions.ts +0 -158
  20. package/src/cli/style.ts +0 -19
  21. package/src/config.ts +0 -291
  22. package/src/core/logger.ts +0 -78
  23. package/src/core/redact.ts +0 -75
  24. package/src/core/types.ts +0 -83
  25. package/src/core/uid.ts +0 -26
  26. package/src/core/utils.ts +0 -137
  27. package/src/cron/parse.ts +0 -146
  28. package/src/cron/scheduler.ts +0 -242
  29. package/src/daemon.ts +0 -169
  30. package/src/db/auth.ts +0 -49
  31. package/src/db/birds.ts +0 -357
  32. package/src/db/core.ts +0 -377
  33. package/src/db/index.ts +0 -10
  34. package/src/db/jobs.ts +0 -289
  35. package/src/db/logs.ts +0 -64
  36. package/src/db/messages.ts +0 -79
  37. package/src/db/path.ts +0 -30
  38. package/src/db/sessions.ts +0 -165
  39. package/src/jobs.ts +0 -140
  40. package/src/provider/claude.test.ts +0 -95
  41. package/src/provider/claude.ts +0 -196
  42. package/src/provider/opencode.test.ts +0 -169
  43. package/src/provider/opencode.ts +0 -248
  44. package/src/provider/session.ts +0 -65
  45. package/src/provider/spawn.ts +0 -173
  46. package/src/provider/stream.ts +0 -67
  47. package/src/provider/types.ts +0 -24
  48. package/src/server/auth.ts +0 -135
  49. package/src/server/health.ts +0 -87
  50. package/src/server/http.ts +0 -132
  51. package/src/server/index.ts +0 -6
  52. package/src/server/lifecycle.ts +0 -135
  53. package/src/server/routes.ts +0 -1199
  54. package/src/server/sse.ts +0 -54
  55. package/src/server/static.ts +0 -45
  56. package/src/server/vnc-proxy.ts +0 -75
package/dist/index.mjs ADDED
@@ -0,0 +1,4748 @@
1
+ import { $ as updateSessionProviderId, A as completeCronRun, B as listFlights, C as failStaleJobs, D as retryAllFailedJobs, E as listJobs, F as ensureSystemCronJob, G as deleteStaleSessions, H as updateCronJob, I as getCronJob, J as getSessionCount, K as findSession, L as getEnabledCronJobs, M as createCronRun, N as deleteCronJob, O as retryJob, P as deleteOldCronRuns, Q as touchSession, R as getFlightStats, S as failJob, T as hasPendingCronJob, U as updateCronJobStatus, V as setCronJobEnabled, W as createSession, X as getSessionTokenStats, Y as getSessionMessages, Z as listSessions, _ as clearJobs, a as getSetting, at as logger, b as deleteJob, c as getUserCount, d as getRecentLogs, et as shortUid, f as insertLog, g as claimNextJob, h as logMessage, i as createUser, it as resolveByUid, j as createCronJob, k as SYSTEM_CRON_PREFIX, l as setSetting, m as getMessageStats, n as resolveDbPath, nt as openDatabase, o as getUserByEmail, p as deleteOldMessages, q as getSession, r as resolveDbPathFromArgv, rt as optimizeDatabase, s as getUserById, tt as closeDatabase, u as deleteOldLogs, v as completeJob, w as getJobStats, x as deleteOldJobs, y as createJob, z as listCronJobs } from "./db-BsYEYsul.mjs";
2
+ import { createRequire } from "node:module";
3
+ import { parseArgs, styleText } from "node:util";
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
+ import { dirname, extname, join, resolve } from "node:path";
6
+ import { createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
7
+ import { connect } from "node:net";
8
+ import { execFileSync, spawn } from "node:child_process";
9
+ import { createServer } from "node:http";
10
+ import { fileURLToPath } from "node:url";
11
+ import { SocketModeClient } from "@slack/socket-mode";
12
+ import { LogLevel, WebClient } from "@slack/web-api";
13
+
14
+ //#region src/core/types.ts
15
+ const COMMANDS = {
16
+ SESSIONS: "sessions",
17
+ BIRDS: "birds",
18
+ CONFIG: "config",
19
+ LOGS: "logs",
20
+ JOBS: "jobs",
21
+ DOCTOR: "doctor"
22
+ };
23
+
24
+ //#endregion
25
+ //#region src/core/utils.ts
26
+ /** @fileoverview Shared utilities: formatting, time ranges, and CLI table output. */
27
+ const BIRD_NAME_MAX_LENGTH = 50;
28
+ function formatDuration(ms) {
29
+ const totalSeconds = Math.round(ms / 1e3);
30
+ const minutes = Math.floor(totalSeconds / 60);
31
+ const seconds = totalSeconds % 60;
32
+ if (minutes === 0) return `${seconds}s`;
33
+ return `${minutes}m ${seconds}s`;
34
+ }
35
+ function deriveBirdName(prompt) {
36
+ return prompt.trim().slice(0, BIRD_NAME_MAX_LENGTH);
37
+ }
38
+ /**
39
+ * Returns true if the current time in the given timezone falls within a HH:MM time range.
40
+ * Handles wrap-around ranges (e.g. 22:00-06:00 spanning midnight).
41
+ */
42
+ function isWithinTimeRange(start, end, date, timezone) {
43
+ const parts = new Intl.DateTimeFormat("en-US", {
44
+ timeZone: timezone,
45
+ hour: "numeric",
46
+ minute: "numeric",
47
+ hour12: false
48
+ }).formatToParts(date);
49
+ const h = Number(parts.find((p) => p.type === "hour")?.value ?? 0);
50
+ const m = Number(parts.find((p) => p.type === "minute")?.value ?? 0);
51
+ const nowMinutes = h * 60 + m;
52
+ const startMinutes = parseHHMM(start);
53
+ const endMinutes = parseHHMM(end);
54
+ if (startMinutes <= endMinutes) return nowMinutes >= startMinutes && nowMinutes < endMinutes;
55
+ return nowMinutes >= startMinutes || nowMinutes < endMinutes;
56
+ }
57
+ function parseHHMM(s) {
58
+ const [hh, mm] = s.split(":");
59
+ const h = Number(hh);
60
+ const m = Number(mm ?? 0);
61
+ if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
62
+ return h * 60 + m;
63
+ }
64
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
65
+ function visibleLength(s) {
66
+ return s.replace(ANSI_RE, "").length;
67
+ }
68
+ /** Print a formatted table with auto-calculated column widths to stdout. */
69
+ function printTable(headers, rows, maxWidths) {
70
+ const widths = headers.map((h) => h.length);
71
+ for (const row of rows) for (let i = 0; i < row.length; i++) {
72
+ const cellLen = visibleLength(row[i] ?? "");
73
+ const maxW = maxWidths?.[i];
74
+ const capped = maxW != null ? Math.min(cellLen, maxW) : cellLen;
75
+ widths[i] = Math.max(widths[i] ?? 0, capped);
76
+ }
77
+ function pad(s, width, max) {
78
+ const vis = visibleLength(s);
79
+ const truncated = max != null && vis > max ? s.slice(0, max - 3) + "..." : s;
80
+ const padLen = width - visibleLength(truncated);
81
+ return padLen > 0 ? truncated + " ".repeat(padLen) : truncated;
82
+ }
83
+ const indent = " ";
84
+ console.log(indent + headers.map((h, i) => pad(h, widths[i])).join(" "));
85
+ console.log(indent + widths.map((w) => "-".repeat(w)).join(" "));
86
+ for (const row of rows) console.log(indent + row.map((cell, i) => pad(cell ?? "", widths[i], maxWidths?.[i])).join(" "));
87
+ }
88
+ function levenshtein(a, b) {
89
+ const m = a.length;
90
+ const n = b.length;
91
+ const dp = Array.from({ length: m + 1 }, () => Array.from({ length: n + 1 }, () => 0));
92
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
93
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
94
+ for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) {
95
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
96
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
97
+ }
98
+ return dp[m][n];
99
+ }
100
+ function unknownSubcommand(subcommand, command, validCommands) {
101
+ const label = command ? "subcommand" : "command";
102
+ process.stderr.write(`error: unknown ${label}: ${subcommand}\n`);
103
+ if (validCommands && validCommands.length > 0) {
104
+ let bestMatch = "";
105
+ let bestDist = Infinity;
106
+ for (const cmd of validCommands) {
107
+ const dist = levenshtein(subcommand, cmd);
108
+ if (dist < bestDist) {
109
+ bestDist = dist;
110
+ bestMatch = cmd;
111
+ }
112
+ }
113
+ if (bestDist <= 2 && bestMatch) process.stderr.write(`did you mean '${bestMatch}'?\n`);
114
+ }
115
+ const helpCmd = command ? `browserbird ${command} --help` : "browserbird --help";
116
+ process.stderr.write(`run '${helpCmd}' for usage\n`);
117
+ process.exitCode = 1;
118
+ }
119
+
120
+ //#endregion
121
+ //#region src/cli/banner.ts
122
+ /** @fileoverview ASCII banner displayed on daemon startup and in help text. */
123
+ const pkg = createRequire(import.meta.url)("../package.json");
124
+ const buildInfo = [];
125
+ buildInfo.push(`commit: ${"8508da3ffdb3230ea117b27463cc293726074f06".substring(0, 7)}`);
126
+ const buildString = buildInfo.length > 0 ? ` (${buildInfo.join(", ")})` : "";
127
+ const VERSION = `browserbird ${pkg.version}${buildString}`;
128
+ const BIRD = [
129
+ " .__.",
130
+ " ( ^>",
131
+ " / )\\",
132
+ " <_/_/",
133
+ " \" \""
134
+ ].join("\n");
135
+ const BANNER = BIRD;
136
+
137
+ //#endregion
138
+ //#region src/config.ts
139
+ /** @fileoverview Configuration loading from JSON with env: variable resolution. */
140
+ const VALID_PROVIDERS$1 = new Set(["claude", "opencode"]);
141
+ const DEFAULTS = {
142
+ timezone: "UTC",
143
+ slack: {
144
+ botToken: "",
145
+ appToken: "",
146
+ requireMention: true,
147
+ coalesce: {
148
+ debounceMs: 3e3,
149
+ bypassDms: true
150
+ },
151
+ channels: ["*"],
152
+ quietHours: {
153
+ enabled: false,
154
+ start: "23:00",
155
+ end: "08:00",
156
+ timezone: "UTC"
157
+ }
158
+ },
159
+ agents: [{
160
+ id: "default",
161
+ name: "BrowserBird",
162
+ provider: "claude",
163
+ model: "sonnet",
164
+ maxTurns: 50,
165
+ systemPrompt: "You are responding in a Slack workspace. Be concise, helpful, and natural.",
166
+ channels: ["*"]
167
+ }],
168
+ sessions: {
169
+ ttlHours: 24,
170
+ maxConcurrent: 5,
171
+ processTimeoutMs: 3e5
172
+ },
173
+ database: { retentionDays: 30 },
174
+ browser: {
175
+ enabled: false,
176
+ mcpConfigPath: void 0,
177
+ vncPort: 5900,
178
+ novncPort: 6080,
179
+ novncHost: "localhost"
180
+ },
181
+ birds: { maxAttempts: 3 },
182
+ web: {
183
+ enabled: true,
184
+ host: "127.0.0.1",
185
+ port: 18800,
186
+ corsOrigin: ""
187
+ }
188
+ };
189
+ /**
190
+ * Resolves `"env:VAR_NAME"` strings to their environment variable values.
191
+ * Throws if the env var is not set.
192
+ */
193
+ function resolveEnvValue(value) {
194
+ if (typeof value === "string" && value.startsWith("env:")) {
195
+ const envKey = value.slice(4);
196
+ const envValue = process.env[envKey];
197
+ if (envValue == null) throw new Error(`Environment variable ${envKey} is not set (referenced as "${value}")`);
198
+ return envValue;
199
+ }
200
+ if (Array.isArray(value)) return value.map(resolveEnvValue);
201
+ if (value !== null && typeof value === "object") return resolveEnvValues(value);
202
+ return value;
203
+ }
204
+ function resolveEnvValues(obj) {
205
+ const resolved = {};
206
+ for (const [key, value] of Object.entries(obj)) resolved[key] = resolveEnvValue(value);
207
+ return resolved;
208
+ }
209
+ function deepMerge(target, source) {
210
+ const result = { ...target };
211
+ for (const [key, value] of Object.entries(source)) {
212
+ const targetValue = target[key];
213
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && targetValue !== null && typeof targetValue === "object" && !Array.isArray(targetValue)) result[key] = deepMerge(targetValue, value);
214
+ else result[key] = value;
215
+ }
216
+ return result;
217
+ }
218
+ /**
219
+ * Loads configuration from a JSON file, merges with defaults, and resolves env: references.
220
+ * Searches for browserbird.json in the current directory, then falls back to defaults.
221
+ */
222
+ function loadConfig(configPath) {
223
+ const filePath = configPath ?? resolve("browserbird.json");
224
+ if (!existsSync(filePath)) {
225
+ logger.warn(`no config file found at ${filePath}, using defaults`);
226
+ return DEFAULTS;
227
+ }
228
+ logger.info(`loading config from ${filePath}`);
229
+ const raw = readFileSync(filePath, "utf-8");
230
+ let parsed;
231
+ try {
232
+ parsed = JSON.parse(raw);
233
+ } catch {
234
+ throw new Error(`Failed to parse config file: ${filePath}`);
235
+ }
236
+ const config = resolveEnvValues(deepMerge(DEFAULTS, parsed));
237
+ validateConfig(config);
238
+ return config;
239
+ }
240
+ /** Validates merged config and throws on invalid values, warns on risky combinations. */
241
+ function validateConfig(config) {
242
+ if (!Array.isArray(config.agents) || config.agents.length === 0) throw new Error("at least one agent must be configured");
243
+ for (const agent of config.agents) {
244
+ if (!agent.id || !agent.name) throw new Error("each agent must have an \"id\" and \"name\"");
245
+ if (!VALID_PROVIDERS$1.has(agent.provider)) throw new Error(`agent "${agent.id}": unknown provider "${agent.provider}" (expected: ${[...VALID_PROVIDERS$1].join(", ")})`);
246
+ if (!agent.model) throw new Error(`agent "${agent.id}": "model" is required`);
247
+ if (!Array.isArray(agent.channels) || agent.channels.length === 0) throw new Error(`agent "${agent.id}": "channels" must be a non-empty array`);
248
+ if (agent.fallbackModel && agent.fallbackModel === agent.model) throw new Error(`agent "${agent.id}": fallbackModel cannot be the same as model ("${agent.model}")`);
249
+ }
250
+ const browserMode = process.env["BROWSER_MODE"] ?? "persistent";
251
+ if (config.browser.enabled && browserMode === "persistent" && config.sessions.maxConcurrent > 1) logger.warn("persistent browser mode with maxConcurrent > 1 will cause lock contention; use \"isolated\" or set maxConcurrent to 1");
252
+ }
253
+ /**
254
+ * Reads and merges JSON config with DEFAULTS but skips env: resolution.
255
+ * Returns raw config data suitable for reading/modifying before writing back.
256
+ */
257
+ function loadRawConfig(configPath) {
258
+ const filePath = configPath ?? resolve("browserbird.json");
259
+ if (!existsSync(filePath)) return JSON.parse(JSON.stringify(DEFAULTS));
260
+ const raw = readFileSync(filePath, "utf-8");
261
+ let parsed;
262
+ try {
263
+ parsed = JSON.parse(raw);
264
+ } catch {
265
+ throw new Error(`Failed to parse config file: ${filePath}`);
266
+ }
267
+ return deepMerge(DEFAULTS, parsed);
268
+ }
269
+ /**
270
+ * Checks whether both Slack tokens are present and resolvable.
271
+ * Literal strings must be non-empty; `"env:VAR"` references must point to a set env var.
272
+ */
273
+ function hasSlackTokens(configPath) {
274
+ const filePath = configPath ?? resolve("browserbird.json");
275
+ if (!existsSync(filePath)) return false;
276
+ let parsed;
277
+ try {
278
+ parsed = JSON.parse(readFileSync(filePath, "utf-8"));
279
+ } catch {
280
+ return false;
281
+ }
282
+ const slack = parsed["slack"];
283
+ if (!slack) return false;
284
+ return isTokenResolvable(slack["botToken"]) && isTokenResolvable(slack["appToken"]);
285
+ }
286
+ function isTokenResolvable(value) {
287
+ if (typeof value !== "string" || !value) return false;
288
+ if (value.startsWith("env:")) {
289
+ const envKey = value.slice(4);
290
+ return !!process.env[envKey];
291
+ }
292
+ return true;
293
+ }
294
+ /** Atomic write: writes to a .tmp file then renames over the target. */
295
+ function saveConfig(configPath, data) {
296
+ const tmp = configPath + ".tmp";
297
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
298
+ renameSync(tmp, configPath);
299
+ }
300
+ /**
301
+ * Reads an existing .env file, updates/appends entries, and writes atomically.
302
+ * Preserves comments, blank lines, and ordering of existing entries.
303
+ */
304
+ function saveEnvFile(envPath, vars) {
305
+ const remaining = new Map(Object.entries(vars));
306
+ const lines = [];
307
+ if (existsSync(envPath)) {
308
+ const content = readFileSync(envPath, "utf-8");
309
+ for (const line of content.split("\n")) {
310
+ const trimmed = line.trim();
311
+ if (!trimmed || trimmed.startsWith("#")) {
312
+ lines.push(line);
313
+ continue;
314
+ }
315
+ const eqIdx = trimmed.indexOf("=");
316
+ if (eqIdx === -1) {
317
+ lines.push(line);
318
+ continue;
319
+ }
320
+ const key = trimmed.slice(0, eqIdx).trim();
321
+ if (remaining.has(key)) {
322
+ lines.push(`${key}=${remaining.get(key)}`);
323
+ remaining.delete(key);
324
+ } else lines.push(line);
325
+ }
326
+ }
327
+ for (const [key, value] of remaining) lines.push(`${key}=${value}`);
328
+ const finalContent = lines.join("\n").replace(/\n{3,}/g, "\n\n");
329
+ const tmp = envPath + ".tmp";
330
+ writeFileSync(tmp, finalContent.endsWith("\n") ? finalContent : finalContent + "\n", "utf-8");
331
+ renameSync(tmp, envPath);
332
+ }
333
+ /**
334
+ * Reads a .env file and injects entries into process.env.
335
+ * Handles comments, blank lines, and optionally quoted values.
336
+ */
337
+ function loadDotEnv(envPath) {
338
+ if (!existsSync(envPath)) return;
339
+ const content = readFileSync(envPath, "utf-8");
340
+ for (const line of content.split("\n")) {
341
+ const trimmed = line.trim();
342
+ if (!trimmed || trimmed.startsWith("#")) continue;
343
+ const eqIdx = trimmed.indexOf("=");
344
+ if (eqIdx === -1) continue;
345
+ const key = trimmed.slice(0, eqIdx).trim();
346
+ let value = trimmed.slice(eqIdx + 1).trim();
347
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
348
+ process.env[key] = value;
349
+ }
350
+ }
351
+
352
+ //#endregion
353
+ //#region src/server/auth.ts
354
+ /** @fileoverview Password hashing, token signing, and verification using node:crypto. */
355
+ const SCRYPT_N = 16384;
356
+ const SCRYPT_R = 8;
357
+ const SCRYPT_P = 1;
358
+ const SCRYPT_KEYLEN = 64;
359
+ const SALT_BYTES = 16;
360
+ const TOKEN_EXPIRY_MS = 10080 * 60 * 1e3;
361
+ function hashPassword(password) {
362
+ return new Promise((resolve, reject) => {
363
+ const salt = randomBytes(SALT_BYTES);
364
+ scrypt(password, salt, SCRYPT_KEYLEN, {
365
+ N: SCRYPT_N,
366
+ r: SCRYPT_R,
367
+ p: SCRYPT_P
368
+ }, (err, key) => {
369
+ if (err) {
370
+ reject(err);
371
+ return;
372
+ }
373
+ resolve(`scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${salt.toString("hex")}$${key.toString("hex")}`);
374
+ });
375
+ });
376
+ }
377
+ function verifyPassword(password, stored) {
378
+ return new Promise((resolve, reject) => {
379
+ const parts = stored.split("$");
380
+ if (parts.length !== 6 || parts[0] !== "scrypt") {
381
+ resolve(false);
382
+ return;
383
+ }
384
+ const n = Number(parts[1]);
385
+ const r = Number(parts[2]);
386
+ const p = Number(parts[3]);
387
+ const salt = Buffer.from(parts[4], "hex");
388
+ const expected = Buffer.from(parts[5], "hex");
389
+ scrypt(password, salt, expected.length, {
390
+ N: n,
391
+ r,
392
+ p
393
+ }, (err, key) => {
394
+ if (err) {
395
+ reject(err);
396
+ return;
397
+ }
398
+ resolve(timingSafeEqual(key, expected));
399
+ });
400
+ });
401
+ }
402
+ function generateTokenKey() {
403
+ return randomBytes(32).toString("hex");
404
+ }
405
+ function getOrCreateSecret() {
406
+ const existing = getSetting("auth_secret");
407
+ if (existing) return existing;
408
+ const secret = randomBytes(32).toString("hex");
409
+ setSetting("auth_secret", secret);
410
+ return secret;
411
+ }
412
+ function base64UrlEncode(data) {
413
+ return Buffer.from(data).toString("base64url");
414
+ }
415
+ function base64UrlDecode(encoded) {
416
+ return Buffer.from(encoded, "base64url").toString();
417
+ }
418
+ function hmacSign(data, key) {
419
+ return createHmac("sha256", key).update(data).digest("base64url");
420
+ }
421
+ function signToken(userId, tokenKey, secret) {
422
+ const now = Date.now();
423
+ const header = base64UrlEncode(JSON.stringify({
424
+ alg: "HS256",
425
+ typ: "JWT"
426
+ }));
427
+ const payload = base64UrlEncode(JSON.stringify({
428
+ sub: userId,
429
+ exp: now + TOKEN_EXPIRY_MS,
430
+ iat: now,
431
+ jti: randomBytes(16).toString("hex")
432
+ }));
433
+ const signingKey = tokenKey + secret;
434
+ return `${header}.${payload}.${hmacSign(`${header}.${payload}`, signingKey)}`;
435
+ }
436
+ /**
437
+ * Verifies a raw token string: structure, signature, expiry, and that the
438
+ * user still exists in the DB. Returns true if valid, false otherwise.
439
+ */
440
+ function verifyToken(token) {
441
+ const parts = token.split(".");
442
+ if (parts.length !== 3) return false;
443
+ let sub;
444
+ try {
445
+ const decoded = JSON.parse(base64UrlDecode(parts[1]));
446
+ if (typeof decoded.sub !== "number") return false;
447
+ sub = decoded.sub;
448
+ } catch {
449
+ return false;
450
+ }
451
+ const user = getUserById(sub);
452
+ if (!user) return false;
453
+ const secret = getOrCreateSecret();
454
+ const [header, payload, signature] = parts;
455
+ const signingKey = user.token_key + secret;
456
+ const expected = hmacSign(`${header}.${payload}`, signingKey);
457
+ const sigBuf = Buffer.from(signature, "base64url");
458
+ const expBuf = Buffer.from(expected, "base64url");
459
+ if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) return false;
460
+ try {
461
+ const decoded = JSON.parse(base64UrlDecode(payload));
462
+ if (typeof decoded.exp !== "number" || decoded.exp < Date.now()) return false;
463
+ return true;
464
+ } catch {
465
+ return false;
466
+ }
467
+ }
468
+
469
+ //#endregion
470
+ //#region src/server/http.ts
471
+ const MIME_TYPES = {
472
+ ".html": "text/html; charset=utf-8",
473
+ ".css": "text/css; charset=utf-8",
474
+ ".js": "application/javascript; charset=utf-8",
475
+ ".json": "application/json; charset=utf-8",
476
+ ".svg": "image/svg+xml",
477
+ ".png": "image/png",
478
+ ".ico": "image/x-icon",
479
+ ".woff2": "font/woff2"
480
+ };
481
+ function pathToRegex(path) {
482
+ const pattern = path.replace(/:(\w+)/g, "(?<$1>[^/]+)");
483
+ return new RegExp(`^${pattern}$`);
484
+ }
485
+ function json(res, data, status = 200) {
486
+ const body = JSON.stringify(data);
487
+ res.writeHead(status, {
488
+ "Content-Type": "application/json; charset=utf-8",
489
+ "Content-Length": Buffer.byteLength(body)
490
+ });
491
+ res.end(body);
492
+ }
493
+ function jsonError(res, message, status) {
494
+ json(res, { error: message }, status);
495
+ }
496
+ function parsePagination(url) {
497
+ return {
498
+ page: Math.max(Number(url.searchParams.get("page")) || 1, 1),
499
+ perPage: Math.max(Number(url.searchParams.get("perPage")) || 20, 1)
500
+ };
501
+ }
502
+ function parseSystemFlag(url) {
503
+ return url.searchParams.get("system") === "true";
504
+ }
505
+ function parseSortParam(url) {
506
+ return url.searchParams.get("sort") ?? void 0;
507
+ }
508
+ function parseSearchParam(url) {
509
+ return url.searchParams.get("search") ?? void 0;
510
+ }
511
+ const MAX_BODY_BYTES = 1024 * 1024;
512
+ async function readJsonBody(req) {
513
+ return new Promise((resolve, reject) => {
514
+ const chunks = [];
515
+ let totalBytes = 0;
516
+ req.on("data", (chunk) => {
517
+ totalBytes += chunk.length;
518
+ if (totalBytes > MAX_BODY_BYTES) {
519
+ req.destroy();
520
+ reject(/* @__PURE__ */ new Error("Request body too large"));
521
+ return;
522
+ }
523
+ chunks.push(chunk);
524
+ });
525
+ req.on("end", () => {
526
+ try {
527
+ resolve(JSON.parse(Buffer.concat(chunks).toString()));
528
+ } catch {
529
+ reject(/* @__PURE__ */ new Error("Invalid JSON body"));
530
+ }
531
+ });
532
+ req.on("error", reject);
533
+ });
534
+ }
535
+ function extractToken(req, allowQueryToken) {
536
+ const authHeader = req.headers["authorization"] ?? "";
537
+ if (authHeader.startsWith("Bearer ")) return authHeader.slice(7);
538
+ if (allowQueryToken) return new URL(req.url ?? "/", `http://${req.headers.host}`).searchParams.get("token");
539
+ return null;
540
+ }
541
+ /**
542
+ * Verifies the request is authenticated. In setup mode (no users in DB),
543
+ * all requests are allowed. Otherwise, a valid signed token is required.
544
+ */
545
+ function checkAuth(req, res, allowQueryToken = false) {
546
+ if (getUserCount() === 0) return true;
547
+ const token = extractToken(req, allowQueryToken);
548
+ if (!token || !verifyToken(token)) {
549
+ jsonError(res, "Unauthorized", 401);
550
+ return false;
551
+ }
552
+ return true;
553
+ }
554
+
555
+ //#endregion
556
+ //#region src/cli/style.ts
557
+ /** @fileoverview CLI color utilities. Safe palette per terminal theme compatibility. */
558
+ function shouldUseColor() {
559
+ if (process.env["NO_COLOR"] !== void 0) return false;
560
+ if (process.env["TERM"] === "dumb") return false;
561
+ if (process.argv.includes("--no-color")) return false;
562
+ return process.stdout.isTTY === true;
563
+ }
564
+ const useColor = shouldUseColor();
565
+ function c(format, text) {
566
+ if (!useColor) return text;
567
+ return styleText(format, text);
568
+ }
569
+
570
+ //#endregion
571
+ //#region src/cli/doctor.ts
572
+ /** @fileoverview Doctor command: checks system dependencies. */
573
+ const DOCTOR_HELP = `
574
+ ${c("cyan", "usage:")} browserbird doctor
575
+
576
+ check system dependencies (agent clis, node.js).
577
+ `.trim();
578
+ function checkCli(binary, versionArgs) {
579
+ try {
580
+ return {
581
+ available: true,
582
+ version: (execFileSync(binary, versionArgs, {
583
+ timeout: 5e3,
584
+ encoding: "utf-8",
585
+ stdio: [
586
+ "ignore",
587
+ "pipe",
588
+ "ignore"
589
+ ]
590
+ }).trim().split("\n")[0] ?? "").replace(/\s*\(.*\)$/, "") || null
591
+ };
592
+ } catch {
593
+ return {
594
+ available: false,
595
+ version: null
596
+ };
597
+ }
598
+ }
599
+ function checkDoctor() {
600
+ return {
601
+ claude: checkCli("claude", ["--version"]),
602
+ opencode: checkCli("opencode", ["--version"]),
603
+ node: process.version
604
+ };
605
+ }
606
+ function handleDoctor() {
607
+ const result = checkDoctor();
608
+ console.log(c("cyan", "doctor"));
609
+ console.log(c("dim", "------"));
610
+ if (result.claude.available) logger.success(`claude cli: ${result.claude.version}`);
611
+ else {
612
+ logger.error("claude cli: not found");
613
+ process.stderr.write(" install: npm install -g @anthropic-ai/claude-code\n");
614
+ }
615
+ if (result.opencode.available) logger.success(`opencode cli: ${result.opencode.version}`);
616
+ else {
617
+ logger.warn("opencode cli: not found (optional)");
618
+ process.stderr.write(" install: npm install -g opencode\n");
619
+ }
620
+ logger.success(`node.js: ${result.node}`);
621
+ }
622
+
623
+ //#endregion
624
+ //#region src/server/health.ts
625
+ /** @fileoverview Cached service health checks for agent CLI and browser connectivity. */
626
+ const AGENT_CHECK_INTERVAL_MS = 6e4;
627
+ const BROWSER_CHECK_INTERVAL_MS = 5e3;
628
+ const BROWSER_PROBE_TIMEOUT_MS = 2e3;
629
+ let agentAvailable = false;
630
+ let agentCheckedAt = 0;
631
+ let browserConnected = false;
632
+ let browserCheckPending = false;
633
+ function refreshAgent(config) {
634
+ const now = Date.now();
635
+ if (now - agentCheckedAt < AGENT_CHECK_INTERVAL_MS) return;
636
+ const result = checkDoctor();
637
+ agentAvailable = [...new Set(config.agents.map((a) => a.provider))].some((p) => result[p]?.available === true);
638
+ agentCheckedAt = now;
639
+ }
640
+ function probeBrowser(host, port) {
641
+ return new Promise((resolve) => {
642
+ const socket = connect(port, host, () => {
643
+ socket.destroy();
644
+ resolve(true);
645
+ });
646
+ socket.setTimeout(BROWSER_PROBE_TIMEOUT_MS);
647
+ socket.on("timeout", () => {
648
+ socket.destroy();
649
+ resolve(false);
650
+ });
651
+ socket.on("error", () => {
652
+ socket.destroy();
653
+ resolve(false);
654
+ });
655
+ });
656
+ }
657
+ function refreshBrowser(config) {
658
+ if (!config.browser.enabled || browserCheckPending) return;
659
+ browserCheckPending = true;
660
+ probeBrowser(config.browser.novncHost, config.browser.novncPort).then((ok) => {
661
+ browserConnected = ok;
662
+ }).catch(() => {
663
+ browserConnected = false;
664
+ }).finally(() => {
665
+ browserCheckPending = false;
666
+ });
667
+ }
668
+ function getServiceHealth(config) {
669
+ refreshAgent(config);
670
+ return {
671
+ agent: { available: agentAvailable },
672
+ browser: { connected: config.browser.enabled ? browserConnected : false }
673
+ };
674
+ }
675
+ function startHealthChecks(config, signal) {
676
+ refreshAgent(config);
677
+ refreshBrowser(config);
678
+ const timer = setInterval(() => {
679
+ refreshBrowser(config);
680
+ }, BROWSER_CHECK_INTERVAL_MS);
681
+ signal.addEventListener("abort", () => {
682
+ clearInterval(timer);
683
+ });
684
+ logger.debug("health checks started");
685
+ }
686
+
687
+ //#endregion
688
+ //#region src/cron/parse.ts
689
+ /** @fileoverview Cron expression parser. Standard 5-field syntax + common macros. */
690
+ const MACROS = {
691
+ "@yearly": "0 0 1 1 *",
692
+ "@annually": "0 0 1 1 *",
693
+ "@monthly": "0 0 1 * *",
694
+ "@weekly": "0 0 * * 0",
695
+ "@daily": "0 0 * * *",
696
+ "@midnight": "0 0 * * *",
697
+ "@hourly": "0 * * * *"
698
+ };
699
+ /**
700
+ * Parses a single cron field (e.g. `"*"`, `"1,5,10"`, `"9-17"`, `"* /15"`).
701
+ * Returns a Set of integer values that match.
702
+ */
703
+ function parseField(field, min, max) {
704
+ const values = /* @__PURE__ */ new Set();
705
+ for (const part of field.split(",")) {
706
+ const stepParts = part.split("/");
707
+ const range = stepParts[0];
708
+ const step = stepParts[1] != null ? parseInt(stepParts[1], 10) : 1;
709
+ let start;
710
+ let end;
711
+ if (range === "*") {
712
+ start = min;
713
+ end = max;
714
+ } else if (range.includes("-")) {
715
+ const [lo, hi] = range.split("-");
716
+ start = parseInt(lo, 10);
717
+ end = parseInt(hi, 10);
718
+ } else {
719
+ start = parseInt(range, 10);
720
+ end = start;
721
+ }
722
+ for (let i = start; i <= end; i += step) values.add(i);
723
+ }
724
+ return values;
725
+ }
726
+ /** Parses a cron expression string into a CronSchedule. */
727
+ function parseCron(expression) {
728
+ const fields = (MACROS[expression.toLowerCase()] ?? expression).trim().split(/\s+/);
729
+ if (fields.length !== 5) throw new Error(`invalid cron expression: expected 5 fields, got ${fields.length}`);
730
+ return {
731
+ minutes: parseField(fields[0], 0, 59),
732
+ hours: parseField(fields[1], 0, 23),
733
+ daysOfMonth: parseField(fields[2], 1, 31),
734
+ months: parseField(fields[3], 1, 12),
735
+ daysOfWeek: parseField(fields[4], 0, 6)
736
+ };
737
+ }
738
+ /**
739
+ * Returns true if the current time falls within the active hours window.
740
+ * When both start and end are null, the bird is always active.
741
+ * Hours are evaluated in the bird's configured timezone.
742
+ */
743
+ function isWithinActiveHours(start, end, date, timezone) {
744
+ if (start == null && end == null) return true;
745
+ return isWithinTimeRange(start ?? "00:00", end ?? "24:00", date, timezone || "UTC");
746
+ }
747
+ /**
748
+ * Returns the next Date (after `after`) that matches the cron schedule,
749
+ * or null if no match is found within the search window (default: 366 days).
750
+ */
751
+ function nextCronMatch(schedule, after, timezone, maxMinutes = 527040) {
752
+ const candidate = new Date(after.getTime());
753
+ candidate.setSeconds(0, 0);
754
+ candidate.setTime(candidate.getTime() + 6e4);
755
+ for (let i = 0; i < maxMinutes; i++) {
756
+ if (matchesCron(schedule, candidate, timezone)) return candidate;
757
+ candidate.setTime(candidate.getTime() + 6e4);
758
+ }
759
+ return null;
760
+ }
761
+ /** Returns true if the given Date matches the cron schedule in the specified timezone. */
762
+ function matchesCron(schedule, date, timezone) {
763
+ const tz = timezone || "UTC";
764
+ const parts = new Intl.DateTimeFormat("en-US", {
765
+ timeZone: tz,
766
+ hour: "numeric",
767
+ minute: "numeric",
768
+ day: "numeric",
769
+ month: "numeric",
770
+ weekday: "short",
771
+ hour12: false
772
+ }).formatToParts(date);
773
+ const get = (type) => Number(parts.find((p) => p.type === type)?.value ?? 0);
774
+ const weekdayStr = parts.find((p) => p.type === "weekday")?.value ?? "";
775
+ return schedule.minutes.has(get("minute")) && schedule.hours.has(get("hour")) && schedule.daysOfMonth.has(get("day")) && schedule.months.has(get("month")) && schedule.daysOfWeek.has({
776
+ Sun: 0,
777
+ Mon: 1,
778
+ Tue: 2,
779
+ Wed: 3,
780
+ Thu: 4,
781
+ Fri: 5,
782
+ Sat: 6
783
+ }[weekdayStr] ?? 0);
784
+ }
785
+
786
+ //#endregion
787
+ //#region src/server/routes.ts
788
+ function buildStatusPayload(getConfig, startedAt, getDeps) {
789
+ const config = getConfig();
790
+ const deps = getDeps();
791
+ const jobs = getJobStats();
792
+ const flights = getFlightStats();
793
+ const messages = getMessageStats();
794
+ const health = deps.serviceHealth();
795
+ return {
796
+ uptime: Date.now() - startedAt,
797
+ processes: {
798
+ active: deps.activeProcessCount(),
799
+ maxConcurrent: config.sessions.maxConcurrent
800
+ },
801
+ jobs,
802
+ flights,
803
+ messages,
804
+ sessions: { total: getSessionCount() },
805
+ web: {
806
+ enabled: config.web.enabled,
807
+ port: config.web.port
808
+ },
809
+ agent: health.agent,
810
+ browser: {
811
+ enabled: config.browser.enabled,
812
+ connected: health.browser.connected
813
+ },
814
+ slack: { connected: deps.slackConnected() }
815
+ };
816
+ }
817
+ function resolveBirdParam(params, res) {
818
+ const uid = params["id"];
819
+ if (!uid) {
820
+ jsonError(res, "Missing bird ID", 400);
821
+ return null;
822
+ }
823
+ const result = resolveByUid("cron_jobs", uid);
824
+ if (!result) {
825
+ jsonError(res, `Bird ${uid} not found`, 404);
826
+ return null;
827
+ }
828
+ if ("ambiguous" in result) {
829
+ jsonError(res, `Ambiguous bird ID "${uid}" matches ${result.count} birds. Use a longer prefix.`, 400);
830
+ return null;
831
+ }
832
+ return result.row;
833
+ }
834
+ const VALID_PROVIDERS = new Set(["claude", "opencode"]);
835
+ function maskSecret(value) {
836
+ if (!value) return {
837
+ set: false,
838
+ hint: ""
839
+ };
840
+ const prefix = [
841
+ "xoxb-",
842
+ "xapp-",
843
+ "sk-ant-api",
844
+ "sk-ant-oat"
845
+ ].find((p) => value.startsWith(p)) ?? "";
846
+ const tail = value.length > 4 ? value.slice(-4) : "";
847
+ return {
848
+ set: true,
849
+ hint: prefix ? `${prefix}...${tail}` : `...${tail}`
850
+ };
851
+ }
852
+ const HH_MM_RE = /^\d{2}:\d{2}$/;
853
+ const ALLOWED_TOP_LEVEL_KEYS = new Set([
854
+ "timezone",
855
+ "agents",
856
+ "sessions",
857
+ "slack",
858
+ "birds",
859
+ "browser",
860
+ "database"
861
+ ]);
862
+ function sanitizeConfig(config) {
863
+ return {
864
+ timezone: config.timezone,
865
+ agents: config.agents.map((a) => ({
866
+ id: a.id,
867
+ name: a.name,
868
+ provider: a.provider,
869
+ model: a.model,
870
+ fallbackModel: a.fallbackModel ?? null,
871
+ maxTurns: a.maxTurns,
872
+ systemPrompt: a.systemPrompt,
873
+ channels: a.channels
874
+ })),
875
+ sessions: {
876
+ ttlHours: config.sessions.ttlHours,
877
+ maxConcurrent: config.sessions.maxConcurrent,
878
+ processTimeoutMs: config.sessions.processTimeoutMs
879
+ },
880
+ slack: {
881
+ requireMention: config.slack.requireMention,
882
+ coalesce: config.slack.coalesce,
883
+ channels: config.slack.channels,
884
+ quietHours: config.slack.quietHours
885
+ },
886
+ birds: config.birds,
887
+ browser: {
888
+ enabled: config.browser.enabled,
889
+ mode: process.env["BROWSER_MODE"] ?? "persistent",
890
+ novncHost: config.browser.novncHost,
891
+ vncPort: config.browser.vncPort,
892
+ novncPort: config.browser.novncPort
893
+ },
894
+ database: config.database,
895
+ web: { port: config.web.port }
896
+ };
897
+ }
898
+ function validateConfigPatch(body) {
899
+ for (const key of Object.keys(body)) if (!ALLOWED_TOP_LEVEL_KEYS.has(key)) return `Unknown config key "${key}"`;
900
+ if ("timezone" in body && typeof body["timezone"] !== "string") return "\"timezone\" must be a string";
901
+ if ("agents" in body) {
902
+ const agents = body["agents"];
903
+ if (!Array.isArray(agents) || agents.length === 0) return "\"agents\" must be a non-empty array";
904
+ for (const a of agents) {
905
+ if (!a["id"] || typeof a["id"] !== "string") return "Each agent must have a string \"id\"";
906
+ if (!a["name"] || typeof a["name"] !== "string") return "Each agent must have a string \"name\"";
907
+ if (!a["provider"] || !VALID_PROVIDERS.has(a["provider"])) return `Agent "${a["id"]}": invalid provider (expected: ${[...VALID_PROVIDERS].join(", ")})`;
908
+ if (!a["model"] || typeof a["model"] !== "string") return `Agent "${a["id"]}": "model" is required`;
909
+ if (!Array.isArray(a["channels"]) || a["channels"].length === 0) return `Agent "${a["id"]}": "channels" must be a non-empty array`;
910
+ }
911
+ }
912
+ if ("sessions" in body) {
913
+ const s = body["sessions"];
914
+ if (typeof s !== "object" || s == null) return "\"sessions\" must be an object";
915
+ for (const k of [
916
+ "ttlHours",
917
+ "maxConcurrent",
918
+ "processTimeoutMs"
919
+ ]) if (k in s && (typeof s[k] !== "number" || s[k] <= 0)) return `"sessions.${k}" must be a positive number`;
920
+ }
921
+ if ("slack" in body) {
922
+ const sl = body["slack"];
923
+ if (typeof sl !== "object" || sl == null) return "\"slack\" must be an object";
924
+ if ("requireMention" in sl && typeof sl["requireMention"] !== "boolean") return "\"slack.requireMention\" must be a boolean";
925
+ if ("channels" in sl) {
926
+ if (!Array.isArray(sl["channels"])) return "\"slack.channels\" must be an array";
927
+ }
928
+ if ("coalesce" in sl) {
929
+ const c = sl["coalesce"];
930
+ if (typeof c !== "object" || c == null) return "\"slack.coalesce\" must be an object";
931
+ if ("debounceMs" in c && (typeof c["debounceMs"] !== "number" || c["debounceMs"] <= 0)) return "\"slack.coalesce.debounceMs\" must be a positive number";
932
+ if ("bypassDms" in c && typeof c["bypassDms"] !== "boolean") return "\"slack.coalesce.bypassDms\" must be a boolean";
933
+ }
934
+ if ("quietHours" in sl) {
935
+ const q = sl["quietHours"];
936
+ if (typeof q !== "object" || q == null) return "\"slack.quietHours\" must be an object";
937
+ if ("enabled" in q && typeof q["enabled"] !== "boolean") return "\"slack.quietHours.enabled\" must be a boolean";
938
+ if ("start" in q && (typeof q["start"] !== "string" || !HH_MM_RE.test(q["start"]))) return "\"slack.quietHours.start\" must be HH:MM format";
939
+ if ("end" in q && (typeof q["end"] !== "string" || !HH_MM_RE.test(q["end"]))) return "\"slack.quietHours.end\" must be HH:MM format";
940
+ }
941
+ }
942
+ if ("birds" in body) {
943
+ const b = body["birds"];
944
+ if (typeof b !== "object" || b == null) return "\"birds\" must be an object";
945
+ if ("maxAttempts" in b && (!Number.isInteger(b["maxAttempts"]) || b["maxAttempts"] <= 0)) return "\"birds.maxAttempts\" must be a positive integer";
946
+ }
947
+ if ("browser" in body) {
948
+ const br = body["browser"];
949
+ if (typeof br !== "object" || br == null) return "\"browser\" must be an object";
950
+ if ("enabled" in br && typeof br["enabled"] !== "boolean") return "\"browser.enabled\" must be a boolean";
951
+ if ("novncHost" in br && (typeof br["novncHost"] !== "string" || !br["novncHost"].trim())) return "\"browser.novncHost\" must be a non-empty string";
952
+ }
953
+ if ("database" in body) {
954
+ const d = body["database"];
955
+ if (typeof d !== "object" || d == null) return "\"database\" must be an object";
956
+ if ("retentionDays" in d && (!Number.isInteger(d["retentionDays"]) || d["retentionDays"] <= 0)) return "\"database.retentionDays\" must be a positive integer";
957
+ }
958
+ return null;
959
+ }
960
+ function buildRoutes(getConfig, startedAt, getDeps, options) {
961
+ return [
962
+ {
963
+ method: "GET",
964
+ pattern: pathToRegex("/api/auth/check"),
965
+ skipAuth: true,
966
+ handler(_req, res) {
967
+ const count = getUserCount();
968
+ json(res, {
969
+ setupRequired: count === 0,
970
+ authRequired: count > 0,
971
+ onboardingRequired: count > 0 && getSetting("onboarding_completed") !== "true"
972
+ });
973
+ }
974
+ },
975
+ {
976
+ method: "POST",
977
+ pattern: pathToRegex("/api/auth/setup"),
978
+ skipAuth: true,
979
+ async handler(req, res) {
980
+ if (getUserCount() > 0) {
981
+ jsonError(res, "Setup already completed", 403);
982
+ return;
983
+ }
984
+ let body;
985
+ try {
986
+ body = await readJsonBody(req);
987
+ } catch {
988
+ jsonError(res, "Invalid JSON body", 400);
989
+ return;
990
+ }
991
+ if (!body.email || typeof body.email !== "string" || !body.email.trim()) {
992
+ jsonError(res, "\"email\" is required", 400);
993
+ return;
994
+ }
995
+ if (!body.password || typeof body.password !== "string" || body.password.length < 8) {
996
+ jsonError(res, "Password must be at least 8 characters", 400);
997
+ return;
998
+ }
999
+ const email = body.email.trim().toLowerCase();
1000
+ const passwordHash = await hashPassword(body.password);
1001
+ const tokenKey = generateTokenKey();
1002
+ const user = createUser(email, passwordHash, tokenKey);
1003
+ const secret = getOrCreateSecret();
1004
+ json(res, {
1005
+ token: signToken(user.id, tokenKey, secret),
1006
+ user: {
1007
+ id: user.id,
1008
+ email: user.email
1009
+ }
1010
+ }, 201);
1011
+ }
1012
+ },
1013
+ {
1014
+ method: "POST",
1015
+ pattern: pathToRegex("/api/auth/login"),
1016
+ skipAuth: true,
1017
+ async handler(req, res) {
1018
+ let body;
1019
+ try {
1020
+ body = await readJsonBody(req);
1021
+ } catch {
1022
+ jsonError(res, "Invalid JSON body", 400);
1023
+ return;
1024
+ }
1025
+ if (!body.email || !body.password) {
1026
+ jsonError(res, "Invalid credentials", 401);
1027
+ return;
1028
+ }
1029
+ const user = getUserByEmail(body.email.trim());
1030
+ if (!user) {
1031
+ jsonError(res, "Invalid credentials", 401);
1032
+ return;
1033
+ }
1034
+ if (!await verifyPassword(body.password, user.password_hash)) {
1035
+ jsonError(res, "Invalid credentials", 401);
1036
+ return;
1037
+ }
1038
+ const secret = getOrCreateSecret();
1039
+ json(res, {
1040
+ token: signToken(user.id, user.token_key, secret),
1041
+ user: {
1042
+ id: user.id,
1043
+ email: user.email
1044
+ }
1045
+ });
1046
+ }
1047
+ },
1048
+ {
1049
+ method: "POST",
1050
+ pattern: pathToRegex("/api/auth/verify"),
1051
+ handler(_req, res) {
1052
+ json(res, { valid: true });
1053
+ }
1054
+ },
1055
+ {
1056
+ method: "GET",
1057
+ pattern: pathToRegex("/api/doctor"),
1058
+ handler(_req, res) {
1059
+ json(res, checkDoctor());
1060
+ }
1061
+ },
1062
+ {
1063
+ method: "GET",
1064
+ pattern: pathToRegex("/api/status"),
1065
+ handler(_req, res) {
1066
+ json(res, buildStatusPayload(getConfig, startedAt, getDeps));
1067
+ }
1068
+ },
1069
+ {
1070
+ method: "GET",
1071
+ pattern: pathToRegex("/api/config"),
1072
+ handler(_req, res) {
1073
+ json(res, sanitizeConfig(getConfig()));
1074
+ }
1075
+ },
1076
+ {
1077
+ method: "PATCH",
1078
+ pattern: pathToRegex("/api/config"),
1079
+ async handler(req, res) {
1080
+ let body;
1081
+ try {
1082
+ body = await readJsonBody(req);
1083
+ } catch {
1084
+ jsonError(res, "Invalid JSON body", 400);
1085
+ return;
1086
+ }
1087
+ const error = validateConfigPatch(body);
1088
+ if (error) {
1089
+ jsonError(res, error, 400);
1090
+ return;
1091
+ }
1092
+ try {
1093
+ const merged = deepMerge(loadRawConfig(options.configPath), body);
1094
+ saveConfig(options.configPath, merged);
1095
+ options.onConfigReload();
1096
+ broadcastSSE("invalidate", { resource: "config" });
1097
+ json(res, sanitizeConfig(getConfig()));
1098
+ } catch (err) {
1099
+ jsonError(res, `Failed to save config: ${err instanceof Error ? err.message : String(err)}`, 500);
1100
+ }
1101
+ }
1102
+ },
1103
+ {
1104
+ method: "GET",
1105
+ pattern: pathToRegex("/api/secrets"),
1106
+ handler(_req, res) {
1107
+ const activeAnthropicValue = process.env["CLAUDE_CODE_OAUTH_TOKEN"] || process.env["ANTHROPIC_API_KEY"];
1108
+ json(res, {
1109
+ slack: {
1110
+ botToken: maskSecret(process.env["SLACK_BOT_TOKEN"]),
1111
+ appToken: maskSecret(process.env["SLACK_APP_TOKEN"])
1112
+ },
1113
+ anthropic: maskSecret(activeAnthropicValue)
1114
+ });
1115
+ }
1116
+ },
1117
+ {
1118
+ method: "PUT",
1119
+ pattern: pathToRegex("/api/secrets/slack"),
1120
+ async handler(req, res) {
1121
+ let body;
1122
+ try {
1123
+ body = await readJsonBody(req);
1124
+ } catch {
1125
+ jsonError(res, "Invalid JSON body", 400);
1126
+ return;
1127
+ }
1128
+ const botToken = body.botToken?.trim();
1129
+ const appToken = body.appToken?.trim();
1130
+ if (!botToken && !appToken) {
1131
+ jsonError(res, "Provide \"botToken\" and/or \"appToken\"", 400);
1132
+ return;
1133
+ }
1134
+ const { WebClient } = await import("@slack/web-api");
1135
+ const envVars = {};
1136
+ if (botToken) {
1137
+ if (!botToken.startsWith("xoxb-")) {
1138
+ jsonError(res, "Bot token must start with xoxb-", 400);
1139
+ return;
1140
+ }
1141
+ try {
1142
+ await new WebClient(botToken).auth.test();
1143
+ } catch {
1144
+ jsonError(res, "Invalid bot token. Check that you copied the full xoxb- token.", 400);
1145
+ return;
1146
+ }
1147
+ envVars["SLACK_BOT_TOKEN"] = botToken;
1148
+ }
1149
+ if (appToken) {
1150
+ if (!appToken.startsWith("xapp-")) {
1151
+ jsonError(res, "App token must start with xapp-", 400);
1152
+ return;
1153
+ }
1154
+ try {
1155
+ await new WebClient(appToken).apiCall("apps.connections.open");
1156
+ } catch {
1157
+ jsonError(res, "Invalid app token. Check that you created an app-level token with the connections:write scope.", 400);
1158
+ return;
1159
+ }
1160
+ envVars["SLACK_APP_TOKEN"] = appToken;
1161
+ }
1162
+ try {
1163
+ const envPath = resolve(".env");
1164
+ saveEnvFile(envPath, envVars);
1165
+ loadDotEnv(envPath);
1166
+ options.onConfigReload();
1167
+ broadcastSSE("invalidate", { resource: "secrets" });
1168
+ json(res, {
1169
+ success: true,
1170
+ requiresRestart: true
1171
+ });
1172
+ } catch (err) {
1173
+ jsonError(res, `Failed to save Slack tokens: ${err instanceof Error ? err.message : String(err)}`, 500);
1174
+ }
1175
+ }
1176
+ },
1177
+ {
1178
+ method: "PUT",
1179
+ pattern: pathToRegex("/api/secrets/anthropic"),
1180
+ async handler(req, res) {
1181
+ let body;
1182
+ try {
1183
+ body = await readJsonBody(req);
1184
+ } catch {
1185
+ jsonError(res, "Invalid JSON body", 400);
1186
+ return;
1187
+ }
1188
+ if (!body.apiKey || typeof body.apiKey !== "string" || !body.apiKey.trim()) {
1189
+ jsonError(res, "\"apiKey\" is required", 400);
1190
+ return;
1191
+ }
1192
+ const key = body.apiKey.trim();
1193
+ let envVar;
1194
+ if (key.startsWith("sk-ant-oat")) envVar = "CLAUDE_CODE_OAUTH_TOKEN";
1195
+ else if (key.startsWith("sk-ant-api")) envVar = "ANTHROPIC_API_KEY";
1196
+ else {
1197
+ jsonError(res, "Invalid key. Expected an Anthropic key starting with sk-ant-...", 400);
1198
+ return;
1199
+ }
1200
+ try {
1201
+ const envPath = resolve(".env");
1202
+ saveEnvFile(envPath, { [envVar]: key });
1203
+ loadDotEnv(envPath);
1204
+ options.onConfigReload();
1205
+ broadcastSSE("invalidate", { resource: "secrets" });
1206
+ json(res, {
1207
+ success: true,
1208
+ requiresRestart: false
1209
+ });
1210
+ } catch (err) {
1211
+ jsonError(res, `Failed to save Anthropic key: ${err instanceof Error ? err.message : String(err)}`, 500);
1212
+ }
1213
+ }
1214
+ },
1215
+ {
1216
+ method: "GET",
1217
+ pattern: pathToRegex("/api/sessions"),
1218
+ handler(req, res) {
1219
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1220
+ const { page, perPage } = parsePagination(url);
1221
+ json(res, listSessions(page, perPage, parseSortParam(url), parseSearchParam(url)));
1222
+ }
1223
+ },
1224
+ {
1225
+ method: "GET",
1226
+ pattern: pathToRegex("/api/sessions/:id"),
1227
+ handler(req, res, params) {
1228
+ const uid = params["id"];
1229
+ if (!uid) {
1230
+ jsonError(res, "Missing session ID", 400);
1231
+ return;
1232
+ }
1233
+ const session = getSession(uid);
1234
+ if (!session) {
1235
+ jsonError(res, `Session ${uid} not found`, 404);
1236
+ return;
1237
+ }
1238
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1239
+ const { page, perPage } = parsePagination(url);
1240
+ json(res, {
1241
+ session,
1242
+ messages: getSessionMessages(session.channel_id, session.thread_id, page, perPage, parseSortParam(url), parseSearchParam(url)),
1243
+ stats: getSessionTokenStats(session.channel_id, session.thread_id)
1244
+ });
1245
+ }
1246
+ },
1247
+ {
1248
+ method: "GET",
1249
+ pattern: pathToRegex("/api/jobs/stats"),
1250
+ handler(_req, res) {
1251
+ json(res, getJobStats());
1252
+ }
1253
+ },
1254
+ {
1255
+ method: "POST",
1256
+ pattern: pathToRegex("/api/jobs/retry-all"),
1257
+ handler(_req, res) {
1258
+ json(res, { count: retryAllFailedJobs() });
1259
+ }
1260
+ },
1261
+ {
1262
+ method: "DELETE",
1263
+ pattern: pathToRegex("/api/jobs/clear"),
1264
+ handler(req, res) {
1265
+ const status = new URL(req.url ?? "/", `http://${req.headers.host}`).searchParams.get("status");
1266
+ if (status !== "completed" && status !== "failed") {
1267
+ jsonError(res, "Query param \"status\" must be \"completed\" or \"failed\"", 400);
1268
+ return;
1269
+ }
1270
+ json(res, { count: clearJobs(status) });
1271
+ }
1272
+ },
1273
+ {
1274
+ method: "GET",
1275
+ pattern: pathToRegex("/api/jobs"),
1276
+ handler(req, res) {
1277
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1278
+ const { page, perPage } = parsePagination(url);
1279
+ json(res, listJobs(page, perPage, {
1280
+ status: url.searchParams.get("status") ?? void 0,
1281
+ cronJobUid: url.searchParams.get("cronJobUid") ?? void 0,
1282
+ name: url.searchParams.get("name") ?? void 0
1283
+ }, parseSortParam(url), parseSearchParam(url)));
1284
+ }
1285
+ },
1286
+ {
1287
+ method: "POST",
1288
+ pattern: pathToRegex("/api/jobs/:id/retry"),
1289
+ handler(_req, res, params) {
1290
+ const id = Number(params["id"]);
1291
+ if (!Number.isFinite(id)) {
1292
+ jsonError(res, "Invalid job ID", 400);
1293
+ return;
1294
+ }
1295
+ if (retryJob(id)) json(res, { success: true });
1296
+ else jsonError(res, `Job #${id} not found or not in failed state`, 404);
1297
+ }
1298
+ },
1299
+ {
1300
+ method: "DELETE",
1301
+ pattern: pathToRegex("/api/jobs/:id"),
1302
+ handler(_req, res, params) {
1303
+ const id = Number(params["id"]);
1304
+ if (!Number.isFinite(id)) {
1305
+ jsonError(res, "Invalid job ID", 400);
1306
+ return;
1307
+ }
1308
+ if (deleteJob(id)) json(res, { success: true });
1309
+ else jsonError(res, `Job #${id} not found`, 404);
1310
+ }
1311
+ },
1312
+ {
1313
+ method: "GET",
1314
+ pattern: pathToRegex("/api/birds/upcoming"),
1315
+ handler(req, res) {
1316
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1317
+ const limit = Math.min(Math.max(Number(url.searchParams.get("limit")) || 5, 1), 20);
1318
+ const now = /* @__PURE__ */ new Date();
1319
+ const upcoming = [];
1320
+ for (const bird of getEnabledCronJobs()) {
1321
+ if (bird.name.startsWith(SYSTEM_CRON_PREFIX)) continue;
1322
+ try {
1323
+ const next = nextCronMatch(parseCron(bird.schedule), now, bird.timezone);
1324
+ if (next) upcoming.push({
1325
+ uid: bird.uid,
1326
+ name: bird.name,
1327
+ schedule: bird.schedule,
1328
+ agent_id: bird.agent_id,
1329
+ next_run: next.toISOString()
1330
+ });
1331
+ } catch {}
1332
+ }
1333
+ upcoming.sort((a, b) => a.next_run.localeCompare(b.next_run));
1334
+ json(res, upcoming.slice(0, limit));
1335
+ }
1336
+ },
1337
+ {
1338
+ method: "GET",
1339
+ pattern: pathToRegex("/api/birds"),
1340
+ handler(req, res) {
1341
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1342
+ const { page, perPage } = parsePagination(url);
1343
+ json(res, listCronJobs(page, perPage, parseSystemFlag(url), parseSortParam(url), parseSearchParam(url)));
1344
+ }
1345
+ },
1346
+ {
1347
+ method: "PATCH",
1348
+ pattern: pathToRegex("/api/birds/:id/enable"),
1349
+ handler(_req, res, params) {
1350
+ const bird = resolveBirdParam(params, res);
1351
+ if (!bird) return;
1352
+ setCronJobEnabled(bird.uid, true);
1353
+ broadcastSSE("invalidate", { resource: "birds" });
1354
+ json(res, { success: true });
1355
+ }
1356
+ },
1357
+ {
1358
+ method: "PATCH",
1359
+ pattern: pathToRegex("/api/birds/:id/disable"),
1360
+ handler(_req, res, params) {
1361
+ const bird = resolveBirdParam(params, res);
1362
+ if (!bird) return;
1363
+ setCronJobEnabled(bird.uid, false);
1364
+ broadcastSSE("invalidate", { resource: "birds" });
1365
+ json(res, { success: true });
1366
+ }
1367
+ },
1368
+ {
1369
+ method: "POST",
1370
+ pattern: pathToRegex("/api/birds"),
1371
+ async handler(req, res) {
1372
+ let body;
1373
+ try {
1374
+ body = await readJsonBody(req);
1375
+ } catch {
1376
+ jsonError(res, "Invalid JSON body", 400);
1377
+ return;
1378
+ }
1379
+ if (!body.schedule || typeof body.schedule !== "string" || !body.schedule.trim()) {
1380
+ jsonError(res, "\"schedule\" is required", 400);
1381
+ return;
1382
+ }
1383
+ if (!body.prompt || typeof body.prompt !== "string" || !body.prompt.trim()) {
1384
+ jsonError(res, "\"prompt\" is required", 400);
1385
+ return;
1386
+ }
1387
+ const job = createCronJob(deriveBirdName(body.prompt), body.schedule.trim(), body.prompt.trim(), body.channel?.trim() || void 0, body.agent?.trim() || void 0, body.timezone?.trim() || getConfig().timezone, body.activeHoursStart?.trim() || void 0, body.activeHoursEnd?.trim() || void 0);
1388
+ broadcastSSE("invalidate", { resource: "birds" });
1389
+ json(res, job, 201);
1390
+ }
1391
+ },
1392
+ {
1393
+ method: "PATCH",
1394
+ pattern: pathToRegex("/api/birds/:id"),
1395
+ async handler(req, res, params) {
1396
+ const bird = resolveBirdParam(params, res);
1397
+ if (!bird) return;
1398
+ let body;
1399
+ try {
1400
+ body = await readJsonBody(req);
1401
+ } catch {
1402
+ jsonError(res, "Invalid JSON body", 400);
1403
+ return;
1404
+ }
1405
+ const updated = updateCronJob(bird.uid, {
1406
+ schedule: body.schedule?.trim() || void 0,
1407
+ prompt: body.prompt?.trim() || void 0,
1408
+ name: body.prompt ? deriveBirdName(body.prompt) : void 0,
1409
+ targetChannelId: body.channel !== void 0 ? body.channel?.trim() || null : void 0,
1410
+ agentId: body.agent?.trim() || void 0,
1411
+ timezone: body.timezone?.trim() || void 0,
1412
+ activeHoursStart: body.activeHoursStart !== void 0 ? body.activeHoursStart?.trim() || null : void 0,
1413
+ activeHoursEnd: body.activeHoursEnd !== void 0 ? body.activeHoursEnd?.trim() || null : void 0
1414
+ });
1415
+ if (updated) {
1416
+ broadcastSSE("invalidate", { resource: "birds" });
1417
+ json(res, updated);
1418
+ } else jsonError(res, `Bird ${bird.uid} not found`, 404);
1419
+ }
1420
+ },
1421
+ {
1422
+ method: "DELETE",
1423
+ pattern: pathToRegex("/api/birds/:id"),
1424
+ handler(_req, res, params) {
1425
+ const bird = resolveBirdParam(params, res);
1426
+ if (!bird) return;
1427
+ if (bird.name.startsWith(SYSTEM_CRON_PREFIX)) {
1428
+ jsonError(res, "System birds cannot be deleted", 403);
1429
+ return;
1430
+ }
1431
+ deleteCronJob(bird.uid);
1432
+ broadcastSSE("invalidate", { resource: "birds" });
1433
+ json(res, { success: true });
1434
+ }
1435
+ },
1436
+ {
1437
+ method: "POST",
1438
+ pattern: pathToRegex("/api/birds/:id/fly"),
1439
+ handler(_req, res, params) {
1440
+ const bird = resolveBirdParam(params, res);
1441
+ if (!bird) return;
1442
+ json(res, {
1443
+ success: true,
1444
+ jobId: (bird.name.startsWith(SYSTEM_CRON_PREFIX) ? enqueue("system_cron_run", {
1445
+ cronJobUid: bird.uid,
1446
+ cronName: bird.name
1447
+ }, {
1448
+ maxAttempts: 3,
1449
+ timeout: 300,
1450
+ cronJobUid: bird.uid
1451
+ }) : enqueue("cron_run", {
1452
+ cronJobUid: bird.uid,
1453
+ prompt: bird.prompt,
1454
+ channelId: bird.target_channel_id,
1455
+ agentId: bird.agent_id
1456
+ }, { cronJobUid: bird.uid })).id
1457
+ });
1458
+ }
1459
+ },
1460
+ {
1461
+ method: "GET",
1462
+ pattern: pathToRegex("/api/flights"),
1463
+ handler(req, res) {
1464
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1465
+ const { page, perPage } = parsePagination(url);
1466
+ json(res, listFlights(page, perPage, {
1467
+ status: url.searchParams.get("status") ?? void 0,
1468
+ birdUid: url.searchParams.get("birdUid") ?? void 0,
1469
+ system: parseSystemFlag(url)
1470
+ }, parseSortParam(url), parseSearchParam(url)));
1471
+ }
1472
+ },
1473
+ {
1474
+ method: "GET",
1475
+ pattern: pathToRegex("/api/birds/:id/flights"),
1476
+ handler(req, res, params) {
1477
+ const bird = resolveBirdParam(params, res);
1478
+ if (!bird) return;
1479
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1480
+ const { page, perPage } = parsePagination(url);
1481
+ json(res, listFlights(page, perPage, {
1482
+ birdUid: bird.uid,
1483
+ system: true
1484
+ }, parseSortParam(url), parseSearchParam(url)));
1485
+ }
1486
+ },
1487
+ {
1488
+ method: "GET",
1489
+ pattern: pathToRegex("/api/messages/stats"),
1490
+ handler(_req, res) {
1491
+ json(res, getMessageStats());
1492
+ }
1493
+ },
1494
+ {
1495
+ method: "GET",
1496
+ pattern: pathToRegex("/api/logs"),
1497
+ handler(req, res) {
1498
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1499
+ const { page, perPage } = parsePagination(url);
1500
+ json(res, getRecentLogs(page, perPage, url.searchParams.get("level") ?? void 0, url.searchParams.get("source") ?? void 0, parseSortParam(url), parseSearchParam(url)));
1501
+ }
1502
+ },
1503
+ {
1504
+ method: "GET",
1505
+ pattern: pathToRegex("/api/onboarding/defaults"),
1506
+ handler(_req, res) {
1507
+ const doctor = checkDoctor();
1508
+ const defaultAgent = DEFAULTS.agents[0];
1509
+ json(res, {
1510
+ agent: {
1511
+ name: defaultAgent.name,
1512
+ provider: defaultAgent.provider,
1513
+ model: defaultAgent.model,
1514
+ systemPrompt: defaultAgent.systemPrompt,
1515
+ maxTurns: defaultAgent.maxTurns,
1516
+ channels: defaultAgent.channels
1517
+ },
1518
+ browser: {
1519
+ enabled: DEFAULTS.browser.enabled,
1520
+ novncHost: DEFAULTS.browser.novncHost,
1521
+ novncPort: DEFAULTS.browser.novncPort
1522
+ },
1523
+ doctor
1524
+ });
1525
+ }
1526
+ },
1527
+ {
1528
+ method: "POST",
1529
+ pattern: pathToRegex("/api/onboarding/slack"),
1530
+ async handler(req, res) {
1531
+ let body;
1532
+ try {
1533
+ body = await readJsonBody(req);
1534
+ } catch {
1535
+ jsonError(res, "Invalid JSON body", 400);
1536
+ return;
1537
+ }
1538
+ if (!body.botToken || typeof body.botToken !== "string" || !body.botToken.trim()) {
1539
+ jsonError(res, "\"botToken\" is required", 400);
1540
+ return;
1541
+ }
1542
+ if (!body.appToken || typeof body.appToken !== "string" || !body.appToken.trim()) {
1543
+ jsonError(res, "\"appToken\" is required", 400);
1544
+ return;
1545
+ }
1546
+ const botToken = body.botToken.trim();
1547
+ const appToken = body.appToken.trim();
1548
+ if (!botToken.startsWith("xoxb-")) {
1549
+ jsonError(res, "Bot token must start with xoxb-", 400);
1550
+ return;
1551
+ }
1552
+ if (!appToken.startsWith("xapp-")) {
1553
+ jsonError(res, "App token must start with xapp-", 400);
1554
+ return;
1555
+ }
1556
+ const { WebClient } = await import("@slack/web-api");
1557
+ let authResult;
1558
+ try {
1559
+ authResult = await new WebClient(botToken).auth.test();
1560
+ } catch {
1561
+ jsonError(res, "Invalid bot token. Check that you copied the full xoxb- token.", 400);
1562
+ return;
1563
+ }
1564
+ try {
1565
+ await new WebClient(appToken).apiCall("apps.connections.open");
1566
+ } catch {
1567
+ jsonError(res, "Invalid app token. Check that you created an app-level token with the connections:write scope.", 400);
1568
+ return;
1569
+ }
1570
+ try {
1571
+ saveEnvFile(resolve(".env"), {
1572
+ SLACK_BOT_TOKEN: botToken,
1573
+ SLACK_APP_TOKEN: appToken
1574
+ });
1575
+ const configPath = options.configPath;
1576
+ const raw = loadRawConfig(configPath);
1577
+ const slack = raw["slack"] ?? {};
1578
+ slack["botToken"] = "env:SLACK_BOT_TOKEN";
1579
+ slack["appToken"] = "env:SLACK_APP_TOKEN";
1580
+ raw["slack"] = slack;
1581
+ saveConfig(configPath, raw);
1582
+ json(res, {
1583
+ valid: true,
1584
+ team: authResult.team ?? "",
1585
+ botUser: authResult.user ?? ""
1586
+ });
1587
+ } catch (err) {
1588
+ jsonError(res, `Failed to save Slack config: ${err instanceof Error ? err.message : String(err)}`, 500);
1589
+ }
1590
+ }
1591
+ },
1592
+ {
1593
+ method: "POST",
1594
+ pattern: pathToRegex("/api/onboarding/agent"),
1595
+ async handler(req, res) {
1596
+ let body;
1597
+ try {
1598
+ body = await readJsonBody(req);
1599
+ } catch {
1600
+ jsonError(res, "Invalid JSON body", 400);
1601
+ return;
1602
+ }
1603
+ if (!body.name || typeof body.name !== "string") {
1604
+ jsonError(res, "\"name\" is required", 400);
1605
+ return;
1606
+ }
1607
+ if (!body.provider || typeof body.provider !== "string") {
1608
+ jsonError(res, "\"provider\" is required", 400);
1609
+ return;
1610
+ }
1611
+ if (!body.model || typeof body.model !== "string") {
1612
+ jsonError(res, "\"model\" is required", 400);
1613
+ return;
1614
+ }
1615
+ const configPath = options.configPath;
1616
+ const raw = loadRawConfig(configPath);
1617
+ raw["agents"] = [{
1618
+ id: "default",
1619
+ name: body.name.trim(),
1620
+ provider: body.provider.trim(),
1621
+ model: body.model.trim(),
1622
+ maxTurns: body.maxTurns ?? DEFAULTS.agents[0].maxTurns,
1623
+ systemPrompt: body.systemPrompt?.trim() ?? DEFAULTS.agents[0].systemPrompt,
1624
+ channels: body.channels ?? ["*"]
1625
+ }];
1626
+ saveConfig(configPath, raw);
1627
+ json(res, { agents: raw["agents"] });
1628
+ }
1629
+ },
1630
+ {
1631
+ method: "POST",
1632
+ pattern: pathToRegex("/api/onboarding/auth"),
1633
+ async handler(req, res) {
1634
+ let body;
1635
+ try {
1636
+ body = await readJsonBody(req);
1637
+ } catch {
1638
+ jsonError(res, "Invalid JSON body", 400);
1639
+ return;
1640
+ }
1641
+ if (!body.apiKey || typeof body.apiKey !== "string" || !body.apiKey.trim()) {
1642
+ jsonError(res, "\"apiKey\" is required", 400);
1643
+ return;
1644
+ }
1645
+ const key = body.apiKey.trim();
1646
+ let envVar;
1647
+ if (key.startsWith("sk-ant-oat")) envVar = "CLAUDE_CODE_OAUTH_TOKEN";
1648
+ else if (key.startsWith("sk-ant-api")) envVar = "ANTHROPIC_API_KEY";
1649
+ else {
1650
+ jsonError(res, "Invalid key. Expected an Anthropic key starting with sk-ant-...", 400);
1651
+ return;
1652
+ }
1653
+ saveEnvFile(resolve(".env"), { [envVar]: key });
1654
+ json(res, { valid: true });
1655
+ }
1656
+ },
1657
+ {
1658
+ method: "POST",
1659
+ pattern: pathToRegex("/api/onboarding/browser/probe"),
1660
+ async handler(req, res) {
1661
+ let body;
1662
+ try {
1663
+ body = await readJsonBody(req);
1664
+ } catch {
1665
+ jsonError(res, "Invalid JSON body", 400);
1666
+ return;
1667
+ }
1668
+ const host = body.host?.trim();
1669
+ if (!host) {
1670
+ jsonError(res, "\"host\" is required", 400);
1671
+ return;
1672
+ }
1673
+ json(res, { reachable: await probeBrowser(host, body.port ?? DEFAULTS.browser.novncPort) });
1674
+ }
1675
+ },
1676
+ {
1677
+ method: "POST",
1678
+ pattern: pathToRegex("/api/onboarding/browser"),
1679
+ async handler(req, res) {
1680
+ let body;
1681
+ try {
1682
+ body = await readJsonBody(req);
1683
+ } catch {
1684
+ jsonError(res, "Invalid JSON body", 400);
1685
+ return;
1686
+ }
1687
+ const configPath = options.configPath;
1688
+ const raw = loadRawConfig(configPath);
1689
+ const browser = raw["browser"] ?? {};
1690
+ if (body.enabled !== void 0) browser["enabled"] = body.enabled;
1691
+ if (body.novncHost !== void 0) browser["novncHost"] = body.novncHost.trim();
1692
+ raw["browser"] = browser;
1693
+ saveConfig(configPath, raw);
1694
+ json(res, { browser: raw["browser"] });
1695
+ }
1696
+ },
1697
+ {
1698
+ method: "POST",
1699
+ pattern: pathToRegex("/api/onboarding/complete"),
1700
+ async handler(_req, res) {
1701
+ try {
1702
+ await options.onLaunch();
1703
+ setSetting("onboarding_completed", "true");
1704
+ json(res, { success: true });
1705
+ } catch (err) {
1706
+ jsonError(res, `Launch failed: ${err instanceof Error ? err.message : String(err)}`, 500);
1707
+ }
1708
+ }
1709
+ }
1710
+ ];
1711
+ }
1712
+
1713
+ //#endregion
1714
+ //#region src/server/sse.ts
1715
+ const sseConnections = /* @__PURE__ */ new Set();
1716
+ function handleSSE(getConfig, startedAt, getDeps, req, res) {
1717
+ if (!checkAuth(req, res, true)) return;
1718
+ res.writeHead(200, {
1719
+ "Content-Type": "text/event-stream; charset=utf-8",
1720
+ "Cache-Control": "no-cache, no-transform",
1721
+ Connection: "keep-alive",
1722
+ "X-Accel-Buffering": "no"
1723
+ });
1724
+ sseConnections.add(res);
1725
+ const send = () => {
1726
+ const data = JSON.stringify(buildStatusPayload(getConfig, startedAt, getDeps));
1727
+ res.write(`event: status\ndata: ${data}\n\n`);
1728
+ };
1729
+ send();
1730
+ const timer = setInterval(send, 5e3);
1731
+ req.on("close", () => {
1732
+ clearInterval(timer);
1733
+ sseConnections.delete(res);
1734
+ });
1735
+ }
1736
+ function broadcastSSE(event, data) {
1737
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
1738
+ for (const res of sseConnections) if (!res.destroyed) res.write(payload);
1739
+ }
1740
+ function closeAllSSE() {
1741
+ for (const res of sseConnections) res.end();
1742
+ sseConnections.clear();
1743
+ }
1744
+
1745
+ //#endregion
1746
+ //#region src/server/static.ts
1747
+ const WEB_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "web", "dist");
1748
+ function serveStatic(res, urlPath) {
1749
+ if (!existsSync(WEB_DIR)) {
1750
+ res.writeHead(503, { "Content-Type": "text/plain" });
1751
+ res.end("Web UI not built");
1752
+ return;
1753
+ }
1754
+ if (urlPath === "/" || urlPath === "") urlPath = "/index.html";
1755
+ const filePath = join(WEB_DIR, urlPath);
1756
+ if (!filePath.startsWith(WEB_DIR)) {
1757
+ res.writeHead(403);
1758
+ res.end("Forbidden");
1759
+ return;
1760
+ }
1761
+ try {
1762
+ const content = readFileSync(filePath);
1763
+ const contentType = MIME_TYPES[extname(filePath)] ?? "application/octet-stream";
1764
+ res.writeHead(200, { "Content-Type": contentType });
1765
+ res.end(content);
1766
+ } catch {
1767
+ try {
1768
+ const indexContent = readFileSync(join(WEB_DIR, "index.html"));
1769
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1770
+ res.end(indexContent);
1771
+ } catch {
1772
+ res.writeHead(404);
1773
+ res.end("Not found");
1774
+ }
1775
+ }
1776
+ }
1777
+
1778
+ //#endregion
1779
+ //#region src/server/vnc-proxy.ts
1780
+ /** @fileoverview WebSocket proxy for VNC: tunnels browser connections to upstream noVNC. */
1781
+ function destroyWithStatus(socket, status, message) {
1782
+ const body = `HTTP/1.1 ${status} ${message}\r\n\r\n`;
1783
+ socket.end(body);
1784
+ }
1785
+ function checkUpgradeAuth(req) {
1786
+ if (getUserCount() === 0) return true;
1787
+ const token = new URL(req.url ?? "/", `http://${req.headers.host}`).searchParams.get("token");
1788
+ return token != null && verifyToken(token);
1789
+ }
1790
+ function handleVncUpgrade(getConfig, req, socket, head) {
1791
+ const config = getConfig();
1792
+ if (!config.browser.enabled) {
1793
+ destroyWithStatus(socket, 404, "Not Found");
1794
+ return;
1795
+ }
1796
+ if (!checkUpgradeAuth(req)) {
1797
+ destroyWithStatus(socket, 403, "Forbidden");
1798
+ return;
1799
+ }
1800
+ const { novncHost, novncPort } = config.browser;
1801
+ logger.info(`vnc proxy connecting to ${novncHost}:${novncPort}`);
1802
+ const upstream = connect(novncPort, novncHost, () => {
1803
+ logger.info(`vnc proxy connected to ${novncHost}:${novncPort}`);
1804
+ let rawHeaders = `GET /websockify HTTP/${req.httpVersion}\r\n`;
1805
+ for (let i = 0; i < req.rawHeaders.length; i += 2) rawHeaders += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`;
1806
+ rawHeaders += "\r\n";
1807
+ upstream.write(rawHeaders);
1808
+ if (head.length > 0) upstream.write(head);
1809
+ socket.pipe(upstream);
1810
+ upstream.pipe(socket);
1811
+ });
1812
+ upstream.on("error", (err) => {
1813
+ const code = err.code ?? "";
1814
+ logger.error(`vnc proxy upstream error: ${code} ${err.message}`);
1815
+ socket.destroy();
1816
+ });
1817
+ socket.on("error", (err) => {
1818
+ const code = err.code ?? "";
1819
+ logger.error(`vnc proxy client error: ${code} ${err.message}`);
1820
+ upstream.destroy();
1821
+ });
1822
+ upstream.on("close", () => socket.destroy());
1823
+ socket.on("close", () => upstream.destroy());
1824
+ }
1825
+
1826
+ //#endregion
1827
+ //#region src/server/lifecycle.ts
1828
+ /** @fileoverview Web server lifecycle: creation, request routing, and shutdown. */
1829
+ function setCorsHeaders(getConfig, res) {
1830
+ const origin = getConfig().web.corsOrigin;
1831
+ if (!origin) return;
1832
+ res.setHeader("Access-Control-Allow-Origin", origin);
1833
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
1834
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
1835
+ res.setHeader("Access-Control-Max-Age", "86400");
1836
+ }
1837
+ function createWebServer(getConfig, signal, getDeps, options) {
1838
+ const startedAt = Date.now();
1839
+ const routes = buildRoutes(getConfig, startedAt, getDeps, options);
1840
+ let server = null;
1841
+ const requestHandler = async (req, res) => {
1842
+ const method = req.method ?? "GET";
1843
+ const urlPath = req.url ?? "/";
1844
+ const qIndex = urlPath.indexOf("?");
1845
+ const pathOnly = qIndex !== -1 ? urlPath.slice(0, qIndex) : urlPath;
1846
+ setCorsHeaders(getConfig, res);
1847
+ if (method === "OPTIONS") {
1848
+ res.writeHead(204);
1849
+ res.end();
1850
+ return;
1851
+ }
1852
+ if (method === "GET" && pathOnly === "/api/events") {
1853
+ handleSSE(getConfig, startedAt, getDeps, req, res);
1854
+ return;
1855
+ }
1856
+ for (const route of routes) {
1857
+ if (route.method !== method) continue;
1858
+ const match = pathOnly.match(route.pattern);
1859
+ if (!match) continue;
1860
+ if (!route.skipAuth && !checkAuth(req, res)) return;
1861
+ const params = match.groups ?? {};
1862
+ try {
1863
+ await route.handler(req, res, params);
1864
+ } catch (err) {
1865
+ const msg = err instanceof Error ? err.message : String(err);
1866
+ logger.error(`api error: ${msg}`);
1867
+ insertLog("error", "api", msg);
1868
+ jsonError(res, "Internal server error", 500);
1869
+ }
1870
+ return;
1871
+ }
1872
+ if (method === "GET") {
1873
+ serveStatic(res, pathOnly);
1874
+ return;
1875
+ }
1876
+ jsonError(res, "Not found", 404);
1877
+ };
1878
+ return {
1879
+ start() {
1880
+ return new Promise((resolve, reject) => {
1881
+ server = createServer((req, res) => {
1882
+ requestHandler(req, res).catch((err) => {
1883
+ logger.error(`unhandled request error: ${err instanceof Error ? err.message : String(err)}`);
1884
+ if (!res.headersSent) jsonError(res, "Internal server error", 500);
1885
+ });
1886
+ });
1887
+ server.on("upgrade", (req, socket, head) => {
1888
+ const url = req.url ?? "/";
1889
+ if ((url.indexOf("?") !== -1 ? url.slice(0, url.indexOf("?")) : url) === "/vnc") handleVncUpgrade(getConfig, req, socket, head);
1890
+ else socket.destroy();
1891
+ });
1892
+ server.on("error", (err) => {
1893
+ reject(err);
1894
+ });
1895
+ const config = getConfig();
1896
+ server.listen(config.web.port, config.web.host, () => {
1897
+ logger.info(`web server listening on http://${config.web.host}:${config.web.port}`);
1898
+ resolve();
1899
+ });
1900
+ signal.addEventListener("abort", () => {
1901
+ server?.close();
1902
+ });
1903
+ });
1904
+ },
1905
+ stop() {
1906
+ return new Promise((resolve) => {
1907
+ if (!server) {
1908
+ resolve();
1909
+ return;
1910
+ }
1911
+ closeAllSSE();
1912
+ server.close(() => {
1913
+ logger.info("web server stopped");
1914
+ resolve();
1915
+ });
1916
+ });
1917
+ }
1918
+ };
1919
+ }
1920
+
1921
+ //#endregion
1922
+ //#region src/jobs.ts
1923
+ const handlers = /* @__PURE__ */ new Map();
1924
+ const POLL_INTERVAL_MS = 1e3;
1925
+ const STALE_CHECK_INTERVAL_MS = 300 * 1e3;
1926
+ function registerHandler(name, handler) {
1927
+ handlers.set(name, handler);
1928
+ }
1929
+ function enqueue(name, payload, options) {
1930
+ const job = createJob({
1931
+ name,
1932
+ payload,
1933
+ priority: options?.priority,
1934
+ maxAttempts: options?.maxAttempts,
1935
+ timeout: options?.timeout,
1936
+ delaySeconds: options?.delaySeconds,
1937
+ cronJobUid: options?.cronJobUid
1938
+ });
1939
+ logger.debug(`enqueued job ${job.id}: ${name}`);
1940
+ return job;
1941
+ }
1942
+ async function processJob(job) {
1943
+ const handler = handlers.get(job.name);
1944
+ if (!handler) {
1945
+ failJob(job.id, `no handler registered for "${job.name}"`);
1946
+ logger.warn(`job ${job.id}: no handler for "${job.name}"`);
1947
+ return;
1948
+ }
1949
+ const isCronRun = job.cron_job_uid != null;
1950
+ const cronRun = isCronRun ? createCronRun(job.cron_job_uid) : null;
1951
+ let finalStatus = "completed";
1952
+ let resultText;
1953
+ let errorText;
1954
+ try {
1955
+ const result = await handler(job.payload ? JSON.parse(job.payload) : void 0);
1956
+ resultText = typeof result === "string" ? result : void 0;
1957
+ completeJob(job.id, resultText);
1958
+ logger.debug(`job ${job.id} completed: ${job.name}`);
1959
+ } catch (err) {
1960
+ finalStatus = "failed";
1961
+ errorText = err instanceof Error ? err.message : String(err);
1962
+ failJob(job.id, errorText);
1963
+ logger.warn(`job ${job.id} failed (attempt ${job.attempts}/${job.max_attempts}): ${errorText}`);
1964
+ insertLog("error", "cron", errorText);
1965
+ } finally {
1966
+ if (cronRun != null) completeCronRun(cronRun.uid, finalStatus === "completed" ? "success" : "error", resultText, errorText);
1967
+ if (isCronRun && job.cron_job_uid != null) {
1968
+ const cronJob = getCronJob(job.cron_job_uid);
1969
+ if (cronJob != null) {
1970
+ const newFailureCount = finalStatus === "failed" ? cronJob.failure_count + 1 : cronJob.failure_count;
1971
+ updateCronJobStatus(job.cron_job_uid, finalStatus, newFailureCount);
1972
+ }
1973
+ }
1974
+ const invalidatePayload = { resource: isCronRun ? "birds" : "sessions" };
1975
+ if (job.cron_job_uid != null) invalidatePayload.cronJobUid = job.cron_job_uid;
1976
+ broadcastSSE("invalidate", invalidatePayload);
1977
+ }
1978
+ }
1979
+ /**
1980
+ * Starts the job worker loop. Polls for pending jobs, processes them one at a time.
1981
+ * Checks for stale running jobs every 5 minutes.
1982
+ */
1983
+ function startWorker(signal) {
1984
+ let pollTimer = null;
1985
+ const pollTick = async () => {
1986
+ if (signal.aborted) return;
1987
+ const job = claimNextJob();
1988
+ if (job) {
1989
+ await processJob(job);
1990
+ if (!signal.aborted) pollTick();
1991
+ return;
1992
+ }
1993
+ pollTimer = setTimeout(pollTick, POLL_INTERVAL_MS);
1994
+ };
1995
+ const staleCheck = () => {
1996
+ if (signal.aborted) return;
1997
+ const stale = failStaleJobs();
1998
+ if (stale > 0) logger.info(`timed out ${stale} stale job(s)`);
1999
+ };
2000
+ staleCheck();
2001
+ const staleTimer = setInterval(staleCheck, STALE_CHECK_INTERVAL_MS);
2002
+ signal.addEventListener("abort", () => {
2003
+ if (pollTimer) clearTimeout(pollTimer);
2004
+ clearInterval(staleTimer);
2005
+ });
2006
+ pollTick();
2007
+ }
2008
+
2009
+ //#endregion
2010
+ //#region src/provider/session.ts
2011
+ /**
2012
+ * Matches an incoming message to the correct agent based on channel config.
2013
+ * Agents are checked in order; first match wins.
2014
+ * A wildcard `"*"` in the agent's channels array matches everything.
2015
+ */
2016
+ function matchAgent(channelId, agents) {
2017
+ for (const agent of agents) for (const pattern of agent.channels) if (pattern === "*" || pattern === channelId) return agent;
2018
+ }
2019
+ /**
2020
+ * Looks up or creates a session for the given Slack thread.
2021
+ * Returns the session row and whether it was newly created.
2022
+ */
2023
+ function resolveSession(channelId, threadTs, config) {
2024
+ const agent = matchAgent(channelId, config.agents);
2025
+ if (!agent) {
2026
+ logger.warn(`no agent matched for channel ${channelId}`);
2027
+ return null;
2028
+ }
2029
+ const existing = findSession(channelId, threadTs);
2030
+ if (existing) {
2031
+ const ageHours = (Date.now() - (/* @__PURE__ */ new Date(existing.last_active + "Z")).getTime()) / (1e3 * 60 * 60);
2032
+ if (ageHours > config.sessions.ttlHours) {
2033
+ logger.info(`session ${existing.uid} expired (${ageHours.toFixed(1)}h old), starting fresh`);
2034
+ return {
2035
+ session: existing,
2036
+ agent,
2037
+ isNew: true
2038
+ };
2039
+ }
2040
+ return {
2041
+ session: existing,
2042
+ agent,
2043
+ isNew: false
2044
+ };
2045
+ }
2046
+ const session = createSession(channelId, threadTs, agent.id, "");
2047
+ logger.info(`created session ${session.uid} for channel=${channelId} thread=${threadTs ?? "none"} agent=${agent.id}`);
2048
+ return {
2049
+ session,
2050
+ agent,
2051
+ isNew: true
2052
+ };
2053
+ }
2054
+ function expireStaleSessions(ttlHours) {
2055
+ const deleted = deleteStaleSessions(ttlHours);
2056
+ if (deleted > 0) logger.info(`expired ${deleted} stale session(s)`);
2057
+ return deleted;
2058
+ }
2059
+
2060
+ //#endregion
2061
+ //#region src/provider/stream.ts
2062
+ /**
2063
+ * Splits a raw data chunk into lines, handling partial lines across chunks.
2064
+ * Returns [completeLines, remainingPartial].
2065
+ */
2066
+ function splitLines(buffer, chunk) {
2067
+ const lines = (buffer + chunk).split("\n");
2068
+ return [lines, lines.pop() ?? ""];
2069
+ }
2070
+
2071
+ //#endregion
2072
+ //#region src/provider/claude.ts
2073
+ function buildCommand$1(options) {
2074
+ const { message, sessionId, agent, mcpConfigPath } = options;
2075
+ const args = [
2076
+ "-p",
2077
+ message,
2078
+ "--output-format",
2079
+ "stream-json",
2080
+ "--model",
2081
+ agent.model,
2082
+ "--verbose",
2083
+ "--max-turns",
2084
+ String(agent.maxTurns)
2085
+ ];
2086
+ if (sessionId) args.push("--resume", sessionId);
2087
+ if (agent.systemPrompt) args.push("--append-system-prompt", agent.systemPrompt);
2088
+ if (mcpConfigPath) args.push("--mcp-config", mcpConfigPath);
2089
+ if (agent.fallbackModel) args.push("--fallback-model", agent.fallbackModel);
2090
+ args.push("--dangerously-skip-permissions");
2091
+ const oauthToken = process.env["CLAUDE_CODE_OAUTH_TOKEN"];
2092
+ const apiKey = process.env["ANTHROPIC_API_KEY"];
2093
+ return {
2094
+ binary: "claude",
2095
+ args,
2096
+ env: oauthToken ? { CLAUDE_CODE_OAUTH_TOKEN: oauthToken } : apiKey ? { ANTHROPIC_API_KEY: apiKey } : {}
2097
+ };
2098
+ }
2099
+ /**
2100
+ * Parses a single line of stream-json output into zero or more StreamEvents.
2101
+ * Only extracts text, images, completion, and error events. Tool use/result
2102
+ * events are internal to the agent and not surfaced to the channel layer.
2103
+ */
2104
+ function parseStreamLine$1(line) {
2105
+ const trimmed = line.trim();
2106
+ if (!trimmed || !trimmed.startsWith("{")) return [];
2107
+ let parsed;
2108
+ try {
2109
+ parsed = JSON.parse(trimmed);
2110
+ } catch {
2111
+ return [];
2112
+ }
2113
+ const eventType = parsed["type"];
2114
+ if (!eventType) return [];
2115
+ switch (eventType) {
2116
+ case "system":
2117
+ if (typeof parsed["session_id"] === "string") return [{
2118
+ type: "init",
2119
+ sessionId: parsed["session_id"],
2120
+ model: parsed["model"] ?? ""
2121
+ }];
2122
+ return [];
2123
+ case "assistant": return parseAssistantContent(parsed);
2124
+ case "user": return extractImages(parsed);
2125
+ case "result": {
2126
+ const usage = parsed["usage"];
2127
+ return [{
2128
+ type: "completion",
2129
+ subtype: parsed["subtype"] ?? "success",
2130
+ result: typeof parsed["result"] === "string" ? parsed["result"] : "",
2131
+ sessionId: parsed["session_id"] ?? "",
2132
+ isError: parsed["is_error"] ?? false,
2133
+ tokensIn: usage?.["input_tokens"] ?? 0,
2134
+ tokensOut: usage?.["output_tokens"] ?? 0,
2135
+ cacheCreationTokens: usage?.["cache_creation_input_tokens"] ?? 0,
2136
+ cacheReadTokens: usage?.["cache_read_input_tokens"] ?? 0,
2137
+ costUsd: parsed["total_cost_usd"] ?? 0,
2138
+ durationMs: parsed["duration_ms"] ?? 0,
2139
+ numTurns: parsed["num_turns"] ?? 0
2140
+ }];
2141
+ }
2142
+ case "rate_limit_event": {
2143
+ const info = parsed["rate_limit_info"];
2144
+ return [{
2145
+ type: "rate_limit",
2146
+ status: info?.["status"] ?? "unknown",
2147
+ resetsAt: info?.["resetsAt"] ?? 0
2148
+ }];
2149
+ }
2150
+ case "error": return [{
2151
+ type: "error",
2152
+ error: typeof parsed["error"] === "string" ? parsed["error"] : JSON.stringify(parsed["error"])
2153
+ }];
2154
+ default: return [];
2155
+ }
2156
+ }
2157
+ function parseAssistantContent(parsed) {
2158
+ const msg = parsed["message"];
2159
+ if (typeof msg === "string") return [{
2160
+ type: "text_delta",
2161
+ delta: msg
2162
+ }];
2163
+ if (!msg || typeof msg !== "object") return [];
2164
+ const content = msg["content"];
2165
+ if (!Array.isArray(content)) return [];
2166
+ const events = [];
2167
+ for (const block of content) {
2168
+ if (!block || typeof block !== "object") continue;
2169
+ const b = block;
2170
+ if (b["type"] === "text" && typeof b["text"] === "string") events.push({
2171
+ type: "text_delta",
2172
+ delta: b["text"]
2173
+ });
2174
+ }
2175
+ return events;
2176
+ }
2177
+ function extractImages(parsed) {
2178
+ const msg = parsed["message"];
2179
+ if (!msg || typeof msg !== "object") return [];
2180
+ const content = msg["content"];
2181
+ if (!Array.isArray(content)) return [];
2182
+ const images = [];
2183
+ for (const block of content) {
2184
+ if (!block || typeof block !== "object") continue;
2185
+ const b = block;
2186
+ if (b["type"] !== "tool_result") continue;
2187
+ const inner = b["content"];
2188
+ if (!Array.isArray(inner)) continue;
2189
+ for (const item of inner) {
2190
+ if (!item || typeof item !== "object") continue;
2191
+ const i = item;
2192
+ if (i["type"] !== "image") continue;
2193
+ const source = i["source"];
2194
+ if (!source || source["type"] !== "base64") continue;
2195
+ images.push({
2196
+ mediaType: source["media_type"] ?? "image/png",
2197
+ data: source["data"] ?? ""
2198
+ });
2199
+ }
2200
+ }
2201
+ if (images.length > 0) return [{
2202
+ type: "tool_images",
2203
+ images
2204
+ }];
2205
+ return [];
2206
+ }
2207
+ const claude = {
2208
+ buildCommand: buildCommand$1,
2209
+ parseStreamLine: parseStreamLine$1
2210
+ };
2211
+
2212
+ //#endregion
2213
+ //#region src/provider/opencode.ts
2214
+ /** @fileoverview OpenCode CLI provider: arg building, workspace setup, and JSON stream parsing. */
2215
+ const WORKSPACE_DIR = resolve(".browserbird", "opencode");
2216
+ /**
2217
+ * Translates a Claude-format MCP config into an opencode-format config.
2218
+ *
2219
+ * Claude format: { mcpServers: { name: { type: "sse", url: "..." } } }
2220
+ * OpenCode format: { mcp: { name: { type: "remote", url: "..." } } }
2221
+ */
2222
+ function translateMcpConfig(claudeConfig) {
2223
+ const servers = claudeConfig["mcpServers"] ?? {};
2224
+ const result = {};
2225
+ for (const [name, server] of Object.entries(servers)) {
2226
+ const serverType = server["type"];
2227
+ const url = server["url"];
2228
+ const command = server["command"];
2229
+ const args = server["args"];
2230
+ if (serverType === "sse" || serverType === "streamable-http") {
2231
+ if (url) result[name] = {
2232
+ type: "remote",
2233
+ url
2234
+ };
2235
+ } else if (serverType === "stdio") {
2236
+ if (command) {
2237
+ const entry = {
2238
+ type: "local",
2239
+ command: args ? [command, ...args] : [command]
2240
+ };
2241
+ const env = server["env"];
2242
+ if (env) entry["environment"] = env;
2243
+ result[name] = entry;
2244
+ }
2245
+ }
2246
+ }
2247
+ return result;
2248
+ }
2249
+ /**
2250
+ * Ensures the opencode workspace directory exists with the right config files.
2251
+ * Writes opencode.json (MCP servers) and .opencode/agent/browserbird.md (system prompt).
2252
+ */
2253
+ function ensureWorkspace(mcpConfigPath, systemPrompt) {
2254
+ mkdirSync(resolve(WORKSPACE_DIR, ".opencode", "agent"), { recursive: true });
2255
+ const config = {};
2256
+ if (mcpConfigPath) try {
2257
+ const raw = readFileSync(resolve(mcpConfigPath), "utf-8");
2258
+ const mcp = translateMcpConfig(JSON.parse(raw));
2259
+ if (Object.keys(mcp).length > 0) config["mcp"] = mcp;
2260
+ } catch (err) {
2261
+ logger.warn(`opencode: failed to read MCP config at ${mcpConfigPath}: ${err instanceof Error ? err.message : String(err)}`);
2262
+ }
2263
+ writeFileSync(resolve(WORKSPACE_DIR, "opencode.json"), JSON.stringify(config, null, 2) + "\n");
2264
+ if (systemPrompt) {
2265
+ const agentMd = `---\nmode: primary\n---\n\n${systemPrompt}\n`;
2266
+ writeFileSync(resolve(WORKSPACE_DIR, ".opencode", "agent", "browserbird.md"), agentMd);
2267
+ }
2268
+ const agentsMd = resolve("AGENTS.md");
2269
+ if (existsSync(agentsMd)) copyFileSync(agentsMd, resolve(WORKSPACE_DIR, "AGENTS.md"));
2270
+ }
2271
+ /**
2272
+ * Builds the `opencode run` command for a given agent config.
2273
+ *
2274
+ * @remarks `fallbackModel` is not yet supported by opencode.
2275
+ * @see https://github.com/anomalyco/opencode/issues/7602
2276
+ */
2277
+ function buildCommand(options) {
2278
+ const { message, sessionId, agent, mcpConfigPath } = options;
2279
+ ensureWorkspace(mcpConfigPath, agent.systemPrompt);
2280
+ const args = [
2281
+ "run",
2282
+ "--format",
2283
+ "json",
2284
+ "-m",
2285
+ agent.model
2286
+ ];
2287
+ if (agent.systemPrompt) args.push("--agent", "browserbird");
2288
+ if (sessionId) args.push("--session", sessionId);
2289
+ args.push(message);
2290
+ const env = {};
2291
+ const apiKey = process.env["ANTHROPIC_API_KEY"];
2292
+ if (apiKey) env["ANTHROPIC_API_KEY"] = apiKey;
2293
+ const openRouterKey = process.env["OPENROUTER_API_KEY"];
2294
+ if (openRouterKey) env["OPENROUTER_API_KEY"] = openRouterKey;
2295
+ const openAiKey = process.env["OPENAI_API_KEY"];
2296
+ if (openAiKey) env["OPENAI_API_KEY"] = openAiKey;
2297
+ const geminiKey = process.env["GEMINI_API_KEY"];
2298
+ if (geminiKey) env["GEMINI_API_KEY"] = geminiKey;
2299
+ return {
2300
+ binary: "opencode",
2301
+ args,
2302
+ cwd: WORKSPACE_DIR,
2303
+ env
2304
+ };
2305
+ }
2306
+ /** Per-session metric accumulators. Concurrent sessions each get their own entry. */
2307
+ const accumulators = /* @__PURE__ */ new Map();
2308
+ function accumulateStep(sessionId, part) {
2309
+ const acc = accumulators.get(sessionId);
2310
+ acc.stepCount++;
2311
+ const tokens = part["tokens"];
2312
+ const cache = tokens?.["cache"];
2313
+ acc.tokensIn += tokens?.["input"] ?? 0;
2314
+ acc.tokensOut += tokens?.["output"] ?? 0;
2315
+ acc.cacheWrite += cache?.["write"] ?? 0;
2316
+ acc.cacheRead += cache?.["read"] ?? 0;
2317
+ acc.cost += part["cost"] ?? 0;
2318
+ }
2319
+ /**
2320
+ * Parses a single line of opencode JSON output into zero or more StreamEvents.
2321
+ *
2322
+ * OpenCode emits these event types:
2323
+ * step_start -> init (first one carries sessionID)
2324
+ * text -> text_delta
2325
+ * tool_use -> ignored (internal to the agent)
2326
+ * step_finish -> accumulates tokens/cost; final one (reason "stop") emits completion
2327
+ * error -> error (connection/auth failures)
2328
+ */
2329
+ function parseStreamLine(line) {
2330
+ const trimmed = line.trim();
2331
+ if (!trimmed || !trimmed.startsWith("{")) return [];
2332
+ let parsed;
2333
+ try {
2334
+ parsed = JSON.parse(trimmed);
2335
+ } catch {
2336
+ return [];
2337
+ }
2338
+ const eventType = parsed["type"];
2339
+ if (!eventType) return [];
2340
+ const part = parsed["part"];
2341
+ const timestamp = parsed["timestamp"] ?? 0;
2342
+ switch (eventType) {
2343
+ case "step_start":
2344
+ if (part && typeof part["sessionID"] === "string") {
2345
+ const sid = part["sessionID"];
2346
+ if (!accumulators.has(sid)) accumulators.set(sid, {
2347
+ startTimestamp: timestamp,
2348
+ stepCount: 0,
2349
+ tokensIn: 0,
2350
+ tokensOut: 0,
2351
+ cacheWrite: 0,
2352
+ cacheRead: 0,
2353
+ cost: 0
2354
+ });
2355
+ return [{
2356
+ type: "init",
2357
+ sessionId: sid,
2358
+ model: ""
2359
+ }];
2360
+ }
2361
+ return [];
2362
+ case "text":
2363
+ if (part && typeof part["text"] === "string") return [{
2364
+ type: "text_delta",
2365
+ delta: part["text"]
2366
+ }];
2367
+ return [];
2368
+ case "step_finish": {
2369
+ if (!part) return [];
2370
+ const sid = part["sessionID"] ?? "";
2371
+ accumulateStep(sid, part);
2372
+ if (part["reason"] !== "stop") return [];
2373
+ const acc = accumulators.get(sid);
2374
+ const durationMs = timestamp > acc.startTimestamp ? timestamp - acc.startTimestamp : 0;
2375
+ const completion = {
2376
+ type: "completion",
2377
+ subtype: "success",
2378
+ result: "",
2379
+ sessionId: sid,
2380
+ isError: false,
2381
+ tokensIn: acc.tokensIn,
2382
+ tokensOut: acc.tokensOut,
2383
+ cacheCreationTokens: acc.cacheWrite,
2384
+ cacheReadTokens: acc.cacheRead,
2385
+ costUsd: acc.cost,
2386
+ durationMs,
2387
+ numTurns: acc.stepCount
2388
+ };
2389
+ accumulators.delete(sid);
2390
+ return [completion];
2391
+ }
2392
+ case "error": {
2393
+ const err = parsed["error"];
2394
+ const data = err?.["data"];
2395
+ return [{
2396
+ type: "error",
2397
+ error: typeof data?.["message"] === "string" && data["message"] || typeof parsed["message"] === "string" && parsed["message"] || typeof err?.["name"] === "string" && err["name"] || JSON.stringify(parsed)
2398
+ }];
2399
+ }
2400
+ default: return [];
2401
+ }
2402
+ }
2403
+ const opencode = {
2404
+ buildCommand,
2405
+ parseStreamLine
2406
+ };
2407
+
2408
+ //#endregion
2409
+ //#region src/provider/spawn.ts
2410
+ /** @fileoverview Spawn a CLI provider as a subprocess. */
2411
+ const SIGKILL_GRACE_MS = 5e3;
2412
+ const PROVIDERS = {
2413
+ claude,
2414
+ opencode
2415
+ };
2416
+ /** Sends SIGTERM, then SIGKILL after a grace period if the process is still alive. */
2417
+ function gracefulKill(proc) {
2418
+ if (!proc.pid || proc.killed) return;
2419
+ proc.kill("SIGTERM");
2420
+ const escalation = setTimeout(() => {
2421
+ if (!proc.killed) {
2422
+ logger.warn(`process ${proc.pid} did not exit after SIGTERM, sending SIGKILL`);
2423
+ proc.kill("SIGKILL");
2424
+ }
2425
+ }, SIGKILL_GRACE_MS);
2426
+ escalation.unref();
2427
+ proc.on("exit", () => clearTimeout(escalation));
2428
+ }
2429
+ /** Env vars that prevent nested Claude Code sessions. */
2430
+ const STRIPPED_ENV_VARS = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"];
2431
+ /** Strips env vars whose names suggest they hold credentials. */
2432
+ const SENSITIVE_NAME_RE$1 = /KEY|SECRET|TOKEN|PASSWORD/i;
2433
+ function cleanEnv() {
2434
+ const env = {};
2435
+ for (const [key, value] of Object.entries(process.env)) {
2436
+ if (value == null) continue;
2437
+ if (STRIPPED_ENV_VARS.includes(key)) continue;
2438
+ if (SENSITIVE_NAME_RE$1.test(key)) continue;
2439
+ env[key] = value;
2440
+ }
2441
+ return env;
2442
+ }
2443
+ /**
2444
+ * Spawns a provider CLI with streaming output.
2445
+ * Returns an async iterable of parsed stream events and a kill handle.
2446
+ */
2447
+ function spawnProvider(provider, options, signal) {
2448
+ const mod = PROVIDERS[provider];
2449
+ const cmd = mod.buildCommand(options);
2450
+ const timeoutMs = options.agent.processTimeoutMs ?? 3e5;
2451
+ logger.debug(`spawning: ${cmd.binary} ${cmd.args.join(" ")} (timeout: ${timeoutMs}ms)`);
2452
+ const baseEnv = cleanEnv();
2453
+ if (cmd.env) for (const [k, v] of Object.entries(cmd.env)) if (v === "") delete baseEnv[k];
2454
+ else baseEnv[k] = v;
2455
+ const proc = spawn(cmd.binary, cmd.args, {
2456
+ cwd: cmd.cwd ?? process.cwd(),
2457
+ stdio: [
2458
+ "ignore",
2459
+ "pipe",
2460
+ "pipe"
2461
+ ],
2462
+ env: baseEnv
2463
+ });
2464
+ let stderrBuf = "";
2465
+ proc.stderr.on("data", (chunk) => {
2466
+ stderrBuf += chunk.toString("utf-8");
2467
+ });
2468
+ const timeout = setTimeout(() => {
2469
+ logger.warn(`${cmd.binary} timed out after ${timeoutMs}ms, killing`);
2470
+ gracefulKill(proc);
2471
+ }, timeoutMs);
2472
+ const onAbort = () => gracefulKill(proc);
2473
+ signal.addEventListener("abort", onAbort, { once: true });
2474
+ async function* iterate() {
2475
+ let buffer = "";
2476
+ try {
2477
+ yield* parseStdout(proc, mod, buffer, (b) => {
2478
+ buffer = b;
2479
+ });
2480
+ if (buffer.trim()) yield* mod.parseStreamLine(buffer);
2481
+ } finally {
2482
+ clearTimeout(timeout);
2483
+ signal.removeEventListener("abort", onAbort);
2484
+ if (stderrBuf.trim()) logger.debug(`${cmd.binary} stderr: ${stderrBuf.trim()}`);
2485
+ }
2486
+ }
2487
+ return {
2488
+ events: iterate(),
2489
+ kill: () => gracefulKill(proc)
2490
+ };
2491
+ }
2492
+ async function* parseStdout(proc, mod, buffer, setBuffer) {
2493
+ const pending = [];
2494
+ let done = false;
2495
+ let error = null;
2496
+ let resolve = null;
2497
+ proc.stdout.on("data", (chunk) => {
2498
+ pending.push(chunk);
2499
+ resolve?.();
2500
+ });
2501
+ proc.on("error", (err) => {
2502
+ error = err.message;
2503
+ done = true;
2504
+ resolve?.();
2505
+ });
2506
+ proc.on("close", () => {
2507
+ done = true;
2508
+ resolve?.();
2509
+ });
2510
+ while (true) {
2511
+ while (pending.length === 0 && !done) await new Promise((r) => {
2512
+ resolve = r;
2513
+ });
2514
+ if (pending.length === 0 && done) break;
2515
+ while (pending.length > 0) {
2516
+ const data = pending.shift().toString("utf-8");
2517
+ logger.debug(`stdout chunk (${data.length} chars)`);
2518
+ const [lines, remaining] = splitLines(buffer, data);
2519
+ buffer = remaining;
2520
+ setBuffer(buffer);
2521
+ for (const line of lines) yield* mod.parseStreamLine(line);
2522
+ }
2523
+ }
2524
+ if (error) yield {
2525
+ type: "error",
2526
+ error
2527
+ };
2528
+ }
2529
+
2530
+ //#endregion
2531
+ //#region src/core/redact.ts
2532
+ /** @fileoverview Output redaction: scrubs known secrets and token patterns from agent output. */
2533
+ const REDACTED = "[redacted]";
2534
+ const SENSITIVE_NAME_RE = /KEY|SECRET|TOKEN|PASSWORD/i;
2535
+ /**
2536
+ * Token prefix patterns. Each entry is [prefix, minLength] where minLength
2537
+ * is the shortest plausible token including the prefix (avoids false positives
2538
+ * on short strings that happen to start with a prefix).
2539
+ */
2540
+ const TOKEN_PATTERNS = [
2541
+ ["xoxb-", 20],
2542
+ ["xapp-", 20],
2543
+ ["sk-ant-api", 20],
2544
+ ["sk-ant-oat", 20],
2545
+ ["sk-or-", 20]
2546
+ ];
2547
+ let knownSecrets;
2548
+ function collectSecrets() {
2549
+ const secrets = [];
2550
+ for (const [name, value] of Object.entries(process.env)) if (value && SENSITIVE_NAME_RE.test(name) && value.length >= 8) secrets.push(value);
2551
+ secrets.sort((a, b) => b.length - a.length);
2552
+ return secrets;
2553
+ }
2554
+ function getSecrets() {
2555
+ if (!knownSecrets) knownSecrets = collectSecrets();
2556
+ return knownSecrets;
2557
+ }
2558
+ function escapeForRegex(s) {
2559
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2560
+ }
2561
+ function buildPatternRegex() {
2562
+ const parts = TOKEN_PATTERNS.map(([prefix, minLength]) => {
2563
+ return `${escapeForRegex(prefix)}[A-Za-z0-9_\\-]{${minLength - prefix.length},}`;
2564
+ });
2565
+ return new RegExp(parts.join("|"), "g");
2566
+ }
2567
+ const patternRegex = buildPatternRegex();
2568
+ /** Replaces known secret values and token patterns in text with [redacted]. */
2569
+ function redact(text) {
2570
+ if (!text) return text;
2571
+ let result = text;
2572
+ for (const secret of getSecrets()) if (result.includes(secret)) result = result.replaceAll(secret, REDACTED);
2573
+ result = result.replace(patternRegex, REDACTED);
2574
+ return result;
2575
+ }
2576
+
2577
+ //#endregion
2578
+ //#region src/channel/blocks.ts
2579
+ function plain(text) {
2580
+ return {
2581
+ type: "plain_text",
2582
+ text,
2583
+ emoji: true
2584
+ };
2585
+ }
2586
+ function mrkdwn(text) {
2587
+ return {
2588
+ type: "mrkdwn",
2589
+ text
2590
+ };
2591
+ }
2592
+ function header(text) {
2593
+ return {
2594
+ type: "header",
2595
+ text: plain(text)
2596
+ };
2597
+ }
2598
+ function section(text) {
2599
+ return {
2600
+ type: "section",
2601
+ text: mrkdwn(text)
2602
+ };
2603
+ }
2604
+ function fields(...pairs) {
2605
+ return {
2606
+ type: "section",
2607
+ fields: pairs.map(([label, value]) => mrkdwn(`*${label}:*\n${value}`))
2608
+ };
2609
+ }
2610
+ function divider() {
2611
+ return { type: "divider" };
2612
+ }
2613
+ function context(text) {
2614
+ return {
2615
+ type: "context",
2616
+ elements: [mrkdwn(text)]
2617
+ };
2618
+ }
2619
+ const SUBTYPE_LABELS = {
2620
+ error_max_turns: "Warning: Hit turn limit",
2621
+ error_max_budget_usd: "Warning: Hit budget limit",
2622
+ error_during_execution: "Error during execution",
2623
+ error_max_structured_output_retries: "Structured output failed"
2624
+ };
2625
+ /**
2626
+ * Builds a context footer appended to the streaming message on completion.
2627
+ * Uses only non-section blocks (divider + context) so Slack renders the
2628
+ * response text from the top-level `text` field, avoiding the 3000-char
2629
+ * section limit for long agent responses.
2630
+ */
2631
+ function completionFooterBlocks(completion, hasError, birdName, userId) {
2632
+ const parts = [];
2633
+ const subtypeLabel = SUBTYPE_LABELS[completion.subtype];
2634
+ if (subtypeLabel) parts.push(subtypeLabel);
2635
+ if (hasError) parts.push("Error");
2636
+ if (userId) parts.push(`Requested by <@${userId}>`);
2637
+ parts.push(formatDuration(completion.durationMs));
2638
+ parts.push(`${completion.numTurns} turn${completion.numTurns === 1 ? "" : "s"}`);
2639
+ if (birdName) parts.push(birdName);
2640
+ return [divider(), context(parts.join(" | "))];
2641
+ }
2642
+ /**
2643
+ * Standalone completion card for cron/bird results posted to a channel
2644
+ * (not in a streaming thread; these need full context).
2645
+ */
2646
+ function sessionCompleteBlocks(completion, summary, birdName, userId) {
2647
+ const statusText = SUBTYPE_LABELS[completion.subtype] ?? "Success";
2648
+ const blocks = [header(completion.subtype === "success" ? "Session Complete" : "Session Ended"), fields(["Status", statusText], ["Duration", formatDuration(completion.durationMs)], ["Turns", String(completion.numTurns)])];
2649
+ if (summary) {
2650
+ const trimmed = summary.length > 2800 ? summary.slice(0, 2800) + "..." : summary;
2651
+ blocks.push(section(`*Summary:*\n${trimmed}`));
2652
+ }
2653
+ const contextParts = [];
2654
+ if (birdName) contextParts.push(`Bird: *${birdName}*`);
2655
+ if (userId) contextParts.push(`Triggered by <@${userId}>`);
2656
+ contextParts.push(`${completion.tokensIn.toLocaleString()} in / ${completion.tokensOut.toLocaleString()} out tokens`);
2657
+ blocks.push(context(contextParts.join(" | ")));
2658
+ return blocks;
2659
+ }
2660
+ function sessionErrorBlocks(errorMessage, opts) {
2661
+ const blocks = [header("Session Failed")];
2662
+ const sectionBlock = {
2663
+ type: "section",
2664
+ text: mrkdwn(`*Error: ${truncate(errorMessage, 200)}*`)
2665
+ };
2666
+ if (opts?.sessionUid) sectionBlock.accessory = {
2667
+ type: "overflow",
2668
+ action_id: "session_error_overflow",
2669
+ options: [{
2670
+ text: plain("Retry"),
2671
+ value: `retry:${opts.sessionUid}`
2672
+ }, {
2673
+ text: plain("View Logs"),
2674
+ value: `logs:${opts.sessionUid}`
2675
+ }]
2676
+ };
2677
+ blocks.push(sectionBlock);
2678
+ const fieldPairs = [];
2679
+ if (opts?.birdName) fieldPairs.push(["Bird", opts.birdName]);
2680
+ if (opts?.durationMs) fieldPairs.push(["Duration", formatDuration(opts.durationMs)]);
2681
+ if (fieldPairs.length > 0) blocks.push(fields(...fieldPairs));
2682
+ return blocks;
2683
+ }
2684
+ function busyBlocks(activeCount, maxConcurrent) {
2685
+ return [section("*Too many active sessions*"), context(`${activeCount}/${maxConcurrent} slots in use. Try again shortly.`)];
2686
+ }
2687
+ function noAgentBlocks(channelId) {
2688
+ return [section("*No agent configured for this channel*"), context(`Channel: \`${channelId}\``)];
2689
+ }
2690
+ function birdCreateModal(defaults) {
2691
+ const scheduleOptions = [
2692
+ {
2693
+ text: plain("Every hour"),
2694
+ value: "0 * * * *"
2695
+ },
2696
+ {
2697
+ text: plain("Every 6 hours"),
2698
+ value: "0 */6 * * *"
2699
+ },
2700
+ {
2701
+ text: plain("Daily at midnight"),
2702
+ value: "0 0 * * *"
2703
+ },
2704
+ {
2705
+ text: plain("Weekly on Monday"),
2706
+ value: "0 0 * * 1"
2707
+ }
2708
+ ];
2709
+ const initialSchedule = defaults?.schedule ? scheduleOptions.find((o) => o.value === defaults.schedule) : void 0;
2710
+ return {
2711
+ type: "modal",
2712
+ callback_id: "bird_create",
2713
+ title: plain("Create Bird"),
2714
+ submit: plain("Create"),
2715
+ close: plain("Cancel"),
2716
+ blocks: [
2717
+ {
2718
+ type: "input",
2719
+ block_id: "bird_name",
2720
+ label: plain("Name"),
2721
+ element: {
2722
+ type: "plain_text_input",
2723
+ action_id: "name_input",
2724
+ placeholder: plain("e.g., lint-patrol"),
2725
+ ...defaults?.name ? { initial_value: defaults.name } : {}
2726
+ }
2727
+ },
2728
+ {
2729
+ type: "input",
2730
+ block_id: "bird_schedule",
2731
+ label: plain("Schedule"),
2732
+ element: {
2733
+ type: "static_select",
2734
+ action_id: "schedule_select",
2735
+ placeholder: plain("Choose a schedule"),
2736
+ options: scheduleOptions,
2737
+ ...initialSchedule ? { initial_option: initialSchedule } : {}
2738
+ }
2739
+ },
2740
+ {
2741
+ type: "input",
2742
+ block_id: "bird_prompt",
2743
+ label: plain("Prompt"),
2744
+ element: {
2745
+ type: "plain_text_input",
2746
+ action_id: "prompt_input",
2747
+ multiline: true,
2748
+ placeholder: plain("What should this bird do?"),
2749
+ ...defaults?.prompt ? { initial_value: defaults.prompt } : {}
2750
+ }
2751
+ },
2752
+ {
2753
+ type: "input",
2754
+ block_id: "bird_channel",
2755
+ label: plain("Report to Channel"),
2756
+ element: {
2757
+ type: "conversations_select",
2758
+ action_id: "channel_select",
2759
+ default_to_current_conversation: true
2760
+ }
2761
+ },
2762
+ {
2763
+ type: "input",
2764
+ block_id: "bird_enabled",
2765
+ label: plain("Status"),
2766
+ element: {
2767
+ type: "radio_buttons",
2768
+ action_id: "enabled_radio",
2769
+ options: [{
2770
+ text: plain("Enabled"),
2771
+ value: "enabled"
2772
+ }, {
2773
+ text: plain("Disabled"),
2774
+ value: "disabled"
2775
+ }],
2776
+ initial_option: {
2777
+ text: plain("Enabled"),
2778
+ value: "enabled"
2779
+ }
2780
+ }
2781
+ }
2782
+ ]
2783
+ };
2784
+ }
2785
+ function birdListBlocks(birds) {
2786
+ if (birds.length === 0) return [section("*No birds configured*"), context("Use `/bird create` to create your first bird.")];
2787
+ const blocks = [header("Active Birds")];
2788
+ for (const bird of birds) {
2789
+ const status = bird.enabled ? "[on]" : "[off]";
2790
+ const lastRun = bird.lastStatus ?? "never";
2791
+ blocks.push(section(`${status} *${bird.name}*\n\`${bird.schedule}\` | Agent: \`${bird.agentId}\` | Last: ${lastRun}`));
2792
+ }
2793
+ blocks.push(context(`${birds.length} bird${birds.length === 1 ? "" : "s"} total`));
2794
+ return blocks;
2795
+ }
2796
+ function birdLogsBlocks(birdName, flights) {
2797
+ if (flights.length === 0) return [section(`*${birdName}* - No flights yet`), context("Trigger with `/bird fly ${birdName}`")];
2798
+ const blocks = [header(`Flights: ${birdName}`)];
2799
+ const lines = flights.map((f) => {
2800
+ const icon = f.status === "success" ? "[ok]" : f.status === "running" ? "[...]" : "[err]";
2801
+ const duration = f.durationMs ? formatDuration(f.durationMs) : "-";
2802
+ const age = formatAge(f.startedAt);
2803
+ const detail = f.error ? truncate(f.error, 80) : duration;
2804
+ return `${icon} ${shortUid(f.uid)} \`${detail}\` - ${age} ago`;
2805
+ });
2806
+ blocks.push(section(lines.join("\n")));
2807
+ blocks.push(context(`${flights.length} most recent flight${flights.length === 1 ? "" : "s"}`));
2808
+ return blocks;
2809
+ }
2810
+ function formatAge(isoDate) {
2811
+ const normalized = isoDate.endsWith("Z") ? isoDate : isoDate + "Z";
2812
+ const ms = Date.now() - new Date(normalized).getTime();
2813
+ const minutes = Math.floor(ms / 6e4);
2814
+ if (minutes < 60) return `${minutes}m`;
2815
+ const hours = Math.floor(minutes / 60);
2816
+ if (hours < 24) return `${hours}h`;
2817
+ return `${Math.floor(hours / 24)}d`;
2818
+ }
2819
+ function birdFlyBlocks(birdName, userId) {
2820
+ return [section(`*${birdName}* is taking flight...`), context(`Triggered by <@${userId}>`)];
2821
+ }
2822
+ function statusBlocks(opts) {
2823
+ const slackStatus = opts.slackConnected ? "Connected" : "Disconnected";
2824
+ return [header("BrowserBird Status"), fields(["Slack", slackStatus], ["Active Sessions", `${opts.activeCount}/${opts.maxConcurrent}`], ["Birds", String(opts.birdCount)], ["Uptime", opts.uptime])];
2825
+ }
2826
+ function truncate(text, maxLength) {
2827
+ if (text.length <= maxLength) return text;
2828
+ return text.slice(0, maxLength) + "...";
2829
+ }
2830
+
2831
+ //#endregion
2832
+ //#region src/cron/scheduler.ts
2833
+ const TICK_INTERVAL_MS = 6e4;
2834
+ const MAX_SCHEDULE_ERRORS = 3;
2835
+ const systemHandlers = /* @__PURE__ */ new Map();
2836
+ function registerSystemCronJobs(config, retentionDays) {
2837
+ const cleanupName = `${SYSTEM_CRON_PREFIX}db_cleanup__`;
2838
+ const optimizeName = `${SYSTEM_CRON_PREFIX}db_optimize__`;
2839
+ systemHandlers.set(cleanupName, () => {
2840
+ expireStaleSessions(config.sessions.ttlHours);
2841
+ const msgs = deleteOldMessages(retentionDays);
2842
+ const runs = deleteOldCronRuns(retentionDays);
2843
+ const jobs = deleteOldJobs(retentionDays);
2844
+ const logs = deleteOldLogs(retentionDays);
2845
+ if (msgs > 0 || runs > 0 || jobs > 0 || logs > 0) {
2846
+ const summary = `${msgs} messages, ${runs} flight logs, ${jobs} jobs, ${logs} logs older than ${retentionDays}d`;
2847
+ logger.info(`system cleanup: ${summary}`);
2848
+ return summary;
2849
+ }
2850
+ return "nothing to clean";
2851
+ });
2852
+ systemHandlers.set(optimizeName, () => {
2853
+ optimizeDatabase();
2854
+ logger.debug("system optimize: database optimized");
2855
+ return "database optimized";
2856
+ });
2857
+ ensureSystemCronJob(cleanupName, "0 */6 * * *", "Database cleanup");
2858
+ ensureSystemCronJob(optimizeName, "0 3 * * *", "Database optimization");
2859
+ logger.info("system birds registered");
2860
+ }
2861
+ /** Registers the cron_run job handler and starts the scheduler tick loop. */
2862
+ function startScheduler(config, signal, deps) {
2863
+ registerSystemCronJobs(config, config.database.retentionDays);
2864
+ registerHandler("cron_run", async (raw) => {
2865
+ const payload = raw;
2866
+ const agent = config.agents.find((a) => a.id === payload.agentId);
2867
+ if (!agent) throw new Error(`agent "${payload.agentId}" not found`);
2868
+ const { events } = spawnProvider(agent.provider, {
2869
+ message: payload.prompt,
2870
+ agent,
2871
+ mcpConfigPath: config.browser.mcpConfigPath
2872
+ }, signal);
2873
+ let result = "";
2874
+ let completion;
2875
+ for await (const event of events) if (event.type === "text_delta") result += redact(event.delta);
2876
+ else if (event.type === "completion") completion = event;
2877
+ else if (event.type === "rate_limit") logger.debug(`bird ${shortUid(payload.cronJobUid)} rate limit window resets ${(/* @__PURE__ */ new Date(event.resetsAt * 1e3)).toISOString()}`);
2878
+ else if (event.type === "error") {
2879
+ const safeError = redact(event.error);
2880
+ if (payload.channelId && deps?.postToSlack) {
2881
+ const blocks = sessionErrorBlocks(safeError, { birdName: agent.name });
2882
+ await deps.postToSlack(payload.channelId, `Bird failed: ${safeError}`, { blocks });
2883
+ }
2884
+ throw new Error(safeError);
2885
+ }
2886
+ if (!result) {
2887
+ logger.info(`bird ${shortUid(payload.cronJobUid)} completed (no output)`);
2888
+ return "completed (no output)";
2889
+ }
2890
+ if (payload.channelId && deps?.postToSlack) {
2891
+ if (completion) {
2892
+ const summary = result.length > 2800 ? result.slice(0, 2800) + "..." : result;
2893
+ const blocks = sessionCompleteBlocks(completion, summary, agent.name);
2894
+ const fallback = `Bird ${agent.name} completed: ${completion.numTurns} turns`;
2895
+ await deps.postToSlack(payload.channelId, fallback, { blocks });
2896
+ } else await deps.postToSlack(payload.channelId, result);
2897
+ logger.info(`bird ${shortUid(payload.cronJobUid)} result posted to ${payload.channelId}`);
2898
+ } else logger.info(`bird ${shortUid(payload.cronJobUid)} completed (${result.length} chars)`);
2899
+ return result;
2900
+ });
2901
+ registerHandler("system_cron_run", (raw) => {
2902
+ const payload = raw;
2903
+ const handler = systemHandlers.get(payload.cronName);
2904
+ if (!handler) throw new Error(`no system handler for "${payload.cronName}"`);
2905
+ const result = handler();
2906
+ logger.info(`${payload.cronName}: ${result ?? "done"}`);
2907
+ return result ?? void 0;
2908
+ });
2909
+ const scheduleCache = /* @__PURE__ */ new Map();
2910
+ const scheduleErrors = /* @__PURE__ */ new Map();
2911
+ const tick = () => {
2912
+ if (signal.aborted) return;
2913
+ const now = /* @__PURE__ */ new Date();
2914
+ const jobs = getEnabledCronJobs();
2915
+ for (const job of jobs) {
2916
+ let schedule = scheduleCache.get(job.uid);
2917
+ if (!schedule) try {
2918
+ schedule = parseCron(job.schedule);
2919
+ scheduleCache.set(job.uid, schedule);
2920
+ scheduleErrors.delete(job.uid);
2921
+ } catch {
2922
+ const count = (scheduleErrors.get(job.uid) ?? 0) + 1;
2923
+ scheduleErrors.set(job.uid, count);
2924
+ if (count >= MAX_SCHEDULE_ERRORS) {
2925
+ logger.warn(`bird ${shortUid(job.uid)}: invalid expression "${job.schedule}" (${count} consecutive failures), disabling`);
2926
+ setCronJobEnabled(job.uid, false);
2927
+ scheduleErrors.delete(job.uid);
2928
+ } else logger.warn(`bird ${shortUid(job.uid)}: invalid expression "${job.schedule}" (attempt ${count}/${MAX_SCHEDULE_ERRORS})`);
2929
+ continue;
2930
+ }
2931
+ if (!matchesCron(schedule, now, job.timezone)) continue;
2932
+ if (!isWithinActiveHours(job.active_hours_start, job.active_hours_end, now, job.timezone)) {
2933
+ logger.debug(`bird ${shortUid(job.uid)} skipped: outside active hours`);
2934
+ continue;
2935
+ }
2936
+ if (hasPendingCronJob(job.uid)) {
2937
+ logger.debug(`bird ${shortUid(job.uid)} skipped: previous run still pending or running`);
2938
+ continue;
2939
+ }
2940
+ const isSystem = job.name.startsWith(SYSTEM_CRON_PREFIX);
2941
+ logger.info(`bird ${shortUid(job.uid)} triggered: "${job.schedule}"`);
2942
+ if (isSystem) enqueue("system_cron_run", {
2943
+ cronJobUid: job.uid,
2944
+ cronName: job.name
2945
+ }, {
2946
+ maxAttempts: 3,
2947
+ timeout: 300,
2948
+ cronJobUid: job.uid
2949
+ });
2950
+ else enqueue("cron_run", {
2951
+ cronJobUid: job.uid,
2952
+ prompt: job.prompt,
2953
+ channelId: job.target_channel_id,
2954
+ agentId: job.agent_id
2955
+ }, {
2956
+ maxAttempts: config.birds.maxAttempts,
2957
+ timeout: 600,
2958
+ cronJobUid: job.uid
2959
+ });
2960
+ updateCronJobStatus(job.uid, "triggered", job.failure_count);
2961
+ broadcastSSE("invalidate", {
2962
+ resource: "birds",
2963
+ cronJobUid: job.uid
2964
+ });
2965
+ }
2966
+ };
2967
+ tick();
2968
+ const timer = setInterval(tick, TICK_INTERVAL_MS);
2969
+ signal.addEventListener("abort", () => {
2970
+ clearInterval(timer);
2971
+ scheduleCache.clear();
2972
+ scheduleErrors.clear();
2973
+ });
2974
+ logger.info("bird scheduler started (60s tick)");
2975
+ }
2976
+
2977
+ //#endregion
2978
+ //#region src/channel/coalesce.ts
2979
+ function createCoalescer(config, onDispatch) {
2980
+ const pending = /* @__PURE__ */ new Map();
2981
+ function fire(key) {
2982
+ const batch = pending.get(key);
2983
+ if (!batch) return;
2984
+ pending.delete(key);
2985
+ const [channelId, threadTs] = key.split(":");
2986
+ onDispatch({
2987
+ channelId,
2988
+ threadTs,
2989
+ messages: batch.messages
2990
+ });
2991
+ }
2992
+ function push(channelId, threadTs, userId, text, messageTs) {
2993
+ const key = `${channelId}:${threadTs}`;
2994
+ const message = {
2995
+ userId,
2996
+ text,
2997
+ timestamp: messageTs
2998
+ };
2999
+ const existing = pending.get(key);
3000
+ if (existing) {
3001
+ clearTimeout(existing.timer);
3002
+ existing.messages.push(message);
3003
+ existing.timer = setTimeout(() => fire(key), config.debounceMs);
3004
+ } else {
3005
+ const timer = setTimeout(() => fire(key), config.debounceMs);
3006
+ pending.set(key, {
3007
+ messages: [message],
3008
+ timer
3009
+ });
3010
+ }
3011
+ }
3012
+ function flush() {
3013
+ for (const key of [...pending.keys()]) {
3014
+ const batch = pending.get(key);
3015
+ if (batch) clearTimeout(batch.timer);
3016
+ fire(key);
3017
+ }
3018
+ }
3019
+ function destroy() {
3020
+ for (const batch of pending.values()) clearTimeout(batch.timer);
3021
+ pending.clear();
3022
+ }
3023
+ return {
3024
+ push,
3025
+ flush,
3026
+ destroy
3027
+ };
3028
+ }
3029
+
3030
+ //#endregion
3031
+ //#region src/channel/handler.ts
3032
+ function createHandler(client, config, signal, getTeamId) {
3033
+ const locks = /* @__PURE__ */ new Map();
3034
+ let activeSpawns = 0;
3035
+ function getLock(key) {
3036
+ let lock = locks.get(key);
3037
+ if (!lock) {
3038
+ lock = {
3039
+ processing: false,
3040
+ queue: [],
3041
+ killCurrent: null
3042
+ };
3043
+ locks.set(key, lock);
3044
+ }
3045
+ return lock;
3046
+ }
3047
+ function formatPrompt(messages) {
3048
+ if (messages.length === 1) return messages[0].text;
3049
+ return messages.map((m) => {
3050
+ return `[${(/* @__PURE__ */ new Date(Number(m.timestamp) * 1e3)).toISOString().slice(11, 19)}] @${m.userId}: ${m.text}`;
3051
+ }).join("\n");
3052
+ }
3053
+ async function streamToChannel(events, channelId, threadTs, sessionUid, teamId, userId, meta) {
3054
+ const streamer = client.startStream({
3055
+ channelId,
3056
+ threadTs,
3057
+ teamId,
3058
+ userId
3059
+ });
3060
+ let fullText = "";
3061
+ let completion;
3062
+ let hasError = false;
3063
+ for await (const event of events) {
3064
+ if (signal.aborted) break;
3065
+ logger.debug(`stream event: ${event.type}`);
3066
+ switch (event.type) {
3067
+ case "init":
3068
+ updateSessionProviderId(sessionUid, event.sessionId);
3069
+ break;
3070
+ case "text_delta": {
3071
+ const safe = redact(event.delta);
3072
+ fullText += safe;
3073
+ await streamer.append({ markdown_text: safe });
3074
+ break;
3075
+ }
3076
+ case "tool_images":
3077
+ await uploadImages(event.images, channelId, threadTs);
3078
+ break;
3079
+ case "completion":
3080
+ completion = event;
3081
+ logger.info(`completion [${event.subtype}]: ${event.tokensIn}in/${event.tokensOut}out, $${event.costUsd.toFixed(4)}, ${event.numTurns} turns`);
3082
+ logMessage(channelId, threadTs, "bot", "out", fullText, event.tokensIn, event.tokensOut);
3083
+ break;
3084
+ case "rate_limit":
3085
+ logger.debug(`rate limit window resets ${(/* @__PURE__ */ new Date(event.resetsAt * 1e3)).toISOString()}`);
3086
+ break;
3087
+ case "error": {
3088
+ hasError = true;
3089
+ const safeError = redact(event.error);
3090
+ logger.error(`agent error: ${safeError}`);
3091
+ insertLog("error", "spawn", safeError, channelId);
3092
+ await streamer.append({ markdown_text: `\n\nError: ${safeError}` });
3093
+ break;
3094
+ }
3095
+ }
3096
+ }
3097
+ const footerBlocks = completion ? completionFooterBlocks(completion, hasError, meta.birdName, userId) : void 0;
3098
+ await streamer.stop(footerBlocks ? { blocks: footerBlocks } : {});
3099
+ }
3100
+ async function uploadImages(images, channelId, threadTs) {
3101
+ for (let i = 0; i < images.length; i++) {
3102
+ const img = images[i];
3103
+ const content = Buffer.from(img.data, "base64");
3104
+ const ext = img.mediaType === "image/jpeg" ? "jpg" : "png";
3105
+ const filename = `screenshot-${i + 1}.${ext}`;
3106
+ try {
3107
+ await client.uploadFile(channelId, threadTs, content, filename, `Screenshot ${i + 1}`);
3108
+ } catch (err) {
3109
+ logger.warn(`image upload failed: ${err instanceof Error ? err.message : String(err)}`);
3110
+ }
3111
+ }
3112
+ }
3113
+ async function handle(dispatch) {
3114
+ const { channelId, threadTs, messages } = dispatch;
3115
+ const key = `${channelId}:${threadTs}`;
3116
+ const lock = getLock(key);
3117
+ if (lock.processing) {
3118
+ lock.queue.push(dispatch);
3119
+ try {
3120
+ await client.postEphemeral(channelId, threadTs, messages[messages.length - 1].userId, "Got it, I'll get to this after my current response.");
3121
+ } catch {}
3122
+ return;
3123
+ }
3124
+ if (activeSpawns >= config.sessions.maxConcurrent) {
3125
+ const blocks = busyBlocks(activeSpawns, config.sessions.maxConcurrent);
3126
+ await client.postMessage(channelId, threadTs, "Too many active sessions. Try again shortly.", { blocks });
3127
+ logger.warn("max concurrent sessions reached");
3128
+ return;
3129
+ }
3130
+ lock.processing = true;
3131
+ activeSpawns++;
3132
+ let sessionUid;
3133
+ try {
3134
+ const resolved = resolveSession(channelId, threadTs, config);
3135
+ if (!resolved) {
3136
+ const blocks = noAgentBlocks(channelId);
3137
+ await client.postMessage(channelId, threadTs, "No agent configured for this channel.", { blocks });
3138
+ return;
3139
+ }
3140
+ const { session, agent, isNew } = resolved;
3141
+ sessionUid = session.uid;
3142
+ for (const msg of messages) logMessage(channelId, threadTs, msg.userId, "in", msg.text);
3143
+ touchSession(session.uid, messages.length + 1);
3144
+ broadcastSSE("invalidate", { resource: "sessions" });
3145
+ const prompt = formatPrompt(messages);
3146
+ const userId = messages[messages.length - 1].userId;
3147
+ const existingSessionId = isNew ? void 0 : session.provider_session_id || void 0;
3148
+ const { events, kill } = spawnProvider(agent.provider, {
3149
+ message: prompt,
3150
+ sessionId: existingSessionId,
3151
+ agent,
3152
+ mcpConfigPath: config.browser.mcpConfigPath
3153
+ }, signal);
3154
+ lock.killCurrent = kill;
3155
+ client.setStatus?.(channelId, threadTs, "is thinking...").catch(() => {});
3156
+ if (isNew) {
3157
+ const title = prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt;
3158
+ client.setTitle?.(channelId, threadTs, title).catch(() => {});
3159
+ }
3160
+ await streamToChannel(events, channelId, threadTs, session.uid, getTeamId(), userId, { birdName: agent.name });
3161
+ } catch (err) {
3162
+ const errMsg = err instanceof Error ? err.message : String(err);
3163
+ logger.error(`handler error: ${errMsg}`);
3164
+ insertLog("error", "handler", errMsg, channelId);
3165
+ try {
3166
+ const blocks = sessionErrorBlocks(errMsg, { sessionUid });
3167
+ await client.postMessage(channelId, threadTs, `Something went wrong: ${errMsg}`, { blocks });
3168
+ } catch {}
3169
+ } finally {
3170
+ activeSpawns--;
3171
+ lock.processing = false;
3172
+ lock.killCurrent = null;
3173
+ const next = lock.queue.shift();
3174
+ if (next) handle(next).catch((err) => {
3175
+ logger.error(`dispatch error: ${err instanceof Error ? err.message : String(err)}`);
3176
+ });
3177
+ else if (lock.queue.length === 0) locks.delete(key);
3178
+ }
3179
+ }
3180
+ function activeCount() {
3181
+ return activeSpawns;
3182
+ }
3183
+ function killAll() {
3184
+ for (const lock of locks.values()) {
3185
+ lock.killCurrent?.();
3186
+ lock.queue.length = 0;
3187
+ }
3188
+ locks.clear();
3189
+ }
3190
+ return {
3191
+ handle,
3192
+ activeCount,
3193
+ killAll
3194
+ };
3195
+ }
3196
+
3197
+ //#endregion
3198
+ //#region src/channel/commands.ts
3199
+ const startTime = Date.now();
3200
+ function formatUptime() {
3201
+ const ms = Date.now() - startTime;
3202
+ const hours = Math.floor(ms / 36e5);
3203
+ const minutes = Math.floor(ms % 36e5 / 6e4);
3204
+ if (hours === 0) return `${minutes}m`;
3205
+ return `${hours}h ${minutes}m`;
3206
+ }
3207
+ async function handleSlashCommand(body, webClient, channelClient, config, status) {
3208
+ const parts = body.text.trim().split(/\s+/);
3209
+ const subcommand = parts[0] ?? "help";
3210
+ async function say(message) {
3211
+ await webClient.chat.postMessage({
3212
+ channel: body.channel_id,
3213
+ text: message.text,
3214
+ ...message.blocks ? { blocks: message.blocks } : {}
3215
+ });
3216
+ }
3217
+ function findBird(nameOrUid) {
3218
+ const byUid = resolveByUid("cron_jobs", nameOrUid);
3219
+ if (byUid && "row" in byUid) return byUid.row;
3220
+ return listCronJobs(1, 100, false).items.find((b) => b.name === nameOrUid);
3221
+ }
3222
+ switch (subcommand) {
3223
+ case "list": {
3224
+ const birds = listCronJobs(1, 20, false).items.map((b) => ({
3225
+ uid: b.uid,
3226
+ name: b.name,
3227
+ schedule: b.schedule,
3228
+ enabled: b.enabled === 1,
3229
+ lastStatus: b.last_status,
3230
+ agentId: b.agent_id
3231
+ }));
3232
+ const blocks = birdListBlocks(birds);
3233
+ await say({
3234
+ text: `${birds.length} bird${birds.length === 1 ? "" : "s"} configured`,
3235
+ blocks
3236
+ });
3237
+ break;
3238
+ }
3239
+ case "fly": {
3240
+ const birdName = parts.slice(1).join(" ");
3241
+ if (!birdName) {
3242
+ await say({ text: "Usage: `/bird fly <name or id>`" });
3243
+ return;
3244
+ }
3245
+ const bird = findBird(birdName);
3246
+ if (!bird) {
3247
+ await say({ text: `Bird not found: \`${birdName}\`` });
3248
+ return;
3249
+ }
3250
+ enqueue("cron_run", {
3251
+ cronJobUid: bird.uid,
3252
+ prompt: bird.prompt,
3253
+ channelId: bird.target_channel_id,
3254
+ agentId: bird.agent_id
3255
+ }, {
3256
+ maxAttempts: config.birds.maxAttempts,
3257
+ timeout: 600,
3258
+ cronJobUid: bird.uid
3259
+ });
3260
+ const blocks = birdFlyBlocks(bird.name, body.user_id);
3261
+ await say({
3262
+ text: `${bird.name} is taking flight...`,
3263
+ blocks
3264
+ });
3265
+ logger.info(`/bird fly: ${bird.name} triggered by ${body.user_id}`);
3266
+ break;
3267
+ }
3268
+ case "enable":
3269
+ case "disable": {
3270
+ const birdName = parts.slice(1).join(" ");
3271
+ if (!birdName) {
3272
+ await say({ text: `Usage: \`/bird ${subcommand} <name or id>\`` });
3273
+ return;
3274
+ }
3275
+ const bird = findBird(birdName);
3276
+ if (!bird) {
3277
+ await say({ text: `Bird not found: \`${birdName}\`` });
3278
+ return;
3279
+ }
3280
+ const enabling = subcommand === "enable";
3281
+ if (bird.enabled === 1 === enabling) {
3282
+ await say({ text: `*${bird.name}* is already ${subcommand}d.` });
3283
+ return;
3284
+ }
3285
+ setCronJobEnabled(bird.uid, enabling);
3286
+ await say({ text: `*${bird.name}* ${subcommand}d.` });
3287
+ logger.info(`/bird ${subcommand}: ${bird.name} by ${body.user_id}`);
3288
+ break;
3289
+ }
3290
+ case "logs": {
3291
+ const birdName = parts.slice(1).join(" ");
3292
+ if (!birdName) {
3293
+ await say({ text: "Usage: `/bird logs <name or id>`" });
3294
+ return;
3295
+ }
3296
+ const bird = findBird(birdName);
3297
+ if (!bird) {
3298
+ await say({ text: `Bird not found: \`${birdName}\`` });
3299
+ return;
3300
+ }
3301
+ const flights = listFlights(1, 5, { birdUid: bird.uid });
3302
+ const mapped = flights.items.map((f) => {
3303
+ const durationMs = f.finished_at && f.started_at ? new Date(f.finished_at).getTime() - new Date(f.started_at).getTime() : void 0;
3304
+ return {
3305
+ uid: f.uid,
3306
+ status: f.status,
3307
+ startedAt: f.started_at,
3308
+ durationMs,
3309
+ error: f.error ?? void 0
3310
+ };
3311
+ });
3312
+ const blocks = birdLogsBlocks(bird.name, mapped);
3313
+ await say({
3314
+ text: `${flights.totalItems} flight${flights.totalItems === 1 ? "" : "s"} for ${bird.name}`,
3315
+ blocks
3316
+ });
3317
+ break;
3318
+ }
3319
+ case "create": {
3320
+ if (!channelClient.openModal) {
3321
+ await say({ text: "Modals are not supported by this adapter." });
3322
+ return;
3323
+ }
3324
+ const modal = birdCreateModal();
3325
+ await channelClient.openModal(body.trigger_id, modal);
3326
+ break;
3327
+ }
3328
+ case "status": {
3329
+ const cronJobs = listCronJobs(1, 1, false);
3330
+ await say({
3331
+ text: "BrowserBird status",
3332
+ blocks: statusBlocks({
3333
+ slackConnected: status.slackConnected(),
3334
+ activeCount: status.activeCount(),
3335
+ maxConcurrent: config.sessions.maxConcurrent,
3336
+ birdCount: cronJobs.totalItems,
3337
+ uptime: formatUptime()
3338
+ })
3339
+ });
3340
+ break;
3341
+ }
3342
+ default: await say({ text: [
3343
+ "*Usage:* `/bird <command>`",
3344
+ "",
3345
+ "`/bird list` - Show all configured birds",
3346
+ "`/bird fly <name>` - Trigger a bird immediately",
3347
+ "`/bird logs <name>` - Show recent flights",
3348
+ "`/bird enable <name>` - Enable a bird",
3349
+ "`/bird disable <name>` - Disable a bird",
3350
+ "`/bird create` - Create a new bird (opens form)",
3351
+ "`/bird status` - Show daemon status"
3352
+ ].join("\n") });
3353
+ }
3354
+ }
3355
+
3356
+ //#endregion
3357
+ //#region src/channel/slack.ts
3358
+ var SlackChannelClient = class {
3359
+ web;
3360
+ constructor(web) {
3361
+ this.web = web;
3362
+ }
3363
+ async postMessage(channelId, threadTs, text, opts) {
3364
+ return (await this.web.chat.postMessage({
3365
+ channel: channelId,
3366
+ thread_ts: threadTs,
3367
+ text,
3368
+ ...opts?.blocks ? { blocks: opts.blocks } : {}
3369
+ })).ts ?? "";
3370
+ }
3371
+ async postEphemeral(channelId, threadTs, userId, text, opts) {
3372
+ await this.web.chat.postEphemeral({
3373
+ channel: channelId,
3374
+ thread_ts: threadTs,
3375
+ user: userId,
3376
+ text,
3377
+ ...opts?.blocks ? { blocks: opts.blocks } : {}
3378
+ });
3379
+ }
3380
+ async openModal(triggerId, view) {
3381
+ await this.web.views.open({
3382
+ trigger_id: triggerId,
3383
+ view
3384
+ });
3385
+ }
3386
+ async uploadFile(channelId, threadTs, content, filename, title) {
3387
+ const upload = await this.web.files.getUploadURLExternal({
3388
+ filename,
3389
+ length: content.byteLength
3390
+ });
3391
+ if (!upload.upload_url || !upload.file_id) return;
3392
+ await fetch(upload.upload_url, {
3393
+ method: "POST",
3394
+ body: new Uint8Array(content)
3395
+ });
3396
+ await this.web.files.completeUploadExternal({
3397
+ files: [{
3398
+ id: upload.file_id,
3399
+ title
3400
+ }],
3401
+ channel_id: channelId,
3402
+ thread_ts: threadTs
3403
+ });
3404
+ }
3405
+ startStream(opts) {
3406
+ return this.web.chatStream({
3407
+ channel: opts.channelId,
3408
+ thread_ts: opts.threadTs,
3409
+ recipient_team_id: opts.teamId,
3410
+ recipient_user_id: opts.userId,
3411
+ task_display_mode: "timeline",
3412
+ buffer_size: 128
3413
+ });
3414
+ }
3415
+ async setStatus(channelId, threadTs, status) {
3416
+ await this.web.assistant.threads.setStatus({
3417
+ channel_id: channelId,
3418
+ thread_ts: threadTs,
3419
+ status
3420
+ });
3421
+ }
3422
+ async setTitle(channelId, threadTs, title) {
3423
+ await this.web.assistant.threads.setTitle({
3424
+ channel_id: channelId,
3425
+ thread_ts: threadTs,
3426
+ title
3427
+ });
3428
+ }
3429
+ async setSuggestedPrompts(channelId, threadTs, prompts) {
3430
+ await this.web.assistant.threads.setSuggestedPrompts({
3431
+ channel_id: channelId,
3432
+ thread_ts: threadTs,
3433
+ prompts
3434
+ });
3435
+ }
3436
+ };
3437
+ const IGNORED_SUBTYPES = new Set([
3438
+ "bot_message",
3439
+ "message_changed",
3440
+ "message_deleted",
3441
+ "message_replied",
3442
+ "channel_join",
3443
+ "channel_leave",
3444
+ "channel_topic",
3445
+ "channel_purpose",
3446
+ "channel_name",
3447
+ "channel_archive",
3448
+ "channel_unarchive",
3449
+ "file_share",
3450
+ "pinned_item",
3451
+ "unpinned_item"
3452
+ ]);
3453
+ const DEDUP_TTL_MS = 3e4;
3454
+ const DEDUP_CLEANUP_MS = 6e4;
3455
+ function logDispatchError(err) {
3456
+ logger.error(`dispatch error: ${err instanceof Error ? err.message : String(err)}`);
3457
+ }
3458
+ function createSlackChannel(config, signal) {
3459
+ const recentEvents = /* @__PURE__ */ new Map();
3460
+ let botUserId = "";
3461
+ let teamId = "";
3462
+ const socketClient = new SocketModeClient({
3463
+ appToken: config.slack.appToken,
3464
+ logLevel: LogLevel.WARN,
3465
+ clientPingTimeout: 15e3,
3466
+ serverPingTimeout: 6e4
3467
+ });
3468
+ const webClient = new WebClient(config.slack.botToken);
3469
+ const channelClient = new SlackChannelClient(webClient);
3470
+ const handler = createHandler(channelClient, config, signal, () => teamId);
3471
+ const coalescer = createCoalescer(config.slack.coalesce, (dispatch) => {
3472
+ handler.handle(dispatch).catch(logDispatchError);
3473
+ });
3474
+ const dedupTimer = setInterval(() => {
3475
+ const cutoff = Date.now() - DEDUP_TTL_MS;
3476
+ for (const [key, ts] of recentEvents) if (ts < cutoff) recentEvents.delete(key);
3477
+ }, DEDUP_CLEANUP_MS);
3478
+ signal.addEventListener("abort", () => {
3479
+ clearInterval(dedupTimer);
3480
+ });
3481
+ function isDuplicate(body) {
3482
+ const eventId = body["event_id"];
3483
+ if (!eventId) return false;
3484
+ if (recentEvents.has(eventId)) return true;
3485
+ recentEvents.set(eventId, Date.now());
3486
+ return false;
3487
+ }
3488
+ socketClient.on("message", async ({ ack, body, event }) => {
3489
+ await ack();
3490
+ if (!event) return;
3491
+ if (event["user"] === botUserId) return;
3492
+ const text = event["text"];
3493
+ if (!text) return;
3494
+ const subtype = event["subtype"];
3495
+ if (subtype && IGNORED_SUBTYPES.has(subtype)) return;
3496
+ if (event["bot_id"]) return;
3497
+ if (isDuplicate(body)) return;
3498
+ const isDm = event["channel_type"] === "im";
3499
+ if (!isDm && config.slack.requireMention) return;
3500
+ const channelId = event["channel"];
3501
+ if (!isChannelAllowed(channelId, config.slack.channels)) return;
3502
+ if (!isDm && isQuietHours(config.slack.quietHours)) return;
3503
+ const threadTs = event["thread_ts"] ?? event["ts"];
3504
+ const userId = event["user"] ?? "unknown";
3505
+ const messageTs = event["ts"];
3506
+ const cleanText = stripMention(text);
3507
+ if (!cleanText.trim()) return;
3508
+ if (isDm && config.slack.coalesce.bypassDms) handler.handle({
3509
+ channelId,
3510
+ threadTs,
3511
+ messages: [{
3512
+ userId,
3513
+ text: cleanText,
3514
+ timestamp: messageTs
3515
+ }]
3516
+ }).catch(logDispatchError);
3517
+ else coalescer.push(channelId, threadTs, userId, cleanText, messageTs);
3518
+ });
3519
+ if (config.slack.requireMention) socketClient.on("app_mention", async ({ ack, body, event }) => {
3520
+ await ack();
3521
+ if (!event) return;
3522
+ if (isDuplicate(body)) return;
3523
+ const channelId = event["channel"];
3524
+ if (!isChannelAllowed(channelId, config.slack.channels)) return;
3525
+ if (isQuietHours(config.slack.quietHours)) return;
3526
+ const messageTs = event["ts"];
3527
+ if (!messageTs) return;
3528
+ const threadTs = event["thread_ts"] ?? messageTs;
3529
+ const userId = event["user"] ?? "unknown";
3530
+ const text = stripMention(event["text"] ?? "");
3531
+ if (!text.trim()) return;
3532
+ coalescer.push(channelId, threadTs, userId, text, messageTs);
3533
+ });
3534
+ socketClient.on("assistant_thread_started", async ({ ack, event }) => {
3535
+ await ack();
3536
+ if (!event) return;
3537
+ const threadInfo = event["assistant_thread"];
3538
+ if (!threadInfo) return;
3539
+ const channelId = threadInfo["channel_id"];
3540
+ const threadTs = threadInfo["thread_ts"];
3541
+ if (!channelId || !threadTs) return;
3542
+ channelClient.setSuggestedPrompts(channelId, threadTs, [
3543
+ {
3544
+ title: "Browse a website",
3545
+ message: "Browse https://example.com and summarize it"
3546
+ },
3547
+ {
3548
+ title: "Run a command",
3549
+ message: "List files in the current directory"
3550
+ },
3551
+ {
3552
+ title: "Help me code",
3553
+ message: "Help me write a function that..."
3554
+ }
3555
+ ]).catch(() => {});
3556
+ });
3557
+ socketClient.on("assistant_thread_context_changed", async ({ ack }) => {
3558
+ await ack();
3559
+ });
3560
+ let connected = false;
3561
+ const statusProvider = {
3562
+ slackConnected: () => connected,
3563
+ activeCount: () => handler.activeCount()
3564
+ };
3565
+ socketClient.on("slash_commands", async ({ ack, body }) => {
3566
+ await ack();
3567
+ const commandBody = body;
3568
+ if (commandBody.command !== "/bird") return;
3569
+ try {
3570
+ await handleSlashCommand(commandBody, webClient, channelClient, config, statusProvider);
3571
+ } catch (err) {
3572
+ logger.error(`/bird command error: ${err instanceof Error ? err.message : String(err)}`);
3573
+ }
3574
+ });
3575
+ socketClient.on("interactive", async ({ ack, body }) => {
3576
+ await ack();
3577
+ const interactionType = body["type"];
3578
+ if (interactionType === "view_submission") {
3579
+ const view = body["view"];
3580
+ if (view?.["callback_id"] === "bird_create") await handleBirdCreateSubmission(view, webClient, config.timezone);
3581
+ }
3582
+ if (interactionType === "block_actions") {
3583
+ const actionsArr = body["actions"];
3584
+ const channel = body["channel"]?.["id"];
3585
+ const user = body["user"]?.["id"];
3586
+ if (!actionsArr || !channel) return;
3587
+ for (const action of actionsArr) {
3588
+ if (action["action_id"] !== "session_error_overflow") continue;
3589
+ const selected = action["selected_option"]?.["value"];
3590
+ if (!selected) continue;
3591
+ if (selected.startsWith("retry:")) {
3592
+ const sessionUid = selected.slice(6);
3593
+ if (!sessionUid) continue;
3594
+ await handleSessionRetry(sessionUid, channel, user ?? "unknown", config, handler);
3595
+ }
3596
+ }
3597
+ }
3598
+ });
3599
+ const MAX_CONSECUTIVE_FAILURES = 5;
3600
+ socketClient.on("connected", () => {
3601
+ connected = true;
3602
+ logger.success("slack connected");
3603
+ });
3604
+ socketClient.on("reconnecting", () => {
3605
+ connected = false;
3606
+ logger.info("slack reconnecting...");
3607
+ });
3608
+ socketClient.on("disconnected", () => {
3609
+ connected = false;
3610
+ logger.warn("slack disconnected");
3611
+ });
3612
+ socketClient.on("close", () => {
3613
+ const failures = socketClient["numOfConsecutiveReconnectionFailures"];
3614
+ if (failures != null && failures > MAX_CONSECUTIVE_FAILURES) {
3615
+ socketClient["numOfConsecutiveReconnectionFailures"] = 1;
3616
+ logger.info("reset reconnection back-off counter");
3617
+ }
3618
+ });
3619
+ async function resolveChannelNames() {
3620
+ const namesToResolve = /* @__PURE__ */ new Set();
3621
+ function collectNames(channels) {
3622
+ for (const ch of channels) if (ch !== "*" && !ch.startsWith("C") && !ch.startsWith("D") && !ch.startsWith("G")) namesToResolve.add(ch);
3623
+ }
3624
+ collectNames(config.slack.channels);
3625
+ for (const agent of config.agents) collectNames(agent.channels);
3626
+ if (namesToResolve.size === 0) return;
3627
+ const nameToId = /* @__PURE__ */ new Map();
3628
+ try {
3629
+ let cursor;
3630
+ do {
3631
+ const result = await webClient.conversations.list({
3632
+ types: "public_channel,private_channel",
3633
+ limit: 200,
3634
+ exclude_archived: true,
3635
+ cursor
3636
+ });
3637
+ for (const ch of result.channels ?? []) if (ch.name && ch.id && namesToResolve.has(ch.name)) nameToId.set(ch.name, ch.id);
3638
+ cursor = result.response_metadata?.next_cursor || void 0;
3639
+ } while (cursor);
3640
+ } catch (err) {
3641
+ logger.warn(`failed to resolve channel names: ${err instanceof Error ? err.message : String(err)}`);
3642
+ return;
3643
+ }
3644
+ function resolveList(channels, label) {
3645
+ return channels.map((ch) => {
3646
+ const resolved = nameToId.get(ch);
3647
+ if (resolved) {
3648
+ logger.info(`${label}: resolved channel "${ch}" -> ${resolved}`);
3649
+ return resolved;
3650
+ }
3651
+ if (namesToResolve.has(ch)) logger.warn(`${label}: channel "${ch}" not found in workspace`);
3652
+ return ch;
3653
+ });
3654
+ }
3655
+ config.slack.channels = resolveList(config.slack.channels, "slack");
3656
+ for (const agent of config.agents) agent.channels = resolveList(agent.channels, `agent "${agent.id}"`);
3657
+ }
3658
+ async function start() {
3659
+ const authResult = await webClient.auth.test();
3660
+ botUserId = authResult.user_id ?? "";
3661
+ teamId = authResult.team_id ?? "";
3662
+ logger.info(`authenticated as ${authResult.user} (team: ${teamId})`);
3663
+ await resolveChannelNames();
3664
+ await socketClient.start();
3665
+ connected = true;
3666
+ }
3667
+ async function stop() {
3668
+ connected = false;
3669
+ coalescer.destroy();
3670
+ handler.killAll();
3671
+ clearInterval(dedupTimer);
3672
+ await socketClient.disconnect();
3673
+ logger.info("slack stopped");
3674
+ }
3675
+ function isConnected() {
3676
+ return connected;
3677
+ }
3678
+ async function postMessage(channel, text, opts) {
3679
+ await webClient.chat.postMessage({
3680
+ channel,
3681
+ text,
3682
+ ...opts?.blocks ? { blocks: opts.blocks } : {}
3683
+ });
3684
+ }
3685
+ return {
3686
+ start,
3687
+ stop,
3688
+ isConnected,
3689
+ activeCount: () => handler.activeCount(),
3690
+ postMessage
3691
+ };
3692
+ }
3693
+ async function handleBirdCreateSubmission(view, webClient, defaultTimezone) {
3694
+ try {
3695
+ const stateValues = view["state"]?.["values"] ?? {};
3696
+ const name = stateValues["bird_name"]?.["name_input"]?.["value"] ?? "";
3697
+ const schedule = (stateValues["bird_schedule"]?.["schedule_select"]?.["selected_option"])?.["value"];
3698
+ const prompt = stateValues["bird_prompt"]?.["prompt_input"]?.["value"] ?? "";
3699
+ const channelId = stateValues["bird_channel"]?.["channel_select"]?.["selected_conversation"] ?? "";
3700
+ const enabledValue = (stateValues["bird_enabled"]?.["enabled_radio"]?.["selected_option"])?.["value"];
3701
+ if (!name || !schedule || !prompt) {
3702
+ logger.warn("bird_create submission missing required fields");
3703
+ return;
3704
+ }
3705
+ const { createCronJob, setCronJobEnabled } = await import("./db-BsYEYsul.mjs").then((n) => n.t);
3706
+ const bird = createCronJob(name, schedule, prompt, channelId || void 0, "default", defaultTimezone);
3707
+ if (enabledValue !== "enabled") setCronJobEnabled(bird.uid, false);
3708
+ await webClient.chat.postMessage({
3709
+ channel: channelId || "general",
3710
+ text: `Bird *${name}* created. Schedule: \`${schedule}\``
3711
+ });
3712
+ logger.info(`bird created via modal: ${name}`);
3713
+ } catch (err) {
3714
+ logger.error(`bird_create submission error: ${err instanceof Error ? err.message : String(err)}`);
3715
+ }
3716
+ }
3717
+ async function handleSessionRetry(sessionUid, channelId, userId, config, handler) {
3718
+ try {
3719
+ const { getSession, getLastInboundMessage } = await import("./db-BsYEYsul.mjs").then((n) => n.t);
3720
+ const session = getSession(sessionUid);
3721
+ if (!session) {
3722
+ logger.warn(`retry: session ${sessionUid} not found`);
3723
+ return;
3724
+ }
3725
+ const lastMsg = getLastInboundMessage(session.channel_id, session.thread_id);
3726
+ if (!lastMsg) {
3727
+ logger.warn(`retry: no inbound message for session ${sessionUid}`);
3728
+ return;
3729
+ }
3730
+ handler.handle({
3731
+ channelId: session.channel_id,
3732
+ threadTs: session.thread_id ?? lastMsg.timestamp,
3733
+ messages: [{
3734
+ userId,
3735
+ text: lastMsg.content,
3736
+ timestamp: lastMsg.timestamp
3737
+ }]
3738
+ }).catch(logDispatchError);
3739
+ logger.info(`retry: session ${sessionUid} re-dispatched by ${userId}`);
3740
+ } catch (err) {
3741
+ logger.error(`retry error: ${err instanceof Error ? err.message : String(err)}`);
3742
+ }
3743
+ }
3744
+ function isChannelAllowed(channelId, channels) {
3745
+ if (channels.includes("*")) return true;
3746
+ return channels.includes(channelId);
3747
+ }
3748
+ function isQuietHours(quietHours) {
3749
+ if (!quietHours.enabled) return false;
3750
+ return isWithinTimeRange(quietHours.start, quietHours.end, /* @__PURE__ */ new Date(), quietHours.timezone);
3751
+ }
3752
+ function stripMention(text) {
3753
+ return text.replace(/^\s*<@[A-Z0-9]+>\s*/i, "").trim();
3754
+ }
3755
+
3756
+ //#endregion
3757
+ //#region src/daemon.ts
3758
+ /** @fileoverview Main orchestrator process: starts all subsystems and handles graceful shutdown. */
3759
+ const controller = new AbortController();
3760
+ const SHUTDOWN_TIMEOUT_MS = 5e3;
3761
+ function setupShutdown() {
3762
+ let shutting = false;
3763
+ const shutdown = (signal) => {
3764
+ if (shutting) {
3765
+ logger.info(`received ${signal} again, forcing exit`);
3766
+ process.exit(1);
3767
+ }
3768
+ shutting = true;
3769
+ logger.info(`received ${signal}, shutting down...`);
3770
+ controller.abort();
3771
+ setTimeout(() => {
3772
+ logger.warn("graceful shutdown timed out, forcing exit");
3773
+ process.exit(1);
3774
+ }, SHUTDOWN_TIMEOUT_MS).unref();
3775
+ };
3776
+ process.on("SIGINT", () => shutdown("SIGINT"));
3777
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
3778
+ }
3779
+ const stubDeps = {
3780
+ slackConnected: () => false,
3781
+ activeProcessCount: () => 0,
3782
+ serviceHealth: () => ({
3783
+ agent: { available: false },
3784
+ browser: { connected: false }
3785
+ })
3786
+ };
3787
+ async function startDaemon(options) {
3788
+ setupShutdown();
3789
+ process.stderr.write(BANNER + "\n\n");
3790
+ if (options.flags.verbose) logger.setLevel("debug");
3791
+ const configPath = resolve(options.flags.config ?? process.env["BROWSERBIRD_CONFIG"] ?? "browserbird.json");
3792
+ const envPath = resolve(".env");
3793
+ openDatabase(resolveDbPath(options.flags.db));
3794
+ startWorker(controller.signal);
3795
+ loadDotEnv(envPath);
3796
+ let currentConfig = loadConfig(configPath);
3797
+ let slackHandle = null;
3798
+ let setupMode = true;
3799
+ const getConfig = () => currentConfig;
3800
+ const getDeps = () => {
3801
+ if (setupMode) return stubDeps;
3802
+ return {
3803
+ slackConnected: () => slackHandle?.isConnected() ?? false,
3804
+ activeProcessCount: () => slackHandle?.activeCount() ?? 0,
3805
+ serviceHealth: () => getServiceHealth(currentConfig)
3806
+ };
3807
+ };
3808
+ const startFull = (config) => {
3809
+ currentConfig = config;
3810
+ setupMode = false;
3811
+ logger.info("connecting to slack...");
3812
+ slackHandle = createSlackChannel(config, controller.signal);
3813
+ logger.info("starting scheduler...");
3814
+ startScheduler(config, controller.signal, { postToSlack: (channel, text, opts) => slackHandle.postMessage(channel, text, opts) });
3815
+ slackHandle.start().catch((err) => {
3816
+ logger.error(`slack failed to start: ${err instanceof Error ? err.message : String(err)}`);
3817
+ });
3818
+ startHealthChecks(config, controller.signal);
3819
+ logger.success("browserbird orchestrator started");
3820
+ logger.info(`agents: ${config.agents.map((a) => a.id).join(", ")}`);
3821
+ logger.info(`max concurrent sessions: ${config.sessions.maxConcurrent}`);
3822
+ if (config.browser.enabled) logger.info(`browser mode: ${process.env["BROWSER_MODE"] ?? "persistent"}`);
3823
+ };
3824
+ const onLaunch = async () => {
3825
+ loadDotEnv(envPath);
3826
+ const config = loadConfig(configPath);
3827
+ if (!config.slack.botToken || !config.slack.appToken) throw new Error("Slack tokens are required to launch");
3828
+ startFull(config);
3829
+ };
3830
+ const reloadConfig = () => {
3831
+ loadDotEnv(envPath);
3832
+ currentConfig = loadConfig(configPath);
3833
+ logger.info("config reloaded");
3834
+ };
3835
+ if (hasSlackTokens(configPath)) {
3836
+ if (!currentConfig.slack.botToken || !currentConfig.slack.appToken) throw new Error("slack tokens not resolvable (set SLACK_BOT_TOKEN/SLACK_APP_TOKEN or configure in browserbird.json)");
3837
+ setSetting("onboarding_completed", "true");
3838
+ startFull(currentConfig);
3839
+ } else {
3840
+ setSetting("onboarding_completed", "");
3841
+ logger.info("starting in setup mode (onboarding not completed)");
3842
+ }
3843
+ let webServer = null;
3844
+ const webConfig = getConfig();
3845
+ if (webConfig.web.enabled) {
3846
+ logger.info(`starting web server on port ${webConfig.web.port}...`);
3847
+ webServer = createWebServer(getConfig, controller.signal, getDeps, {
3848
+ configPath,
3849
+ onLaunch,
3850
+ onConfigReload: reloadConfig
3851
+ });
3852
+ await webServer.start();
3853
+ }
3854
+ if (setupMode) logger.info("waiting for onboarding to complete via web UI");
3855
+ await new Promise((resolvePromise) => {
3856
+ controller.signal.addEventListener("abort", () => {
3857
+ resolvePromise();
3858
+ });
3859
+ });
3860
+ if (webServer) await webServer.stop();
3861
+ if (slackHandle) await Promise.race([slackHandle.stop(), new Promise((r) => setTimeout(r, 3e3))]);
3862
+ closeDatabase();
3863
+ logger.info("browserbird stopped");
3864
+ }
3865
+
3866
+ //#endregion
3867
+ //#region src/cli/sessions.ts
3868
+ /** @fileoverview Sessions command: list and inspect sessions. */
3869
+ const SESSIONS_HELP = `
3870
+ ${c("cyan", "usage:")} browserbird sessions <subcommand> [options]
3871
+
3872
+ manage sessions.
3873
+
3874
+ ${c("dim", "subcommands:")}
3875
+
3876
+ ${c("cyan", "list")} list recent sessions
3877
+ ${c("cyan", "logs")} <uid> show session detail and message history
3878
+
3879
+ ${c("dim", "options:")}
3880
+
3881
+ ${c("yellow", "--json")} output as JSON (with list, logs)
3882
+ ${c("yellow", "--db")} <path> database file path (env: BROWSERBIRD_DB)
3883
+ ${c("yellow", "-h, --help")} show this help
3884
+ `.trim();
3885
+ function handleSessions(argv) {
3886
+ const subcommand = argv[0] ?? "list";
3887
+ const args = argv.slice(1);
3888
+ const dbPath = resolveDbPathFromArgv(argv);
3889
+ const { values, positionals } = parseArgs({
3890
+ args,
3891
+ options: { json: {
3892
+ type: "boolean",
3893
+ default: false
3894
+ } },
3895
+ allowPositionals: true,
3896
+ strict: false
3897
+ });
3898
+ if (subcommand === "list") {
3899
+ openDatabase(dbPath);
3900
+ try {
3901
+ const { items, totalItems } = listSessions(1, 20);
3902
+ if (values.json) {
3903
+ console.log(JSON.stringify(items, null, 2));
3904
+ return;
3905
+ }
3906
+ console.log(`sessions (${totalItems} total):`);
3907
+ if (items.length === 0) {
3908
+ console.log("\n no sessions found");
3909
+ return;
3910
+ }
3911
+ console.log("");
3912
+ printTable([
3913
+ "uid",
3914
+ "channel",
3915
+ "agent",
3916
+ "messages",
3917
+ "last active"
3918
+ ], items.map((s) => [
3919
+ c("dim", shortUid(s.uid)),
3920
+ s.channel_id,
3921
+ s.agent_id,
3922
+ String(s.message_count),
3923
+ s.last_active.slice(0, 19)
3924
+ ]));
3925
+ } finally {
3926
+ closeDatabase();
3927
+ }
3928
+ return;
3929
+ }
3930
+ if (subcommand !== "logs") {
3931
+ unknownSubcommand(subcommand, "sessions", ["list", "logs"]);
3932
+ return;
3933
+ }
3934
+ const uidPrefix = positionals[0];
3935
+ if (!uidPrefix) {
3936
+ logger.error("usage: browserbird sessions logs <uid>");
3937
+ process.exitCode = 1;
3938
+ return;
3939
+ }
3940
+ openDatabase(dbPath);
3941
+ try {
3942
+ const result = resolveByUid("sessions", uidPrefix);
3943
+ if (!result) {
3944
+ logger.error(`session ${uidPrefix} not found`);
3945
+ process.exitCode = 1;
3946
+ return;
3947
+ }
3948
+ if ("ambiguous" in result) {
3949
+ logger.error(`ambiguous session ID "${uidPrefix}" matches ${result.count} sessions, use a longer prefix`);
3950
+ process.exitCode = 1;
3951
+ return;
3952
+ }
3953
+ const session = result.row;
3954
+ const tokenStats = getSessionTokenStats(session.channel_id, session.thread_id);
3955
+ if (values.json) {
3956
+ const msgResult = getSessionMessages(session.channel_id, session.thread_id, 1, 50);
3957
+ console.log(JSON.stringify({
3958
+ session,
3959
+ messages: msgResult.items
3960
+ }, null, 2));
3961
+ return;
3962
+ }
3963
+ console.log(`session ${c("cyan", shortUid(session.uid))}`);
3964
+ console.log(c("dim", "------------------"));
3965
+ console.log(`${c("dim", "uid:")} ${session.uid}`);
3966
+ console.log(`${c("dim", "channel:")} ${session.channel_id}`);
3967
+ console.log(`${c("dim", "thread:")} ${session.thread_id ?? "(none)"}`);
3968
+ console.log(`${c("dim", "agent:")} ${session.agent_id}`);
3969
+ console.log(`${c("dim", "provider id:")} ${session.provider_session_id}`);
3970
+ console.log(`${c("dim", "created:")} ${session.created_at}`);
3971
+ console.log(`${c("dim", "last active:")} ${session.last_active}`);
3972
+ console.log(`${c("dim", "messages:")} ${session.message_count}`);
3973
+ console.log(`${c("dim", "tokens:")} ${tokenStats.totalTokensIn} in / ${tokenStats.totalTokensOut} out`);
3974
+ console.log("");
3975
+ const msgResult = getSessionMessages(session.channel_id, session.thread_id, 1, 50);
3976
+ if (msgResult.items.length === 0) {
3977
+ console.log("no messages recorded.");
3978
+ return;
3979
+ }
3980
+ console.log(`messages (${msgResult.totalItems} total, showing page 1 of ${msgResult.totalPages}):`);
3981
+ console.log("------------------");
3982
+ for (const msg of msgResult.items) {
3983
+ const dir = msg.direction === "in" ? c("green", "->") : c("cyan", "<-");
3984
+ const tokens = msg.tokens_in != null || msg.tokens_out != null ? ` [in:${msg.tokens_in ?? 0} out:${msg.tokens_out ?? 0}]` : "";
3985
+ const preview = (msg.content ?? "").slice(0, 120);
3986
+ const truncated = (msg.content?.length ?? 0) > 120 ? "..." : "";
3987
+ console.log(`${dir} ${msg.user_id} ${msg.created_at}${tokens}`);
3988
+ if (preview) console.log(` ${preview}${truncated}`);
3989
+ }
3990
+ } finally {
3991
+ closeDatabase();
3992
+ }
3993
+ }
3994
+
3995
+ //#endregion
3996
+ //#region src/cli/birds.ts
3997
+ /** @fileoverview Birds command: manage scheduled birds (cron jobs). */
3998
+ const BIRDS_HELP = `
3999
+ ${c("cyan", "usage:")} browserbird birds <subcommand> [options]
4000
+
4001
+ manage scheduled birds.
4002
+
4003
+ ${c("dim", "subcommands:")}
4004
+
4005
+ ${c("cyan", "list")} list all birds
4006
+ ${c("cyan", "add")} <schedule> <prompt> add a new bird
4007
+ ${c("cyan", "edit")} <uid> edit a bird
4008
+ ${c("cyan", "remove")} <uid> remove a bird
4009
+ ${c("cyan", "enable")} <uid> enable a bird
4010
+ ${c("cyan", "disable")} <uid> disable a bird
4011
+ ${c("cyan", "fly")} <uid> trigger a bird manually
4012
+ ${c("cyan", "flights")} <uid> show flight history for a bird
4013
+
4014
+ ${c("dim", "options:")}
4015
+
4016
+ ${c("yellow", "--channel")} <id> target slack channel
4017
+ ${c("yellow", "--agent")} <id> target agent id
4018
+ ${c("yellow", "--schedule")} <expr> cron schedule expression
4019
+ ${c("yellow", "--prompt")} <text> prompt text
4020
+ ${c("yellow", "--timezone")} <tz> IANA timezone (default: UTC)
4021
+ ${c("yellow", "--active-hours")} <range> restrict runs to a time window (e.g. "09:00-17:00")
4022
+ ${c("yellow", "--limit")} <n> number of flights to show (default: 10)
4023
+ ${c("yellow", "--json")} output as JSON (with list, flights)
4024
+ ${c("yellow", "--db")} <path> database file path (env: BROWSERBIRD_DB)
4025
+ ${c("yellow", "-h, --help")} show this help
4026
+ `.trim();
4027
+ function statusColor(status) {
4028
+ if (status == null) return "-";
4029
+ switch (status) {
4030
+ case "success":
4031
+ case "completed": return c("green", status);
4032
+ case "running": return c("blue", status);
4033
+ case "failed": return c("red", status);
4034
+ default: return status;
4035
+ }
4036
+ }
4037
+ function parseActiveHours(raw) {
4038
+ const match = raw.match(/^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})$/);
4039
+ if (!match) return null;
4040
+ return {
4041
+ start: match[1],
4042
+ end: match[2]
4043
+ };
4044
+ }
4045
+ function resolveBird(uidPrefix) {
4046
+ const result = resolveByUid("cron_jobs", uidPrefix);
4047
+ if (!result) {
4048
+ logger.error(`bird ${uidPrefix} not found`);
4049
+ process.exitCode = 1;
4050
+ return;
4051
+ }
4052
+ if ("ambiguous" in result) {
4053
+ logger.error(`ambiguous bird ID "${uidPrefix}" matches ${result.count} birds, use a longer prefix`);
4054
+ process.exitCode = 1;
4055
+ return;
4056
+ }
4057
+ return result.row;
4058
+ }
4059
+ function handleBirds(argv) {
4060
+ const subcommand = argv[0] ?? "list";
4061
+ const { values, positionals } = parseArgs({
4062
+ args: argv.slice(1),
4063
+ options: {
4064
+ channel: { type: "string" },
4065
+ agent: { type: "string" },
4066
+ schedule: { type: "string" },
4067
+ prompt: { type: "string" },
4068
+ timezone: { type: "string" },
4069
+ "active-hours": { type: "string" },
4070
+ limit: { type: "string" },
4071
+ json: {
4072
+ type: "boolean",
4073
+ default: false
4074
+ }
4075
+ },
4076
+ allowPositionals: true,
4077
+ strict: false
4078
+ });
4079
+ openDatabase(resolveDbPathFromArgv(argv));
4080
+ try {
4081
+ switch (subcommand) {
4082
+ case "list": {
4083
+ const result = listCronJobs(1, 100);
4084
+ if (values.json) {
4085
+ console.log(JSON.stringify(result.items, null, 2));
4086
+ break;
4087
+ }
4088
+ console.log(`birds (${result.totalItems} total):`);
4089
+ if (result.items.length === 0) {
4090
+ console.log("\n no birds configured");
4091
+ return;
4092
+ }
4093
+ console.log("");
4094
+ printTable([
4095
+ "uid",
4096
+ "status",
4097
+ "schedule",
4098
+ "agent",
4099
+ "channel",
4100
+ "last",
4101
+ "prompt"
4102
+ ], result.items.map((job) => [
4103
+ c("dim", shortUid(job.uid)),
4104
+ job.enabled ? c("green", "enabled") : c("yellow", "disabled"),
4105
+ job.schedule,
4106
+ job.agent_id,
4107
+ job.target_channel_id ?? "-",
4108
+ statusColor(job.last_status),
4109
+ job.prompt.slice(0, 50)
4110
+ ]), [
4111
+ void 0,
4112
+ void 0,
4113
+ void 0,
4114
+ void 0,
4115
+ void 0,
4116
+ void 0,
4117
+ 50
4118
+ ]);
4119
+ break;
4120
+ }
4121
+ case "add": {
4122
+ const schedule = positionals[0];
4123
+ const prompt = positionals.slice(1).join(" ") || values.prompt;
4124
+ if (!schedule || !prompt) {
4125
+ logger.error("usage: browserbird birds add <schedule> <prompt> [--channel <id>] [--agent <id>]");
4126
+ process.exitCode = 1;
4127
+ return;
4128
+ }
4129
+ const activeHoursRaw = values["active-hours"];
4130
+ let activeStart;
4131
+ let activeEnd;
4132
+ if (activeHoursRaw) {
4133
+ const parsed = parseActiveHours(activeHoursRaw);
4134
+ if (!parsed) {
4135
+ logger.error("--active-hours must be HH:MM-HH:MM (e.g. \"09:00-17:00\")");
4136
+ process.exitCode = 1;
4137
+ return;
4138
+ }
4139
+ activeStart = parsed.start;
4140
+ activeEnd = parsed.end;
4141
+ }
4142
+ const job = createCronJob(deriveBirdName(prompt), schedule, prompt, values.channel, values.agent, values.timezone, activeStart, activeEnd);
4143
+ logger.success(`bird ${shortUid(job.uid)} created: "${schedule}"`);
4144
+ process.stderr.write(c("dim", ` hint: run 'browserbird birds fly ${shortUid(job.uid)}' to trigger it now`) + "\n");
4145
+ break;
4146
+ }
4147
+ case "edit": {
4148
+ const uidPrefix = positionals[0];
4149
+ if (!uidPrefix) {
4150
+ logger.error("usage: browserbird birds edit <uid> [--schedule <expr>] [--prompt <text>] [--channel <id>] [--agent <id>] [--timezone <tz>] [--active-hours <range>]");
4151
+ process.exitCode = 1;
4152
+ return;
4153
+ }
4154
+ const bird = resolveBird(uidPrefix);
4155
+ if (!bird) return;
4156
+ const channel = values.channel;
4157
+ const agent = values.agent;
4158
+ const schedule = values.schedule;
4159
+ const prompt = values.prompt;
4160
+ const timezone = values.timezone;
4161
+ const editActiveHoursRaw = values["active-hours"];
4162
+ let editActiveStart;
4163
+ let editActiveEnd;
4164
+ if (editActiveHoursRaw === "") {
4165
+ editActiveStart = null;
4166
+ editActiveEnd = null;
4167
+ } else if (editActiveHoursRaw) {
4168
+ const parsed = parseActiveHours(editActiveHoursRaw);
4169
+ if (!parsed) {
4170
+ logger.error("--active-hours must be HH:MM-HH:MM (e.g. \"09:00-17:00\"), or \"\" to clear");
4171
+ process.exitCode = 1;
4172
+ return;
4173
+ }
4174
+ editActiveStart = parsed.start;
4175
+ editActiveEnd = parsed.end;
4176
+ }
4177
+ if (!schedule && !prompt && !channel && !agent && !timezone && editActiveStart === void 0) {
4178
+ logger.error("provide at least one of: --schedule, --prompt, --channel, --agent, --timezone, --active-hours");
4179
+ process.exitCode = 1;
4180
+ return;
4181
+ }
4182
+ if (updateCronJob(bird.uid, {
4183
+ schedule,
4184
+ prompt,
4185
+ name: prompt ? deriveBirdName(prompt) : void 0,
4186
+ targetChannelId: channel !== void 0 ? channel || null : void 0,
4187
+ agentId: agent,
4188
+ timezone,
4189
+ activeHoursStart: editActiveStart,
4190
+ activeHoursEnd: editActiveEnd
4191
+ })) {
4192
+ logger.success(`bird ${shortUid(bird.uid)} updated`);
4193
+ process.stderr.write(c("dim", ` hint: run 'browserbird birds list' to see updated birds`) + "\n");
4194
+ } else {
4195
+ logger.error(`bird ${shortUid(bird.uid)} not found`);
4196
+ process.exitCode = 1;
4197
+ }
4198
+ break;
4199
+ }
4200
+ case "remove": {
4201
+ const uidPrefix = positionals[0];
4202
+ if (!uidPrefix) {
4203
+ logger.error("usage: browserbird birds remove <uid>");
4204
+ process.exitCode = 1;
4205
+ return;
4206
+ }
4207
+ const bird = resolveBird(uidPrefix);
4208
+ if (!bird) return;
4209
+ if (deleteCronJob(bird.uid)) logger.success(`bird ${shortUid(bird.uid)} removed`);
4210
+ else {
4211
+ logger.error(`bird ${shortUid(bird.uid)} not found`);
4212
+ process.exitCode = 1;
4213
+ }
4214
+ break;
4215
+ }
4216
+ case "enable":
4217
+ case "disable": {
4218
+ const uidPrefix = positionals[0];
4219
+ if (!uidPrefix) {
4220
+ logger.error(`usage: browserbird birds ${subcommand} <uid>`);
4221
+ process.exitCode = 1;
4222
+ return;
4223
+ }
4224
+ const bird = resolveBird(uidPrefix);
4225
+ if (!bird) return;
4226
+ const enabled = subcommand === "enable";
4227
+ if (setCronJobEnabled(bird.uid, enabled)) {
4228
+ logger.success(`bird ${shortUid(bird.uid)} ${enabled ? "enabled" : "disabled"}`);
4229
+ if (enabled) process.stderr.write(c("dim", ` hint: run 'browserbird birds fly ${shortUid(bird.uid)}' to trigger it now`) + "\n");
4230
+ } else {
4231
+ logger.error(`bird ${shortUid(bird.uid)} not found`);
4232
+ process.exitCode = 1;
4233
+ }
4234
+ break;
4235
+ }
4236
+ case "fly": {
4237
+ const uidPrefix = positionals[0];
4238
+ if (!uidPrefix) {
4239
+ logger.error("usage: browserbird birds fly <uid>");
4240
+ process.exitCode = 1;
4241
+ return;
4242
+ }
4243
+ const cronJob = resolveBird(uidPrefix);
4244
+ if (!cronJob) return;
4245
+ const enqueuedJob = enqueue("cron_run", {
4246
+ cronJobUid: cronJob.uid,
4247
+ prompt: cronJob.prompt,
4248
+ channelId: cronJob.target_channel_id,
4249
+ agentId: cronJob.agent_id
4250
+ }, { cronJobUid: cronJob.uid });
4251
+ logger.success(`enqueued job #${enqueuedJob.id} for bird ${shortUid(cronJob.uid)}`);
4252
+ process.stderr.write(c("dim", ` hint: run 'browserbird birds flights ${shortUid(cronJob.uid)}' to check results`) + "\n");
4253
+ break;
4254
+ }
4255
+ case "flights": {
4256
+ const uidPrefix = positionals[0];
4257
+ if (!uidPrefix) {
4258
+ logger.error("usage: browserbird birds flights <uid>");
4259
+ process.exitCode = 1;
4260
+ return;
4261
+ }
4262
+ const bird = resolveBird(uidPrefix);
4263
+ if (!bird) return;
4264
+ const perPage = values.limit != null ? Number(values.limit) : 10;
4265
+ if (!Number.isFinite(perPage) || perPage < 1) {
4266
+ logger.error("--limit must be a positive number");
4267
+ process.exitCode = 1;
4268
+ return;
4269
+ }
4270
+ const result = listFlights(1, perPage, { birdUid: bird.uid });
4271
+ if (values.json) {
4272
+ console.log(JSON.stringify(result.items, null, 2));
4273
+ break;
4274
+ }
4275
+ console.log(`flight history for bird ${shortUid(bird.uid)} (${result.totalItems} total):`);
4276
+ if (result.items.length === 0) {
4277
+ console.log("\n no flights recorded");
4278
+ return;
4279
+ }
4280
+ console.log("");
4281
+ printTable([
4282
+ "flight",
4283
+ "status",
4284
+ "duration",
4285
+ "started",
4286
+ "error / result"
4287
+ ], result.items.map((flight) => {
4288
+ const durationMs = flight.finished_at ? new Date(flight.finished_at).getTime() - new Date(flight.started_at).getTime() : null;
4289
+ const duration = durationMs == null ? "-" : formatDuration(durationMs);
4290
+ return [
4291
+ c("dim", shortUid(flight.uid)),
4292
+ statusColor(flight.status),
4293
+ duration,
4294
+ flight.started_at.slice(0, 19),
4295
+ flight.error ?? flight.result?.slice(0, 60) ?? ""
4296
+ ];
4297
+ }), [
4298
+ void 0,
4299
+ void 0,
4300
+ void 0,
4301
+ void 0,
4302
+ 60
4303
+ ]);
4304
+ break;
4305
+ }
4306
+ default: unknownSubcommand(subcommand, "birds", [
4307
+ "list",
4308
+ "add",
4309
+ "edit",
4310
+ "remove",
4311
+ "enable",
4312
+ "disable",
4313
+ "fly",
4314
+ "flights"
4315
+ ]);
4316
+ }
4317
+ } finally {
4318
+ closeDatabase();
4319
+ }
4320
+ }
4321
+
4322
+ //#endregion
4323
+ //#region src/cli/logs.ts
4324
+ /** @fileoverview Logs command: show recent log entries from the database. */
4325
+ const LOGS_HELP = `
4326
+ ${c("cyan", "usage:")} browserbird logs [options]
4327
+
4328
+ show recent log entries (default: error level).
4329
+
4330
+ ${c("dim", "options:")}
4331
+
4332
+ ${c("yellow", "--level")} <lvl> filter by level: error, warn, info (default: error)
4333
+ ${c("yellow", "--limit")} <n> number of entries to show (default: 20)
4334
+ ${c("yellow", "--json")} output as JSON
4335
+ ${c("yellow", "--db")} <path> database file path (env: BROWSERBIRD_DB)
4336
+ ${c("yellow", "--config")} <path> config file path
4337
+ ${c("yellow", "-h, --help")} show this help
4338
+ `.trim();
4339
+ function handleLogs(argv) {
4340
+ const { values } = parseArgs({
4341
+ args: argv,
4342
+ options: {
4343
+ level: { type: "string" },
4344
+ limit: { type: "string" },
4345
+ json: {
4346
+ type: "boolean",
4347
+ default: false
4348
+ }
4349
+ },
4350
+ allowPositionals: true,
4351
+ strict: false
4352
+ });
4353
+ const level = values.level;
4354
+ const perPage = values.limit != null ? Number(values.limit) : 20;
4355
+ if (!Number.isFinite(perPage) || perPage < 1) {
4356
+ logger.error("--limit must be a positive number");
4357
+ process.exitCode = 1;
4358
+ return;
4359
+ }
4360
+ openDatabase(resolveDbPathFromArgv(argv));
4361
+ try {
4362
+ const result = getRecentLogs(1, perPage, level ?? "error");
4363
+ if (values.json) {
4364
+ console.log(JSON.stringify(result.items, null, 2));
4365
+ return;
4366
+ }
4367
+ console.log(`logs (${result.totalItems} total, showing ${result.items.length}):`);
4368
+ if (result.items.length === 0) {
4369
+ console.log("\n no logs recorded");
4370
+ return;
4371
+ }
4372
+ console.log("");
4373
+ printTable([
4374
+ "time",
4375
+ "level",
4376
+ "source",
4377
+ "message"
4378
+ ], result.items.map((entry) => [
4379
+ entry.created_at.slice(11, 19),
4380
+ entry.level,
4381
+ entry.source,
4382
+ entry.message
4383
+ ]), [
4384
+ void 0,
4385
+ void 0,
4386
+ void 0,
4387
+ 80
4388
+ ]);
4389
+ } finally {
4390
+ closeDatabase();
4391
+ }
4392
+ }
4393
+
4394
+ //#endregion
4395
+ //#region src/cli/jobs.ts
4396
+ /** @fileoverview Jobs command: inspect and manage the job queue. */
4397
+ const JOBS_HELP = `
4398
+ ${c("cyan", "usage:")} browserbird jobs [subcommand] [options]
4399
+
4400
+ inspect and manage the job queue.
4401
+
4402
+ ${c("dim", "subcommands:")}
4403
+
4404
+ ${c("cyan", "list")} list job queue entries (default)
4405
+ ${c("cyan", "stats")} show job queue statistics
4406
+ ${c("cyan", "retry")} <id> retry a failed job (or --all-failed)
4407
+ ${c("cyan", "delete")} <id> delete a job
4408
+ ${c("cyan", "clear")} clear completed or failed jobs
4409
+
4410
+ ${c("dim", "options:")}
4411
+
4412
+ ${c("yellow", "--status")} <s> filter by status: pending, running, completed, failed (with list)
4413
+ ${c("yellow", "--name")} <s> filter by job name (with list)
4414
+ ${c("yellow", "--all-failed")} retry all failed jobs (with retry)
4415
+ ${c("yellow", "--completed")} clear completed jobs (with clear)
4416
+ ${c("yellow", "--failed")} clear failed jobs (with clear)
4417
+ ${c("yellow", "--json")} output as JSON (with list, stats)
4418
+ ${c("yellow", "--db")} <path> database file path (env: BROWSERBIRD_DB)
4419
+ ${c("yellow", "--config")} <path> config file path
4420
+ ${c("yellow", "-h, --help")} show this help
4421
+ `.trim();
4422
+ function handleJobs(argv) {
4423
+ const { values, positionals } = parseArgs({
4424
+ args: argv,
4425
+ options: {
4426
+ status: { type: "string" },
4427
+ name: { type: "string" },
4428
+ "all-failed": {
4429
+ type: "boolean",
4430
+ default: false
4431
+ },
4432
+ completed: {
4433
+ type: "boolean",
4434
+ default: false
4435
+ },
4436
+ failed: {
4437
+ type: "boolean",
4438
+ default: false
4439
+ },
4440
+ json: {
4441
+ type: "boolean",
4442
+ default: false
4443
+ }
4444
+ },
4445
+ allowPositionals: true,
4446
+ strict: false
4447
+ });
4448
+ const subcommand = positionals[0] ?? "list";
4449
+ openDatabase(resolveDbPathFromArgv(argv));
4450
+ try {
4451
+ switch (subcommand) {
4452
+ case "list": {
4453
+ const result = listJobs(1, 100, {
4454
+ status: values.status,
4455
+ name: values.name
4456
+ });
4457
+ if (values.json) {
4458
+ console.log(JSON.stringify(result.items, null, 2));
4459
+ break;
4460
+ }
4461
+ console.log(`jobs (${result.totalItems} total):`);
4462
+ if (result.items.length === 0) {
4463
+ console.log("\n no jobs found");
4464
+ return;
4465
+ }
4466
+ console.log("");
4467
+ printTable([
4468
+ "id",
4469
+ "status",
4470
+ "name",
4471
+ "attempts",
4472
+ "created",
4473
+ "error"
4474
+ ], result.items.map((job) => [
4475
+ String(job.id),
4476
+ job.status,
4477
+ job.name,
4478
+ `${job.attempts}/${job.max_attempts}`,
4479
+ job.created_at.slice(0, 19),
4480
+ job.error ?? ""
4481
+ ]), [
4482
+ void 0,
4483
+ void 0,
4484
+ 30,
4485
+ void 0,
4486
+ void 0,
4487
+ 40
4488
+ ]);
4489
+ break;
4490
+ }
4491
+ case "stats": {
4492
+ const stats = getJobStats();
4493
+ if (values.json) {
4494
+ console.log(JSON.stringify(stats, null, 2));
4495
+ break;
4496
+ }
4497
+ console.log("job queue statistics");
4498
+ console.log("");
4499
+ console.log(` pending: ${stats.pending}`);
4500
+ console.log(` running: ${stats.running}`);
4501
+ console.log(` completed: ${stats.completed}`);
4502
+ console.log(` failed: ${stats.failed}`);
4503
+ console.log(" --");
4504
+ console.log(` total: ${stats.total}`);
4505
+ break;
4506
+ }
4507
+ case "retry": {
4508
+ if (values["all-failed"]) {
4509
+ const count = retryAllFailedJobs();
4510
+ logger.success(`reset ${count} failed job(s) to pending`);
4511
+ return;
4512
+ }
4513
+ const id = Number(positionals[1]);
4514
+ if (!Number.isFinite(id)) {
4515
+ logger.error("usage: browserbird jobs retry <id> | --all-failed");
4516
+ process.exitCode = 1;
4517
+ return;
4518
+ }
4519
+ if (retryJob(id)) logger.success(`job #${id} reset to pending`);
4520
+ else {
4521
+ logger.error(`job #${id} not found or not in failed state`);
4522
+ process.exitCode = 1;
4523
+ }
4524
+ break;
4525
+ }
4526
+ case "delete": {
4527
+ const id = Number(positionals[1]);
4528
+ if (!Number.isFinite(id)) {
4529
+ logger.error("usage: browserbird jobs delete <id>");
4530
+ process.exitCode = 1;
4531
+ return;
4532
+ }
4533
+ if (deleteJob(id)) logger.success(`job #${id} deleted`);
4534
+ else {
4535
+ logger.error(`job #${id} not found`);
4536
+ process.exitCode = 1;
4537
+ }
4538
+ break;
4539
+ }
4540
+ case "clear": {
4541
+ if (!values.completed && !values.failed) {
4542
+ logger.error("usage: browserbird jobs clear --completed | --failed");
4543
+ process.exitCode = 1;
4544
+ return;
4545
+ }
4546
+ let total = 0;
4547
+ if (values.completed) total += clearJobs("completed");
4548
+ if (values.failed) total += clearJobs("failed");
4549
+ logger.success(`cleared ${total} job(s)`);
4550
+ break;
4551
+ }
4552
+ default: unknownSubcommand(subcommand, "jobs", [
4553
+ "stats",
4554
+ "retry",
4555
+ "delete",
4556
+ "clear"
4557
+ ]);
4558
+ }
4559
+ } finally {
4560
+ closeDatabase();
4561
+ }
4562
+ }
4563
+
4564
+ //#endregion
4565
+ //#region src/cli/config.ts
4566
+ /** @fileoverview Config command: display merged configuration. */
4567
+ const CONFIG_HELP = `
4568
+ ${c("cyan", "usage:")} browserbird config [options]
4569
+
4570
+ view full configuration (defaults merged with browserbird.json).
4571
+
4572
+ ${c("dim", "options:")}
4573
+
4574
+ ${c("yellow", "--config")} <path> config file path
4575
+ ${c("yellow", "-h, --help")} show this help
4576
+ `.trim();
4577
+ function handleConfig(argv) {
4578
+ const { values } = parseArgs({
4579
+ args: argv,
4580
+ options: { config: { type: "string" } },
4581
+ allowPositionals: false,
4582
+ strict: false
4583
+ });
4584
+ printConfig(values.config);
4585
+ }
4586
+ function printConfig(configPath) {
4587
+ const config = loadRawConfig(configPath);
4588
+ console.log(c("cyan", "config"));
4589
+ console.log(c("dim", "------"));
4590
+ console.log(`\n${c("dim", "timezone:")} ${config.timezone}`);
4591
+ console.log(`\n${c("cyan", "agents:")}`);
4592
+ for (const a of config.agents) {
4593
+ console.log(` ${c("cyan", a.id)} (${a.name})`);
4594
+ console.log(` ${c("dim", "provider:")} ${a.provider}`);
4595
+ console.log(` ${c("dim", "model:")} ${a.model}`);
4596
+ console.log(` ${c("dim", "max turns:")} ${a.maxTurns}`);
4597
+ console.log(` ${c("dim", "channels:")} ${a.channels.join(", ") || "*"}`);
4598
+ }
4599
+ console.log(`\n${c("cyan", "sessions:")}`);
4600
+ console.log(` ${c("dim", "max concurrent:")} ${config.sessions.maxConcurrent}`);
4601
+ console.log(` ${c("dim", "ttl:")} ${config.sessions.ttlHours}h`);
4602
+ console.log(` ${c("dim", "timeout:")} ${config.sessions.processTimeoutMs / 1e3}s`);
4603
+ console.log(`\n${c("cyan", "slack:")}`);
4604
+ console.log(` ${c("dim", "require mention:")} ${config.slack.requireMention ? "yes" : "no"}`);
4605
+ console.log(` ${c("dim", "debounce:")} ${config.slack.coalesce.debounceMs}ms`);
4606
+ console.log(` ${c("dim", "bypass dms:")} ${config.slack.coalesce.bypassDms ? "yes" : "no"}`);
4607
+ console.log(` ${c("dim", "channels:")} ${config.slack.channels.join(", ") || "(all)"}`);
4608
+ console.log(` ${c("dim", "quiet hours:")} ${config.slack.quietHours.enabled ? `${config.slack.quietHours.start}-${config.slack.quietHours.end} (${config.slack.quietHours.timezone})` : "disabled"}`);
4609
+ console.log(`\n${c("cyan", "birds:")}`);
4610
+ console.log(` ${c("dim", "max attempts:")} ${config.birds.maxAttempts}`);
4611
+ console.log(`\n${c("cyan", "browser:")}`);
4612
+ console.log(` ${c("dim", "enabled:")} ${config.browser.enabled ? "yes" : "no"}`);
4613
+ if (config.browser.enabled) {
4614
+ console.log(` ${c("dim", "mode:")} ${process.env["BROWSER_MODE"] ?? "persistent"}`);
4615
+ console.log(` ${c("dim", "vnc port:")} ${config.browser.vncPort}`);
4616
+ console.log(` ${c("dim", "novnc port:")} ${config.browser.novncPort}`);
4617
+ }
4618
+ console.log(`\n${c("cyan", "database:")}`);
4619
+ console.log(` ${c("dim", "retention:")} ${config.database.retentionDays}d`);
4620
+ console.log(`\n${c("cyan", "web:")}`);
4621
+ console.log(` ${c("dim", "port:")} ${config.web.port}`);
4622
+ }
4623
+
4624
+ //#endregion
4625
+ //#region src/cli/run.ts
4626
+ /** @fileoverview CLI entry point: argument parsing and command routing. */
4627
+ const MAIN_HELP = BANNER + `
4628
+ ${c("cyan", "usage:")} browserbird [command] [options]
4629
+
4630
+ ${c("dim", "commands:")}
4631
+
4632
+ ${c("cyan", "sessions")} manage sessions
4633
+ ${c("cyan", "birds")} manage scheduled birds
4634
+ ${c("cyan", "config")} view configuration
4635
+ ${c("cyan", "logs")} show recent log entries
4636
+ ${c("cyan", "jobs")} inspect and manage the job queue
4637
+ ${c("cyan", "doctor")} check system dependencies
4638
+
4639
+ ${c("dim", "options:")}
4640
+
4641
+ ${c("yellow", "-h, --help")} show this help
4642
+ ${c("yellow", "-v, --version")} show version
4643
+ ${c("yellow", "--verbose")} enable debug logging
4644
+ ${c("yellow", "--config")} config file path (env: BROWSERBIRD_CONFIG)
4645
+ ${c("yellow", "--db")} database file path (env: BROWSERBIRD_DB)
4646
+
4647
+ run 'browserbird <command> --help' for command-specific options.`.trimEnd();
4648
+ const COMMAND_HELP = {
4649
+ sessions: SESSIONS_HELP,
4650
+ birds: BIRDS_HELP,
4651
+ config: CONFIG_HELP,
4652
+ logs: LOGS_HELP,
4653
+ jobs: JOBS_HELP,
4654
+ doctor: DOCTOR_HELP
4655
+ };
4656
+ async function run(argv) {
4657
+ const command = argv[0];
4658
+ if (command === "--help" || command === "-h") {
4659
+ console.log(MAIN_HELP);
4660
+ return;
4661
+ }
4662
+ if (!command) {
4663
+ await startDaemon(parseGlobalFlags(argv));
4664
+ return;
4665
+ }
4666
+ if (command === "--version" || command === "-v") {
4667
+ console.log(VERSION);
4668
+ return;
4669
+ }
4670
+ const rest = argv.slice(1);
4671
+ const isHelp = rest.includes("--help") || rest.includes("-h");
4672
+ if (command === "--verbose" || command === "--config" || command === "--no-color") {
4673
+ await startDaemon(parseGlobalFlags(argv));
4674
+ return;
4675
+ }
4676
+ switch (command) {
4677
+ case COMMANDS.SESSIONS:
4678
+ if (isHelp) {
4679
+ console.log(COMMAND_HELP.sessions);
4680
+ return;
4681
+ }
4682
+ handleSessions(rest);
4683
+ break;
4684
+ case COMMANDS.BIRDS:
4685
+ if (isHelp) {
4686
+ console.log(COMMAND_HELP.birds);
4687
+ return;
4688
+ }
4689
+ handleBirds(rest);
4690
+ break;
4691
+ case COMMANDS.CONFIG:
4692
+ if (isHelp) {
4693
+ console.log(COMMAND_HELP.config);
4694
+ return;
4695
+ }
4696
+ handleConfig(rest);
4697
+ break;
4698
+ case COMMANDS.LOGS:
4699
+ if (isHelp) {
4700
+ console.log(COMMAND_HELP.logs);
4701
+ return;
4702
+ }
4703
+ handleLogs(rest);
4704
+ break;
4705
+ case COMMANDS.JOBS:
4706
+ if (isHelp) {
4707
+ console.log(COMMAND_HELP.jobs);
4708
+ return;
4709
+ }
4710
+ handleJobs(rest);
4711
+ break;
4712
+ case COMMANDS.DOCTOR:
4713
+ handleDoctor();
4714
+ break;
4715
+ default: unknownSubcommand(command, "", [
4716
+ "sessions",
4717
+ "birds",
4718
+ "config",
4719
+ "logs",
4720
+ "jobs",
4721
+ "doctor"
4722
+ ]);
4723
+ }
4724
+ }
4725
+ function parseGlobalFlags(argv) {
4726
+ const { values } = parseArgs({
4727
+ args: argv,
4728
+ options: {
4729
+ verbose: {
4730
+ type: "boolean",
4731
+ default: false
4732
+ },
4733
+ config: { type: "string" },
4734
+ db: { type: "string" }
4735
+ },
4736
+ allowPositionals: true,
4737
+ strict: false
4738
+ });
4739
+ if (values.verbose) logger.setLevel("debug");
4740
+ return { flags: {
4741
+ verbose: values.verbose,
4742
+ config: values.config,
4743
+ db: values.db
4744
+ } };
4745
+ }
4746
+
4747
+ //#endregion
4748
+ export { checkDoctor, run };