@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 +6 -10
- package/dist/index.mjs +54 -290
- package/package.json +1 -1
- package/web/dist/assets/index-BJA2Sa8F.js +7 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-16SesbKh.js +0 -7
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 ([
|
|
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
|
-
- `
|
|
148
|
-
- `
|
|
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)
|
|
253
|
-
| `CLAUDE_CODE_OAUTH_TOKEN` | OAuth token
|
|
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
|
|
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 {
|
|
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: ${"
|
|
126
|
-
buildInfo.push(`built: 2026-03-
|
|
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
|
|
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(
|
|
668
|
+
function refreshAgent() {
|
|
678
669
|
const now = Date.now();
|
|
679
670
|
if (now - agentCheckedAt < AGENT_CHECK_INTERVAL_MS) return;
|
|
680
|
-
|
|
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(
|
|
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(
|
|
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)
|
|
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
|
|
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
|
|
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/
|
|
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
|
-
*
|
|
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
|
|
2398
|
-
const
|
|
2399
|
-
|
|
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
|
|
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
|
|
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(
|
|
2524
|
-
const
|
|
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,
|
|
2324
|
+
yield* parseStdout(proc, buffer, (b) => {
|
|
2556
2325
|
buffer = b;
|
|
2557
2326
|
});
|
|
2558
|
-
if (buffer.trim()) yield*
|
|
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,
|
|
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*
|
|
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(
|
|
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
|
|
3309
|
-
const { events, kill } = spawnProvider(agent.provider, {
|
|
3077
|
+
const { events, kill } = spawnProvider({
|
|
3310
3078
|
message: prompt,
|
|
3311
|
-
sessionId:
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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(", ") || "*"}`);
|