@owloops/browserbird 1.4.15 → 1.4.17
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 +59 -294
- 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/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: ${"4e976792ac1f38e4ad5ba619163daa52cc482ae1".substring(0, 7)}`);
|
|
126
|
+
buildInfo.push(`built: 2026-03-15T22:25:47+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
|
}
|
|
@@ -1557,21 +1544,21 @@ function buildRoutes(getConfig, startedAt, getDeps, options) {
|
|
|
1557
1544
|
pattern: pathToRegex("/api/onboarding/defaults"),
|
|
1558
1545
|
handler(_req, res) {
|
|
1559
1546
|
const doctor = checkDoctor();
|
|
1560
|
-
const
|
|
1561
|
-
const
|
|
1547
|
+
const config = getConfig();
|
|
1548
|
+
const defaultAgent = config.agents[0] ?? DEFAULTS.agents[0];
|
|
1549
|
+
const novncHost = !!process.env["RAILWAY_ENVIRONMENT_NAME"] ? "browserbird-vm.railway.internal" : config.browser.novncHost || DEFAULTS.browser.novncHost;
|
|
1562
1550
|
json(res, {
|
|
1563
1551
|
agent: {
|
|
1564
1552
|
name: defaultAgent.name,
|
|
1565
|
-
provider: defaultAgent.provider,
|
|
1566
1553
|
model: defaultAgent.model,
|
|
1567
1554
|
systemPrompt: defaultAgent.systemPrompt,
|
|
1568
1555
|
maxTurns: defaultAgent.maxTurns,
|
|
1569
1556
|
channels: defaultAgent.channels
|
|
1570
1557
|
},
|
|
1571
1558
|
browser: {
|
|
1572
|
-
enabled:
|
|
1559
|
+
enabled: config.browser.enabled,
|
|
1573
1560
|
novncHost,
|
|
1574
|
-
novncPort:
|
|
1561
|
+
novncPort: config.browser.novncPort
|
|
1575
1562
|
},
|
|
1576
1563
|
doctor
|
|
1577
1564
|
});
|
|
@@ -1658,10 +1645,6 @@ function buildRoutes(getConfig, startedAt, getDeps, options) {
|
|
|
1658
1645
|
jsonError(res, "\"name\" is required", 400);
|
|
1659
1646
|
return;
|
|
1660
1647
|
}
|
|
1661
|
-
if (!body.provider || typeof body.provider !== "string") {
|
|
1662
|
-
jsonError(res, "\"provider\" is required", 400);
|
|
1663
|
-
return;
|
|
1664
|
-
}
|
|
1665
1648
|
if (!body.model || typeof body.model !== "string") {
|
|
1666
1649
|
jsonError(res, "\"model\" is required", 400);
|
|
1667
1650
|
return;
|
|
@@ -1671,7 +1654,6 @@ function buildRoutes(getConfig, startedAt, getDeps, options) {
|
|
|
1671
1654
|
raw["agents"] = [{
|
|
1672
1655
|
id: "default",
|
|
1673
1656
|
name: body.name.trim(),
|
|
1674
|
-
provider: body.provider.trim(),
|
|
1675
1657
|
model: body.model.trim(),
|
|
1676
1658
|
maxTurns: body.maxTurns ?? DEFAULTS.agents[0].maxTurns,
|
|
1677
1659
|
systemPrompt: body.systemPrompt?.trim() ?? DEFAULTS.agents[0].systemPrompt,
|
|
@@ -2067,16 +2049,20 @@ function startWorker(signal) {
|
|
|
2067
2049
|
* Matches an incoming message to the correct agent based on channel config.
|
|
2068
2050
|
* Agents are checked in order; first match wins.
|
|
2069
2051
|
* A wildcard `"*"` in the agent's channels array matches everything.
|
|
2052
|
+
* The optional `nameToId` map resolves human-readable channel names to Slack IDs.
|
|
2070
2053
|
*/
|
|
2071
|
-
function matchAgent(channelId, agents) {
|
|
2072
|
-
for (const agent of agents) for (const pattern of agent.channels)
|
|
2054
|
+
function matchAgent(channelId, agents, nameToId) {
|
|
2055
|
+
for (const agent of agents) for (const pattern of agent.channels) {
|
|
2056
|
+
if (pattern === "*" || pattern === channelId) return agent;
|
|
2057
|
+
if (nameToId?.get(pattern) === channelId) return agent;
|
|
2058
|
+
}
|
|
2073
2059
|
}
|
|
2074
2060
|
/**
|
|
2075
2061
|
* Looks up or creates a session for the given Slack thread.
|
|
2076
2062
|
* Returns the session row and whether it was newly created.
|
|
2077
2063
|
*/
|
|
2078
|
-
function resolveSession(channelId, threadTs, config) {
|
|
2079
|
-
const agent = matchAgent(channelId, config.agents);
|
|
2064
|
+
function resolveSession(channelId, threadTs, config, nameToId) {
|
|
2065
|
+
const agent = matchAgent(channelId, config.agents, nameToId);
|
|
2080
2066
|
if (!agent) {
|
|
2081
2067
|
logger.warn(`no agent matched for channel ${channelId}`);
|
|
2082
2068
|
return null;
|
|
@@ -2112,21 +2098,10 @@ function expireStaleSessions(ttlHours) {
|
|
|
2112
2098
|
return deleted;
|
|
2113
2099
|
}
|
|
2114
2100
|
|
|
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
2101
|
//#endregion
|
|
2127
2102
|
//#region src/provider/claude.ts
|
|
2128
2103
|
/** @fileoverview Claude Code CLI provider: arg building and stream-json parsing. */
|
|
2129
|
-
function buildCommand
|
|
2104
|
+
function buildCommand(options) {
|
|
2130
2105
|
const { message, sessionId, agent, mcpConfigPath } = options;
|
|
2131
2106
|
const args = [
|
|
2132
2107
|
"-p",
|
|
@@ -2162,7 +2137,7 @@ function buildCommand$1(options) {
|
|
|
2162
2137
|
* Only extracts text, images, completion, and error events. Tool use/result
|
|
2163
2138
|
* events are internal to the agent and not surfaced to the channel layer.
|
|
2164
2139
|
*/
|
|
2165
|
-
function parseStreamLine
|
|
2140
|
+
function parseStreamLine(line) {
|
|
2166
2141
|
const trimmed = line.trim();
|
|
2167
2142
|
if (!trimmed || !trimmed.startsWith("{")) return [];
|
|
2168
2143
|
let parsed;
|
|
@@ -2269,226 +2244,22 @@ function extractImages(parsed) {
|
|
|
2269
2244
|
}];
|
|
2270
2245
|
return [];
|
|
2271
2246
|
}
|
|
2272
|
-
const claude = {
|
|
2273
|
-
buildCommand: buildCommand$1,
|
|
2274
|
-
parseStreamLine: parseStreamLine$1
|
|
2275
|
-
};
|
|
2276
2247
|
|
|
2277
2248
|
//#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
|
-
}
|
|
2249
|
+
//#region src/provider/stream.ts
|
|
2387
2250
|
/**
|
|
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)
|
|
2251
|
+
* Splits a raw data chunk into lines, handling partial lines across chunks.
|
|
2252
|
+
* Returns [completeLines, remainingPartial].
|
|
2396
2253
|
*/
|
|
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
|
-
}
|
|
2254
|
+
function splitLines(buffer, chunk) {
|
|
2255
|
+
const lines = (buffer + chunk).split("\n");
|
|
2256
|
+
return [lines, lines.pop() ?? ""];
|
|
2478
2257
|
}
|
|
2479
|
-
const opencode = {
|
|
2480
|
-
buildCommand,
|
|
2481
|
-
parseStreamLine
|
|
2482
|
-
};
|
|
2483
2258
|
|
|
2484
2259
|
//#endregion
|
|
2485
2260
|
//#region src/provider/spawn.ts
|
|
2486
|
-
/** @fileoverview Spawn
|
|
2261
|
+
/** @fileoverview Spawn the Claude CLI as a subprocess with streaming output. */
|
|
2487
2262
|
const SIGKILL_GRACE_MS = 5e3;
|
|
2488
|
-
const PROVIDERS = {
|
|
2489
|
-
claude,
|
|
2490
|
-
opencode
|
|
2491
|
-
};
|
|
2492
2263
|
/** Sends SIGTERM, then SIGKILL after a grace period if the process is still alive. */
|
|
2493
2264
|
function gracefulKill(proc) {
|
|
2494
2265
|
if (!proc.pid || proc.killed) return;
|
|
@@ -2517,12 +2288,11 @@ function cleanEnv() {
|
|
|
2517
2288
|
return env;
|
|
2518
2289
|
}
|
|
2519
2290
|
/**
|
|
2520
|
-
* Spawns
|
|
2291
|
+
* Spawns the Claude CLI with streaming output.
|
|
2521
2292
|
* Returns an async iterable of parsed stream events and a kill handle.
|
|
2522
2293
|
*/
|
|
2523
|
-
function spawnProvider(
|
|
2524
|
-
const
|
|
2525
|
-
const cmd = mod.buildCommand(options);
|
|
2294
|
+
function spawnProvider(options, signal) {
|
|
2295
|
+
const cmd = buildCommand(options);
|
|
2526
2296
|
const timeoutMs = options.agent.processTimeoutMs ?? options.globalTimeoutMs ?? 3e5;
|
|
2527
2297
|
logger.debug(`spawning: ${cmd.binary} ${cmd.args.join(" ")} (timeout: ${timeoutMs}ms)`);
|
|
2528
2298
|
const baseEnv = cleanEnv();
|
|
@@ -2552,10 +2322,10 @@ function spawnProvider(provider, options, signal) {
|
|
|
2552
2322
|
async function* iterate() {
|
|
2553
2323
|
let buffer = "";
|
|
2554
2324
|
try {
|
|
2555
|
-
yield* parseStdout(proc,
|
|
2325
|
+
yield* parseStdout(proc, buffer, (b) => {
|
|
2556
2326
|
buffer = b;
|
|
2557
2327
|
});
|
|
2558
|
-
if (buffer.trim()) yield*
|
|
2328
|
+
if (buffer.trim()) yield* parseStreamLine(buffer);
|
|
2559
2329
|
if (timedOut) yield {
|
|
2560
2330
|
type: "timeout",
|
|
2561
2331
|
timeoutMs
|
|
@@ -2571,7 +2341,7 @@ function spawnProvider(provider, options, signal) {
|
|
|
2571
2341
|
kill: () => gracefulKill(proc)
|
|
2572
2342
|
};
|
|
2573
2343
|
}
|
|
2574
|
-
async function* parseStdout(proc,
|
|
2344
|
+
async function* parseStdout(proc, buffer, setBuffer) {
|
|
2575
2345
|
const pending = [];
|
|
2576
2346
|
let done = false;
|
|
2577
2347
|
let error = null;
|
|
@@ -2600,7 +2370,7 @@ async function* parseStdout(proc, mod, buffer, setBuffer) {
|
|
|
2600
2370
|
const [lines, remaining] = splitLines(buffer, data);
|
|
2601
2371
|
buffer = remaining;
|
|
2602
2372
|
setBuffer(buffer);
|
|
2603
|
-
for (const line of lines) yield*
|
|
2373
|
+
for (const line of lines) yield* parseStreamLine(line);
|
|
2604
2374
|
}
|
|
2605
2375
|
}
|
|
2606
2376
|
if (error) yield {
|
|
@@ -2967,7 +2737,7 @@ function startScheduler(getConfig, signal, deps) {
|
|
|
2967
2737
|
const needsBrowserLock = config.browser.enabled && getBrowserMode() === "persistent";
|
|
2968
2738
|
let browserLock = null;
|
|
2969
2739
|
try {
|
|
2970
|
-
const { events } = spawnProvider(
|
|
2740
|
+
const { events } = spawnProvider({
|
|
2971
2741
|
message: payload.prompt,
|
|
2972
2742
|
agent,
|
|
2973
2743
|
mcpConfigPath: config.browser.mcpConfigPath,
|
|
@@ -3146,7 +2916,7 @@ function createCoalescer(config, onDispatch) {
|
|
|
3146
2916
|
//#endregion
|
|
3147
2917
|
//#region src/channel/handler.ts
|
|
3148
2918
|
const BROWSER_TOOL_PREFIX = "mcp__playwright__";
|
|
3149
|
-
function createHandler(client, getConfig, signal, getTeamId) {
|
|
2919
|
+
function createHandler(client, getConfig, signal, getTeamId, getChannelNameToId) {
|
|
3150
2920
|
const locks = /* @__PURE__ */ new Map();
|
|
3151
2921
|
let activeSpawns = 0;
|
|
3152
2922
|
function getLock(key) {
|
|
@@ -3292,7 +3062,7 @@ function createHandler(client, getConfig, signal, getTeamId) {
|
|
|
3292
3062
|
activeSpawns++;
|
|
3293
3063
|
let sessionUid;
|
|
3294
3064
|
try {
|
|
3295
|
-
const resolved = resolveSession(channelId, threadTs, config);
|
|
3065
|
+
const resolved = resolveSession(channelId, threadTs, config, getChannelNameToId?.());
|
|
3296
3066
|
if (!resolved) {
|
|
3297
3067
|
const blocks = noAgentBlocks(channelId);
|
|
3298
3068
|
await client.postMessage(channelId, threadTs, "No agent configured for this channel.", { blocks });
|
|
@@ -3305,10 +3075,9 @@ function createHandler(client, getConfig, signal, getTeamId) {
|
|
|
3305
3075
|
broadcastSSE("invalidate", { resource: "sessions" });
|
|
3306
3076
|
const prompt = formatPrompt(messages);
|
|
3307
3077
|
const userId = messages[messages.length - 1].userId;
|
|
3308
|
-
const
|
|
3309
|
-
const { events, kill } = spawnProvider(agent.provider, {
|
|
3078
|
+
const { events, kill } = spawnProvider({
|
|
3310
3079
|
message: prompt,
|
|
3311
|
-
sessionId:
|
|
3080
|
+
sessionId: isNew ? void 0 : session.provider_session_id || void 0,
|
|
3312
3081
|
agent,
|
|
3313
3082
|
mcpConfigPath: config.browser.mcpConfigPath,
|
|
3314
3083
|
timezone: config.timezone,
|
|
@@ -3653,7 +3422,7 @@ function createSlackChannel(getConfig, signal) {
|
|
|
3653
3422
|
});
|
|
3654
3423
|
const webClient = new WebClient(initConfig.slack.botToken);
|
|
3655
3424
|
const channelClient = new SlackChannelClient(webClient);
|
|
3656
|
-
const handler = createHandler(channelClient, getConfig, signal, () => teamId);
|
|
3425
|
+
const handler = createHandler(channelClient, getConfig, signal, () => teamId, () => channelNameToId);
|
|
3657
3426
|
const coalescer = createCoalescer(initConfig.slack.coalesce, (dispatch) => {
|
|
3658
3427
|
handler.handle(dispatch).catch(logDispatchError);
|
|
3659
3428
|
});
|
|
@@ -3685,7 +3454,7 @@ function createSlackChannel(getConfig, signal) {
|
|
|
3685
3454
|
const isDm = event["channel_type"] === "im";
|
|
3686
3455
|
if (!isDm && config.slack.requireMention) return;
|
|
3687
3456
|
const channelId = event["channel"];
|
|
3688
|
-
if (!isChannelAllowed(channelId, config.slack.channels)) return;
|
|
3457
|
+
if (!isChannelAllowed(channelId, config.slack.channels, channelNameToId)) return;
|
|
3689
3458
|
if (!isDm && isQuietHours(config.slack.quietHours)) return;
|
|
3690
3459
|
const threadTs = event["thread_ts"] ?? event["ts"];
|
|
3691
3460
|
const userId = event["user"] ?? "unknown";
|
|
@@ -3709,7 +3478,7 @@ function createSlackChannel(getConfig, signal) {
|
|
|
3709
3478
|
if (isDuplicate(body)) return;
|
|
3710
3479
|
const config = getConfig();
|
|
3711
3480
|
const channelId = event["channel"];
|
|
3712
|
-
if (!isChannelAllowed(channelId, config.slack.channels)) return;
|
|
3481
|
+
if (!isChannelAllowed(channelId, config.slack.channels, channelNameToId)) return;
|
|
3713
3482
|
if (isQuietHours(config.slack.quietHours)) return;
|
|
3714
3483
|
const messageTs = event["ts"];
|
|
3715
3484
|
if (!messageTs) return;
|
|
@@ -3804,6 +3573,8 @@ function createSlackChannel(getConfig, signal) {
|
|
|
3804
3573
|
logger.info("reset reconnection back-off counter");
|
|
3805
3574
|
}
|
|
3806
3575
|
});
|
|
3576
|
+
/** Rebuilt on connect and config reload via resolveChannelNames(). */
|
|
3577
|
+
const channelNameToId = /* @__PURE__ */ new Map();
|
|
3807
3578
|
async function resolveChannelNames() {
|
|
3808
3579
|
const target = getConfig();
|
|
3809
3580
|
const namesToResolve = /* @__PURE__ */ new Set();
|
|
@@ -3813,7 +3584,7 @@ function createSlackChannel(getConfig, signal) {
|
|
|
3813
3584
|
collectNames(target.slack.channels);
|
|
3814
3585
|
for (const agent of target.agents) collectNames(agent.channels);
|
|
3815
3586
|
if (namesToResolve.size === 0) return;
|
|
3816
|
-
|
|
3587
|
+
channelNameToId.clear();
|
|
3817
3588
|
try {
|
|
3818
3589
|
let cursor;
|
|
3819
3590
|
do {
|
|
@@ -3823,26 +3594,17 @@ function createSlackChannel(getConfig, signal) {
|
|
|
3823
3594
|
exclude_archived: true,
|
|
3824
3595
|
cursor
|
|
3825
3596
|
});
|
|
3826
|
-
for (const ch of result.channels ?? []) if (ch.name && ch.id && namesToResolve.has(ch.name))
|
|
3597
|
+
for (const ch of result.channels ?? []) if (ch.name && ch.id && namesToResolve.has(ch.name)) {
|
|
3598
|
+
channelNameToId.set(ch.name, ch.id);
|
|
3599
|
+
logger.info(`resolved channel "${ch.name}" -> ${ch.id}`);
|
|
3600
|
+
}
|
|
3827
3601
|
cursor = result.response_metadata?.next_cursor || void 0;
|
|
3828
3602
|
} while (cursor);
|
|
3829
3603
|
} catch (err) {
|
|
3830
3604
|
logger.warn(`failed to resolve channel names: ${err instanceof Error ? err.message : String(err)}`);
|
|
3831
3605
|
return;
|
|
3832
3606
|
}
|
|
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}"`);
|
|
3607
|
+
for (const name of namesToResolve) if (!channelNameToId.has(name)) logger.warn(`channel "${name}" not found in workspace`);
|
|
3846
3608
|
}
|
|
3847
3609
|
async function start() {
|
|
3848
3610
|
const authResult = await webClient.auth.test();
|
|
@@ -3931,9 +3693,13 @@ async function handleSessionRetry(sessionUid, userId, handler) {
|
|
|
3931
3693
|
logger.error(`retry error: ${err instanceof Error ? err.message : String(err)}`);
|
|
3932
3694
|
}
|
|
3933
3695
|
}
|
|
3934
|
-
function isChannelAllowed(channelId, channels) {
|
|
3696
|
+
function isChannelAllowed(channelId, channels, nameToId) {
|
|
3935
3697
|
if (channels.includes("*")) return true;
|
|
3936
|
-
|
|
3698
|
+
for (const ch of channels) {
|
|
3699
|
+
if (ch === channelId) return true;
|
|
3700
|
+
if (nameToId.get(ch) === channelId) return true;
|
|
3701
|
+
}
|
|
3702
|
+
return false;
|
|
3937
3703
|
}
|
|
3938
3704
|
function isQuietHours(quietHours) {
|
|
3939
3705
|
if (!quietHours.enabled) return false;
|
|
@@ -4793,7 +4559,6 @@ function printConfig(configPath) {
|
|
|
4793
4559
|
console.log(`\n${c("cyan", "agents:")}`);
|
|
4794
4560
|
for (const a of config.agents) {
|
|
4795
4561
|
console.log(` ${c("cyan", a.id)} (${a.name})`);
|
|
4796
|
-
console.log(` ${c("dim", "provider:")} ${a.provider}`);
|
|
4797
4562
|
console.log(` ${c("dim", "model:")} ${a.model}`);
|
|
4798
4563
|
console.log(` ${c("dim", "max turns:")} ${a.maxTurns}`);
|
|
4799
4564
|
console.log(` ${c("dim", "channels:")} ${a.channels.join(", ") || "*"}`);
|