@owloops/browserbird 1.4.15 → 1.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@ Self-hosted AI agent orchestrator with a real browser, a cron scheduler, and a w
25
25
  </tr>
26
26
  </table>
27
27
 
28
- Schedule AI agents to run on a cron, browse the web with a real Chromium browser you can watch live through VNC, and manage everything from a web dashboard or the CLI. Optionally connect Slack for conversational threads and slash commands. BrowserBird is the orchestration layer; the agent CLI ([claude](https://docs.anthropic.com/en/docs/claude-code/overview), [opencode](https://github.com/anomalyco/opencode)) handles reasoning, memory, tools, and sub-agents.
28
+ Schedule AI agents to run on a cron, browse the web with a real Chromium browser you can watch live through VNC, and manage everything from a web dashboard or the CLI. Optionally connect Slack for conversational threads and slash commands. BrowserBird is the orchestration layer; the agent CLI ([Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview)) handles reasoning, memory, tools, and sub-agents.
29
29
 
30
30
  Built by [Owloops](https://github.com/Owloops), building browser automation tools since 2020.
31
31
 
@@ -131,7 +131,6 @@ The top-level `timezone` field (IANA format, default `"UTC"`) is used for cron s
131
131
  {
132
132
  "id": "default",
133
133
  "name": "BrowserBird",
134
- "provider": "claude",
135
134
  "model": "sonnet",
136
135
  "fallbackModel": "haiku",
137
136
  "maxTurns": 50,
@@ -144,9 +143,8 @@ The top-level `timezone` field (IANA format, default `"UTC"`) is used for cron s
144
143
  Each agent is scoped to specific channels. Multiple agents are matched in order, first match wins.
145
144
 
146
145
  - `id`, `name`: Required. Unique identifier and display name
147
- - `provider`: `"claude"` or `"opencode"`
148
- - `model`: Claude uses short names (`sonnet`, `haiku`). OpenCode uses `provider/model` format (`anthropic/claude-sonnet-4-20250514`)
149
- - `fallbackModel`: Fallback when primary is unavailable (claude only)
146
+ - `model`: Short names (`sonnet`, `haiku`) or full model IDs
147
+ - `fallbackModel`: Fallback when primary model is unavailable
150
148
  - `maxTurns`: Max conversation turns per session
151
149
  - `systemPrompt`: Instructions prepended to every session
152
150
  - `channels`: Channel names or IDs this agent handles, or `"*"` for all
@@ -249,17 +247,15 @@ Authentication is handled via the web UI. On first visit, you create an account.
249
247
  | ------------------------- | ------------------------------------------------------------------------------------------------ |
250
248
  | `SLACK_BOT_TOKEN` | Bot user OAuth token (optional, for Slack integration) |
251
249
  | `SLACK_APP_TOKEN` | App-level token for Socket Mode (optional, for Slack integration) |
252
- | `ANTHROPIC_API_KEY` | Anthropic API key (pay-per-token). Used by both claude and opencode providers |
253
- | `CLAUDE_CODE_OAUTH_TOKEN` | OAuth token for claude provider only (uses your Claude Pro/Max subscription) |
250
+ | `ANTHROPIC_API_KEY` | Anthropic API key (pay-per-token) |
251
+ | `CLAUDE_CODE_OAUTH_TOKEN` | OAuth token (uses your Claude Pro/Max subscription) |
254
252
  | `BROWSER_MODE` | `persistent` (default) or `isolated`. Requires container restart |
255
253
  | `BROWSERBIRD_CONFIG` | Path to `browserbird.json`. Overridden by `--config` flag |
256
254
  | `BROWSERBIRD_DB` | Path to SQLite database file. Overridden by `--db` flag |
257
255
  | `NO_COLOR` | Disable colored output |
258
256
 
259
- The **opencode** provider inherits standard env vars per model provider: `OPENAI_API_KEY`, `GEMINI_API_KEY`, `OPENROUTER_API_KEY`, etc. See the full list at [models.dev](https://models.dev).
260
-
261
257
  > [!NOTE]
262
- > **Agent authentication:** `ANTHROPIC_API_KEY` (pay-per-token) is required for shared or commercial deployments per Anthropic's Consumer ToS. `CLAUDE_CODE_OAUTH_TOKEN` is fine for personal self-hosted use (claude provider only). When both are set, the claude provider uses OAuth and the opencode provider uses the API key.
258
+ > **Agent authentication:** `ANTHROPIC_API_KEY` (pay-per-token) is required for shared or commercial deployments per Anthropic's Consumer ToS. `CLAUDE_CODE_OAUTH_TOKEN` is fine for personal self-hosted use. When both are set, OAuth takes priority.
263
259
 
264
260
  ## CLI
265
261
 
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
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 resolveByUid, 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 optimizeDatabase, j as createCronJob, k as SYSTEM_CRON_PREFIX, l as setSetting, m as getMessageStats, n as resolveDbPath, nt as getDb, o as getUserByEmail, ot as logger, p as deleteOldMessages, q as getSession, r as resolveDbPathFromArgv, rt as openDatabase, 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-CyQcrilg.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { parseArgs, styleText } from "node:util";
4
- import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
5
  import { dirname, extname, join, resolve } from "node:path";
6
6
  import { createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
7
7
  import { connect } from "node:net";
@@ -122,8 +122,8 @@ function unknownSubcommand(subcommand, command, validCommands) {
122
122
  /** @fileoverview ASCII banner displayed on daemon startup and in help text. */
123
123
  const pkg = createRequire(import.meta.url)("../package.json");
124
124
  const buildInfo = [];
125
- buildInfo.push(`commit: ${"d1793a3eaee8e6d63526c28dcdca1e3598d384a9".substring(0, 7)}`);
126
- buildInfo.push(`built: 2026-03-13T00:29:35+04:00`);
125
+ buildInfo.push(`commit: ${"b807d937b0d78743f9de1d49acfe9cc32165fe4f".substring(0, 7)}`);
126
+ buildInfo.push(`built: 2026-03-15T01:33:22+04:00`);
127
127
  const buildString = buildInfo.length > 0 ? ` (${buildInfo.join(", ")})` : "";
128
128
  const VERSION = `browserbird ${pkg.version}${buildString}`;
129
129
  const BIRD = [
@@ -138,7 +138,6 @@ const BANNER = BIRD;
138
138
  //#endregion
139
139
  //#region src/config.ts
140
140
  /** @fileoverview Configuration loading from JSON with env: variable resolution. */
141
- const VALID_PROVIDERS$1 = new Set(["claude", "opencode"]);
142
141
  const DEFAULTS = {
143
142
  timezone: "UTC",
144
143
  slack: {
@@ -160,7 +159,6 @@ const DEFAULTS = {
160
159
  agents: [{
161
160
  id: "default",
162
161
  name: "BrowserBird",
163
- provider: "claude",
164
162
  model: "sonnet",
165
163
  maxTurns: 50,
166
164
  systemPrompt: "You are responding in a Slack workspace. Be concise, helpful, and natural.",
@@ -243,7 +241,6 @@ function validateConfig(config) {
243
241
  if (!Array.isArray(config.agents) || config.agents.length === 0) throw new Error("at least one agent must be configured");
244
242
  for (const agent of config.agents) {
245
243
  if (!agent.id || !agent.name) throw new Error("each agent must have an \"id\" and \"name\"");
246
- if (!VALID_PROVIDERS$1.has(agent.provider)) throw new Error(`agent "${agent.id}": unknown provider "${agent.provider}" (expected: ${[...VALID_PROVIDERS$1].join(", ")})`);
247
244
  if (!agent.model) throw new Error(`agent "${agent.id}": "model" is required`);
248
245
  if (!Array.isArray(agent.channels) || agent.channels.length === 0) throw new Error(`agent "${agent.id}": "channels" must be a non-empty array`);
249
246
  if (agent.fallbackModel && agent.fallbackModel === agent.model) throw new Error(`agent "${agent.id}": fallbackModel cannot be the same as model ("${agent.model}")`);
@@ -617,7 +614,7 @@ function c(format, text) {
617
614
  const DOCTOR_HELP = `
618
615
  ${c("cyan", "usage:")} browserbird doctor
619
616
 
620
- check system dependencies (agent clis, node.js).
617
+ check system dependencies (agent cli, node.js).
621
618
  `.trim();
622
619
  function checkCli(binary, versionArgs) {
623
620
  try {
@@ -643,7 +640,6 @@ function checkCli(binary, versionArgs) {
643
640
  function checkDoctor() {
644
641
  return {
645
642
  claude: checkCli("claude", ["--version"]),
646
- opencode: checkCli("opencode", ["--version"]),
647
643
  node: process.version
648
644
  };
649
645
  }
@@ -656,11 +652,6 @@ function handleDoctor() {
656
652
  logger.error("claude cli: not found");
657
653
  process.stderr.write(" install: npm install -g @anthropic-ai/claude-code\n");
658
654
  }
659
- if (result.opencode.available) logger.success(`opencode cli: ${result.opencode.version}`);
660
- else {
661
- logger.warn("opencode cli: not found (optional)");
662
- process.stderr.write(" install: npm install -g opencode\n");
663
- }
664
655
  logger.success(`node.js: ${result.node}`);
665
656
  }
666
657
 
@@ -674,11 +665,10 @@ let agentAvailable = false;
674
665
  let agentCheckedAt = 0;
675
666
  let browserConnected = false;
676
667
  let browserCheckPending = false;
677
- function refreshAgent(config) {
668
+ function refreshAgent() {
678
669
  const now = Date.now();
679
670
  if (now - agentCheckedAt < AGENT_CHECK_INTERVAL_MS) return;
680
- const result = checkDoctor();
681
- agentAvailable = [...new Set(config.agents.map((a) => a.provider))].some((p) => result[p]?.available === true);
671
+ agentAvailable = checkDoctor().claude.available;
682
672
  agentCheckedAt = now;
683
673
  }
684
674
  function probeBrowser(host, port) {
@@ -710,14 +700,14 @@ function refreshBrowser(config) {
710
700
  });
711
701
  }
712
702
  function getServiceHealth(config) {
713
- refreshAgent(config);
703
+ refreshAgent();
714
704
  return {
715
705
  agent: { available: agentAvailable },
716
706
  browser: { connected: config.browser.enabled ? browserConnected : false }
717
707
  };
718
708
  }
719
709
  function startHealthChecks(getConfig, signal) {
720
- refreshAgent(getConfig());
710
+ refreshAgent();
721
711
  refreshBrowser(getConfig());
722
712
  const timer = setInterval(() => {
723
713
  refreshBrowser(getConfig());
@@ -875,7 +865,6 @@ function resolveBirdParam(params, res) {
875
865
  }
876
866
  return result.row;
877
867
  }
878
- const VALID_PROVIDERS = new Set(["claude", "opencode"]);
879
868
  function maskSecret(value) {
880
869
  if (!value) return {
881
870
  set: false,
@@ -909,7 +898,6 @@ function sanitizeConfig(config) {
909
898
  agents: config.agents.map((a) => ({
910
899
  id: a.id,
911
900
  name: a.name,
912
- provider: a.provider,
913
901
  model: a.model,
914
902
  fallbackModel: a.fallbackModel ?? null,
915
903
  maxTurns: a.maxTurns,
@@ -949,7 +937,6 @@ function validateConfigPatch(body) {
949
937
  for (const a of agents) {
950
938
  if (!a["id"] || typeof a["id"] !== "string") return "Each agent must have a string \"id\"";
951
939
  if (!a["name"] || typeof a["name"] !== "string") return "Each agent must have a string \"name\"";
952
- if (!a["provider"] || !VALID_PROVIDERS.has(a["provider"])) return `Agent "${a["id"]}": invalid provider (expected: ${[...VALID_PROVIDERS].join(", ")})`;
953
940
  if (!a["model"] || typeof a["model"] !== "string") return `Agent "${a["id"]}": "model" is required`;
954
941
  if (!Array.isArray(a["channels"]) || a["channels"].length === 0) return `Agent "${a["id"]}": "channels" must be a non-empty array`;
955
942
  }
@@ -1562,7 +1549,6 @@ function buildRoutes(getConfig, startedAt, getDeps, options) {
1562
1549
  json(res, {
1563
1550
  agent: {
1564
1551
  name: defaultAgent.name,
1565
- provider: defaultAgent.provider,
1566
1552
  model: defaultAgent.model,
1567
1553
  systemPrompt: defaultAgent.systemPrompt,
1568
1554
  maxTurns: defaultAgent.maxTurns,
@@ -1658,10 +1644,6 @@ function buildRoutes(getConfig, startedAt, getDeps, options) {
1658
1644
  jsonError(res, "\"name\" is required", 400);
1659
1645
  return;
1660
1646
  }
1661
- if (!body.provider || typeof body.provider !== "string") {
1662
- jsonError(res, "\"provider\" is required", 400);
1663
- return;
1664
- }
1665
1647
  if (!body.model || typeof body.model !== "string") {
1666
1648
  jsonError(res, "\"model\" is required", 400);
1667
1649
  return;
@@ -1671,7 +1653,6 @@ function buildRoutes(getConfig, startedAt, getDeps, options) {
1671
1653
  raw["agents"] = [{
1672
1654
  id: "default",
1673
1655
  name: body.name.trim(),
1674
- provider: body.provider.trim(),
1675
1656
  model: body.model.trim(),
1676
1657
  maxTurns: body.maxTurns ?? DEFAULTS.agents[0].maxTurns,
1677
1658
  systemPrompt: body.systemPrompt?.trim() ?? DEFAULTS.agents[0].systemPrompt,
@@ -2067,16 +2048,20 @@ function startWorker(signal) {
2067
2048
  * Matches an incoming message to the correct agent based on channel config.
2068
2049
  * Agents are checked in order; first match wins.
2069
2050
  * A wildcard `"*"` in the agent's channels array matches everything.
2051
+ * The optional `nameToId` map resolves human-readable channel names to Slack IDs.
2070
2052
  */
2071
- function matchAgent(channelId, agents) {
2072
- for (const agent of agents) for (const pattern of agent.channels) if (pattern === "*" || pattern === channelId) return agent;
2053
+ function matchAgent(channelId, agents, nameToId) {
2054
+ for (const agent of agents) for (const pattern of agent.channels) {
2055
+ if (pattern === "*" || pattern === channelId) return agent;
2056
+ if (nameToId?.get(pattern) === channelId) return agent;
2057
+ }
2073
2058
  }
2074
2059
  /**
2075
2060
  * Looks up or creates a session for the given Slack thread.
2076
2061
  * Returns the session row and whether it was newly created.
2077
2062
  */
2078
- function resolveSession(channelId, threadTs, config) {
2079
- const agent = matchAgent(channelId, config.agents);
2063
+ function resolveSession(channelId, threadTs, config, nameToId) {
2064
+ const agent = matchAgent(channelId, config.agents, nameToId);
2080
2065
  if (!agent) {
2081
2066
  logger.warn(`no agent matched for channel ${channelId}`);
2082
2067
  return null;
@@ -2112,21 +2097,10 @@ function expireStaleSessions(ttlHours) {
2112
2097
  return deleted;
2113
2098
  }
2114
2099
 
2115
- //#endregion
2116
- //#region src/provider/stream.ts
2117
- /**
2118
- * Splits a raw data chunk into lines, handling partial lines across chunks.
2119
- * Returns [completeLines, remainingPartial].
2120
- */
2121
- function splitLines(buffer, chunk) {
2122
- const lines = (buffer + chunk).split("\n");
2123
- return [lines, lines.pop() ?? ""];
2124
- }
2125
-
2126
2100
  //#endregion
2127
2101
  //#region src/provider/claude.ts
2128
2102
  /** @fileoverview Claude Code CLI provider: arg building and stream-json parsing. */
2129
- function buildCommand$1(options) {
2103
+ function buildCommand(options) {
2130
2104
  const { message, sessionId, agent, mcpConfigPath } = options;
2131
2105
  const args = [
2132
2106
  "-p",
@@ -2162,7 +2136,7 @@ function buildCommand$1(options) {
2162
2136
  * Only extracts text, images, completion, and error events. Tool use/result
2163
2137
  * events are internal to the agent and not surfaced to the channel layer.
2164
2138
  */
2165
- function parseStreamLine$1(line) {
2139
+ function parseStreamLine(line) {
2166
2140
  const trimmed = line.trim();
2167
2141
  if (!trimmed || !trimmed.startsWith("{")) return [];
2168
2142
  let parsed;
@@ -2269,226 +2243,22 @@ function extractImages(parsed) {
2269
2243
  }];
2270
2244
  return [];
2271
2245
  }
2272
- const claude = {
2273
- buildCommand: buildCommand$1,
2274
- parseStreamLine: parseStreamLine$1
2275
- };
2276
2246
 
2277
2247
  //#endregion
2278
- //#region src/provider/opencode.ts
2279
- /** @fileoverview OpenCode CLI provider: arg building, workspace setup, and JSON stream parsing. */
2280
- const WORKSPACE_DIR = resolve(".browserbird", "opencode");
2281
- /**
2282
- * Translates a Claude-format MCP config into an opencode-format config.
2283
- *
2284
- * Claude format: { mcpServers: { name: { type: "sse", url: "..." } } }
2285
- * OpenCode format: { mcp: { name: { type: "remote", url: "..." } } }
2286
- */
2287
- function translateMcpConfig(claudeConfig) {
2288
- const servers = claudeConfig["mcpServers"] ?? {};
2289
- const result = {};
2290
- for (const [name, server] of Object.entries(servers)) {
2291
- const serverType = server["type"];
2292
- const url = server["url"];
2293
- const command = server["command"];
2294
- const args = server["args"];
2295
- if (serverType === "sse" || serverType === "streamable-http") {
2296
- if (url) result[name] = {
2297
- type: "remote",
2298
- url
2299
- };
2300
- } else if (serverType === "stdio") {
2301
- if (command) {
2302
- const entry = {
2303
- type: "local",
2304
- command: args ? [command, ...args] : [command]
2305
- };
2306
- const env = server["env"];
2307
- if (env) entry["environment"] = env;
2308
- result[name] = entry;
2309
- }
2310
- }
2311
- }
2312
- return result;
2313
- }
2314
- /**
2315
- * Ensures the opencode workspace directory exists with the right config files.
2316
- * Writes opencode.json (MCP servers) and .opencode/agent/browserbird.md (system prompt).
2317
- */
2318
- function ensureWorkspace(mcpConfigPath, systemPrompt) {
2319
- mkdirSync(resolve(WORKSPACE_DIR, ".opencode", "agent"), { recursive: true });
2320
- const config = {};
2321
- if (mcpConfigPath) try {
2322
- const raw = readFileSync(resolve(mcpConfigPath), "utf-8");
2323
- const mcp = translateMcpConfig(JSON.parse(raw));
2324
- if (Object.keys(mcp).length > 0) config["mcp"] = mcp;
2325
- } catch (err) {
2326
- logger.warn(`opencode: failed to read MCP config at ${mcpConfigPath}: ${err instanceof Error ? err.message : String(err)}`);
2327
- }
2328
- writeFileSync(resolve(WORKSPACE_DIR, "opencode.json"), JSON.stringify(config, null, 2) + "\n");
2329
- if (systemPrompt) {
2330
- const agentMd = `---\nmode: primary\n---\n\n${systemPrompt}\n`;
2331
- writeFileSync(resolve(WORKSPACE_DIR, ".opencode", "agent", "browserbird.md"), agentMd);
2332
- }
2333
- const agentsMd = resolve("AGENTS.md");
2334
- if (existsSync(agentsMd)) copyFileSync(agentsMd, resolve(WORKSPACE_DIR, "AGENTS.md"));
2335
- }
2336
- /**
2337
- * Builds the `opencode run` command for a given agent config.
2338
- *
2339
- * @remarks `fallbackModel` is not yet supported by opencode.
2340
- * @see https://github.com/anomalyco/opencode/issues/7602
2341
- */
2342
- function buildCommand(options) {
2343
- const { message, sessionId, agent, mcpConfigPath } = options;
2344
- const systemParts = [];
2345
- if (agent.systemPrompt) systemParts.push(agent.systemPrompt);
2346
- if (options.timezone) systemParts.push(`System timezone: ${options.timezone}. All cron expressions and scheduled times use this timezone.`);
2347
- ensureWorkspace(mcpConfigPath, systemParts.join(" ") || void 0);
2348
- const args = [
2349
- "run",
2350
- "--format",
2351
- "json",
2352
- "-m",
2353
- agent.model
2354
- ];
2355
- if (agent.systemPrompt) args.push("--agent", "browserbird");
2356
- if (sessionId) args.push("--session", sessionId);
2357
- args.push(message);
2358
- const env = {};
2359
- const apiKey = process.env["ANTHROPIC_API_KEY"];
2360
- if (apiKey) env["ANTHROPIC_API_KEY"] = apiKey;
2361
- const openRouterKey = process.env["OPENROUTER_API_KEY"];
2362
- if (openRouterKey) env["OPENROUTER_API_KEY"] = openRouterKey;
2363
- const openAiKey = process.env["OPENAI_API_KEY"];
2364
- if (openAiKey) env["OPENAI_API_KEY"] = openAiKey;
2365
- const geminiKey = process.env["GEMINI_API_KEY"];
2366
- if (geminiKey) env["GEMINI_API_KEY"] = geminiKey;
2367
- return {
2368
- binary: "opencode",
2369
- args,
2370
- cwd: WORKSPACE_DIR,
2371
- env
2372
- };
2373
- }
2374
- /** Per-session metric accumulators. Concurrent sessions each get their own entry. */
2375
- const accumulators = /* @__PURE__ */ new Map();
2376
- function accumulateStep(sessionId, part) {
2377
- const acc = accumulators.get(sessionId);
2378
- acc.stepCount++;
2379
- const tokens = part["tokens"];
2380
- const cache = tokens?.["cache"];
2381
- acc.tokensIn += tokens?.["input"] ?? 0;
2382
- acc.tokensOut += tokens?.["output"] ?? 0;
2383
- acc.cacheWrite += cache?.["write"] ?? 0;
2384
- acc.cacheRead += cache?.["read"] ?? 0;
2385
- acc.cost += part["cost"] ?? 0;
2386
- }
2248
+ //#region src/provider/stream.ts
2387
2249
  /**
2388
- * Parses a single line of opencode JSON output into zero or more StreamEvents.
2389
- *
2390
- * OpenCode emits these event types:
2391
- * step_start -> init (first one carries sessionID)
2392
- * text -> text_delta
2393
- * tool_use -> ignored (internal to the agent)
2394
- * step_finish -> accumulates tokens/cost; final one (reason "stop") emits completion
2395
- * error -> error (connection/auth failures)
2250
+ * Splits a raw data chunk into lines, handling partial lines across chunks.
2251
+ * Returns [completeLines, remainingPartial].
2396
2252
  */
2397
- function parseStreamLine(line) {
2398
- const trimmed = line.trim();
2399
- if (!trimmed || !trimmed.startsWith("{")) return [];
2400
- let parsed;
2401
- try {
2402
- parsed = JSON.parse(trimmed);
2403
- } catch {
2404
- return [];
2405
- }
2406
- const eventType = parsed["type"];
2407
- if (!eventType) return [];
2408
- const part = parsed["part"];
2409
- const timestamp = parsed["timestamp"] ?? 0;
2410
- switch (eventType) {
2411
- case "step_start":
2412
- if (part && typeof part["sessionID"] === "string") {
2413
- const sid = part["sessionID"];
2414
- if (!accumulators.has(sid)) accumulators.set(sid, {
2415
- startTimestamp: timestamp,
2416
- stepCount: 0,
2417
- tokensIn: 0,
2418
- tokensOut: 0,
2419
- cacheWrite: 0,
2420
- cacheRead: 0,
2421
- cost: 0
2422
- });
2423
- return [{
2424
- type: "init",
2425
- sessionId: sid,
2426
- model: ""
2427
- }];
2428
- }
2429
- return [];
2430
- case "text":
2431
- if (part && typeof part["text"] === "string") return [{
2432
- type: "text_delta",
2433
- delta: part["text"]
2434
- }];
2435
- return [];
2436
- case "step_finish": {
2437
- if (!part) return [];
2438
- const sid = part["sessionID"] ?? "";
2439
- accumulateStep(sid, part);
2440
- if (part["reason"] !== "stop") return [];
2441
- const acc = accumulators.get(sid);
2442
- const durationMs = timestamp > acc.startTimestamp ? timestamp - acc.startTimestamp : 0;
2443
- const completion = {
2444
- type: "completion",
2445
- subtype: "success",
2446
- result: "",
2447
- sessionId: sid,
2448
- isError: false,
2449
- tokensIn: acc.tokensIn,
2450
- tokensOut: acc.tokensOut,
2451
- cacheCreationTokens: acc.cacheWrite,
2452
- cacheReadTokens: acc.cacheRead,
2453
- costUsd: acc.cost,
2454
- durationMs,
2455
- numTurns: acc.stepCount
2456
- };
2457
- accumulators.delete(sid);
2458
- return [completion];
2459
- }
2460
- case "tool_use": {
2461
- const toolName = typeof part?.["tool"] === "string" && part["tool"] || "";
2462
- if (toolName) return [{
2463
- type: "tool_use",
2464
- toolName
2465
- }];
2466
- return [];
2467
- }
2468
- case "error": {
2469
- const err = parsed["error"];
2470
- const data = err?.["data"];
2471
- return [{
2472
- type: "error",
2473
- error: typeof data?.["message"] === "string" && data["message"] || typeof parsed["message"] === "string" && parsed["message"] || typeof err?.["name"] === "string" && err["name"] || JSON.stringify(parsed)
2474
- }];
2475
- }
2476
- default: return [];
2477
- }
2253
+ function splitLines(buffer, chunk) {
2254
+ const lines = (buffer + chunk).split("\n");
2255
+ return [lines, lines.pop() ?? ""];
2478
2256
  }
2479
- const opencode = {
2480
- buildCommand,
2481
- parseStreamLine
2482
- };
2483
2257
 
2484
2258
  //#endregion
2485
2259
  //#region src/provider/spawn.ts
2486
- /** @fileoverview Spawn a CLI provider as a subprocess. */
2260
+ /** @fileoverview Spawn the Claude CLI as a subprocess with streaming output. */
2487
2261
  const SIGKILL_GRACE_MS = 5e3;
2488
- const PROVIDERS = {
2489
- claude,
2490
- opencode
2491
- };
2492
2262
  /** Sends SIGTERM, then SIGKILL after a grace period if the process is still alive. */
2493
2263
  function gracefulKill(proc) {
2494
2264
  if (!proc.pid || proc.killed) return;
@@ -2517,12 +2287,11 @@ function cleanEnv() {
2517
2287
  return env;
2518
2288
  }
2519
2289
  /**
2520
- * Spawns a provider CLI with streaming output.
2290
+ * Spawns the Claude CLI with streaming output.
2521
2291
  * Returns an async iterable of parsed stream events and a kill handle.
2522
2292
  */
2523
- function spawnProvider(provider, options, signal) {
2524
- const mod = PROVIDERS[provider];
2525
- const cmd = mod.buildCommand(options);
2293
+ function spawnProvider(options, signal) {
2294
+ const cmd = buildCommand(options);
2526
2295
  const timeoutMs = options.agent.processTimeoutMs ?? options.globalTimeoutMs ?? 3e5;
2527
2296
  logger.debug(`spawning: ${cmd.binary} ${cmd.args.join(" ")} (timeout: ${timeoutMs}ms)`);
2528
2297
  const baseEnv = cleanEnv();
@@ -2552,10 +2321,10 @@ function spawnProvider(provider, options, signal) {
2552
2321
  async function* iterate() {
2553
2322
  let buffer = "";
2554
2323
  try {
2555
- yield* parseStdout(proc, mod, buffer, (b) => {
2324
+ yield* parseStdout(proc, buffer, (b) => {
2556
2325
  buffer = b;
2557
2326
  });
2558
- if (buffer.trim()) yield* mod.parseStreamLine(buffer);
2327
+ if (buffer.trim()) yield* parseStreamLine(buffer);
2559
2328
  if (timedOut) yield {
2560
2329
  type: "timeout",
2561
2330
  timeoutMs
@@ -2571,7 +2340,7 @@ function spawnProvider(provider, options, signal) {
2571
2340
  kill: () => gracefulKill(proc)
2572
2341
  };
2573
2342
  }
2574
- async function* parseStdout(proc, mod, buffer, setBuffer) {
2343
+ async function* parseStdout(proc, buffer, setBuffer) {
2575
2344
  const pending = [];
2576
2345
  let done = false;
2577
2346
  let error = null;
@@ -2600,7 +2369,7 @@ async function* parseStdout(proc, mod, buffer, setBuffer) {
2600
2369
  const [lines, remaining] = splitLines(buffer, data);
2601
2370
  buffer = remaining;
2602
2371
  setBuffer(buffer);
2603
- for (const line of lines) yield* mod.parseStreamLine(line);
2372
+ for (const line of lines) yield* parseStreamLine(line);
2604
2373
  }
2605
2374
  }
2606
2375
  if (error) yield {
@@ -2967,7 +2736,7 @@ function startScheduler(getConfig, signal, deps) {
2967
2736
  const needsBrowserLock = config.browser.enabled && getBrowserMode() === "persistent";
2968
2737
  let browserLock = null;
2969
2738
  try {
2970
- const { events } = spawnProvider(agent.provider, {
2739
+ const { events } = spawnProvider({
2971
2740
  message: payload.prompt,
2972
2741
  agent,
2973
2742
  mcpConfigPath: config.browser.mcpConfigPath,
@@ -3146,7 +2915,7 @@ function createCoalescer(config, onDispatch) {
3146
2915
  //#endregion
3147
2916
  //#region src/channel/handler.ts
3148
2917
  const BROWSER_TOOL_PREFIX = "mcp__playwright__";
3149
- function createHandler(client, getConfig, signal, getTeamId) {
2918
+ function createHandler(client, getConfig, signal, getTeamId, getChannelNameToId) {
3150
2919
  const locks = /* @__PURE__ */ new Map();
3151
2920
  let activeSpawns = 0;
3152
2921
  function getLock(key) {
@@ -3292,7 +3061,7 @@ function createHandler(client, getConfig, signal, getTeamId) {
3292
3061
  activeSpawns++;
3293
3062
  let sessionUid;
3294
3063
  try {
3295
- const resolved = resolveSession(channelId, threadTs, config);
3064
+ const resolved = resolveSession(channelId, threadTs, config, getChannelNameToId?.());
3296
3065
  if (!resolved) {
3297
3066
  const blocks = noAgentBlocks(channelId);
3298
3067
  await client.postMessage(channelId, threadTs, "No agent configured for this channel.", { blocks });
@@ -3305,10 +3074,9 @@ function createHandler(client, getConfig, signal, getTeamId) {
3305
3074
  broadcastSSE("invalidate", { resource: "sessions" });
3306
3075
  const prompt = formatPrompt(messages);
3307
3076
  const userId = messages[messages.length - 1].userId;
3308
- const existingSessionId = isNew ? void 0 : session.provider_session_id || void 0;
3309
- const { events, kill } = spawnProvider(agent.provider, {
3077
+ const { events, kill } = spawnProvider({
3310
3078
  message: prompt,
3311
- sessionId: existingSessionId,
3079
+ sessionId: isNew ? void 0 : session.provider_session_id || void 0,
3312
3080
  agent,
3313
3081
  mcpConfigPath: config.browser.mcpConfigPath,
3314
3082
  timezone: config.timezone,
@@ -3653,7 +3421,7 @@ function createSlackChannel(getConfig, signal) {
3653
3421
  });
3654
3422
  const webClient = new WebClient(initConfig.slack.botToken);
3655
3423
  const channelClient = new SlackChannelClient(webClient);
3656
- const handler = createHandler(channelClient, getConfig, signal, () => teamId);
3424
+ const handler = createHandler(channelClient, getConfig, signal, () => teamId, () => channelNameToId);
3657
3425
  const coalescer = createCoalescer(initConfig.slack.coalesce, (dispatch) => {
3658
3426
  handler.handle(dispatch).catch(logDispatchError);
3659
3427
  });
@@ -3685,7 +3453,7 @@ function createSlackChannel(getConfig, signal) {
3685
3453
  const isDm = event["channel_type"] === "im";
3686
3454
  if (!isDm && config.slack.requireMention) return;
3687
3455
  const channelId = event["channel"];
3688
- if (!isChannelAllowed(channelId, config.slack.channels)) return;
3456
+ if (!isChannelAllowed(channelId, config.slack.channels, channelNameToId)) return;
3689
3457
  if (!isDm && isQuietHours(config.slack.quietHours)) return;
3690
3458
  const threadTs = event["thread_ts"] ?? event["ts"];
3691
3459
  const userId = event["user"] ?? "unknown";
@@ -3709,7 +3477,7 @@ function createSlackChannel(getConfig, signal) {
3709
3477
  if (isDuplicate(body)) return;
3710
3478
  const config = getConfig();
3711
3479
  const channelId = event["channel"];
3712
- if (!isChannelAllowed(channelId, config.slack.channels)) return;
3480
+ if (!isChannelAllowed(channelId, config.slack.channels, channelNameToId)) return;
3713
3481
  if (isQuietHours(config.slack.quietHours)) return;
3714
3482
  const messageTs = event["ts"];
3715
3483
  if (!messageTs) return;
@@ -3804,6 +3572,8 @@ function createSlackChannel(getConfig, signal) {
3804
3572
  logger.info("reset reconnection back-off counter");
3805
3573
  }
3806
3574
  });
3575
+ /** Rebuilt on connect and config reload via resolveChannelNames(). */
3576
+ const channelNameToId = /* @__PURE__ */ new Map();
3807
3577
  async function resolveChannelNames() {
3808
3578
  const target = getConfig();
3809
3579
  const namesToResolve = /* @__PURE__ */ new Set();
@@ -3813,7 +3583,7 @@ function createSlackChannel(getConfig, signal) {
3813
3583
  collectNames(target.slack.channels);
3814
3584
  for (const agent of target.agents) collectNames(agent.channels);
3815
3585
  if (namesToResolve.size === 0) return;
3816
- const nameToId = /* @__PURE__ */ new Map();
3586
+ channelNameToId.clear();
3817
3587
  try {
3818
3588
  let cursor;
3819
3589
  do {
@@ -3823,26 +3593,17 @@ function createSlackChannel(getConfig, signal) {
3823
3593
  exclude_archived: true,
3824
3594
  cursor
3825
3595
  });
3826
- for (const ch of result.channels ?? []) if (ch.name && ch.id && namesToResolve.has(ch.name)) nameToId.set(ch.name, ch.id);
3596
+ for (const ch of result.channels ?? []) if (ch.name && ch.id && namesToResolve.has(ch.name)) {
3597
+ channelNameToId.set(ch.name, ch.id);
3598
+ logger.info(`resolved channel "${ch.name}" -> ${ch.id}`);
3599
+ }
3827
3600
  cursor = result.response_metadata?.next_cursor || void 0;
3828
3601
  } while (cursor);
3829
3602
  } catch (err) {
3830
3603
  logger.warn(`failed to resolve channel names: ${err instanceof Error ? err.message : String(err)}`);
3831
3604
  return;
3832
3605
  }
3833
- function resolveList(channels, label) {
3834
- return channels.map((ch) => {
3835
- const resolved = nameToId.get(ch);
3836
- if (resolved) {
3837
- logger.info(`${label}: resolved channel "${ch}" -> ${resolved}`);
3838
- return resolved;
3839
- }
3840
- if (namesToResolve.has(ch)) logger.warn(`${label}: channel "${ch}" not found in workspace`);
3841
- return ch;
3842
- });
3843
- }
3844
- target.slack.channels = resolveList(target.slack.channels, "slack");
3845
- for (const agent of target.agents) agent.channels = resolveList(agent.channels, `agent "${agent.id}"`);
3606
+ for (const name of namesToResolve) if (!channelNameToId.has(name)) logger.warn(`channel "${name}" not found in workspace`);
3846
3607
  }
3847
3608
  async function start() {
3848
3609
  const authResult = await webClient.auth.test();
@@ -3931,9 +3692,13 @@ async function handleSessionRetry(sessionUid, userId, handler) {
3931
3692
  logger.error(`retry error: ${err instanceof Error ? err.message : String(err)}`);
3932
3693
  }
3933
3694
  }
3934
- function isChannelAllowed(channelId, channels) {
3695
+ function isChannelAllowed(channelId, channels, nameToId) {
3935
3696
  if (channels.includes("*")) return true;
3936
- return channels.includes(channelId);
3697
+ for (const ch of channels) {
3698
+ if (ch === channelId) return true;
3699
+ if (nameToId.get(ch) === channelId) return true;
3700
+ }
3701
+ return false;
3937
3702
  }
3938
3703
  function isQuietHours(quietHours) {
3939
3704
  if (!quietHours.enabled) return false;
@@ -4793,7 +4558,6 @@ function printConfig(configPath) {
4793
4558
  console.log(`\n${c("cyan", "agents:")}`);
4794
4559
  for (const a of config.agents) {
4795
4560
  console.log(` ${c("cyan", a.id)} (${a.name})`);
4796
- console.log(` ${c("dim", "provider:")} ${a.provider}`);
4797
4561
  console.log(` ${c("dim", "model:")} ${a.model}`);
4798
4562
  console.log(` ${c("dim", "max turns:")} ${a.maxTurns}`);
4799
4563
  console.log(` ${c("dim", "channels:")} ${a.channels.join(", ") || "*"}`);