@owloops/browserbird 1.2.4 → 1.2.6

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.
@@ -290,6 +290,17 @@ const MIGRATIONS = [{
290
290
  ON logs(level, source, created_at DESC);
291
291
  `);
292
292
  }
293
+ }, {
294
+ name: "browser lock",
295
+ up(d) {
296
+ d.exec(`
297
+ CREATE TABLE IF NOT EXISTS browser_lock (
298
+ id INTEGER PRIMARY KEY CHECK(id = 1),
299
+ holder TEXT NOT NULL,
300
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now'))
301
+ );
302
+ `);
303
+ }
293
304
  }];
294
305
  let db = null;
295
306
  function getSchemaVersion(d) {
@@ -1025,4 +1036,4 @@ var db_exports = /* @__PURE__ */ __exportAll({
1025
1036
  });
1026
1037
 
1027
1038
  //#endregion
1028
- export { updateSessionProviderId as $, completeCronRun as A, listFlights as B, failStaleJobs as C, retryAllFailedJobs as D, listJobs as E, ensureSystemCronJob as F, deleteStaleSessions as G, updateCronJob as H, getCronJob as I, getSessionCount as J, findSession as K, getEnabledCronJobs as L, createCronRun as M, deleteCronJob as N, retryJob as O, deleteOldCronRuns as P, touchSession as Q, getFlightStats as R, failJob as S, hasPendingCronJob as T, updateCronJobStatus as U, setCronJobEnabled as V, createSession as W, getSessionTokenStats as X, getSessionMessages as Y, listSessions as Z, clearJobs as _, getSetting as a, logger as at, deleteJob as b, getUserCount as c, getRecentLogs as d, shortUid as et, insertLog as f, claimNextJob as g, logMessage as h, createUser as i, resolveByUid as it, createCronJob as j, SYSTEM_CRON_PREFIX as k, setSetting as l, getMessageStats as m, resolveDbPath as n, openDatabase as nt, getUserByEmail as o, deleteOldMessages as p, getSession as q, resolveDbPathFromArgv as r, optimizeDatabase as rt, getUserById as s, db_exports as t, closeDatabase as tt, deleteOldLogs as u, completeJob as v, getJobStats as w, deleteOldJobs as x, createJob as y, listCronJobs as z };
1039
+ export { updateSessionProviderId as $, completeCronRun as A, listFlights as B, failStaleJobs as C, retryAllFailedJobs as D, listJobs as E, ensureSystemCronJob as F, deleteStaleSessions as G, updateCronJob as H, getCronJob as I, getSessionCount as J, findSession as K, getEnabledCronJobs as L, createCronRun as M, deleteCronJob as N, retryJob as O, deleteOldCronRuns as P, touchSession as Q, getFlightStats as R, failJob as S, hasPendingCronJob as T, updateCronJobStatus as U, setCronJobEnabled as V, createSession as W, getSessionTokenStats as X, getSessionMessages as Y, listSessions as Z, clearJobs as _, getSetting as a, resolveByUid as at, deleteJob as b, getUserCount as c, getRecentLogs as d, shortUid as et, insertLog as f, claimNextJob as g, logMessage as h, createUser as i, optimizeDatabase as it, createCronJob as j, SYSTEM_CRON_PREFIX as k, setSetting as l, getMessageStats as m, resolveDbPath as n, getDb as nt, getUserByEmail as o, logger as ot, deleteOldMessages as p, getSession as q, resolveDbPathFromArgv as r, openDatabase as rt, getUserById as s, db_exports as t, closeDatabase as tt, deleteOldLogs as u, completeJob as v, getJobStats as w, deleteOldJobs as x, createJob as y, listCronJobs as z };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as updateSessionProviderId, A as completeCronRun, B as listFlights, C as failStaleJobs, D as retryAllFailedJobs, E as listJobs, F as ensureSystemCronJob, G as deleteStaleSessions, H as updateCronJob, I as getCronJob, J as getSessionCount, K as findSession, L as getEnabledCronJobs, M as createCronRun, N as deleteCronJob, O as retryJob, P as deleteOldCronRuns, Q as touchSession, R as getFlightStats, S as failJob, T as hasPendingCronJob, U as updateCronJobStatus, V as setCronJobEnabled, W as createSession, X as getSessionTokenStats, Y as getSessionMessages, Z as listSessions, _ as clearJobs, a as getSetting, at as logger, b as deleteJob, c as getUserCount, d as getRecentLogs, et as shortUid, f as insertLog, g as claimNextJob, h as logMessage, i as createUser, it as resolveByUid, j as createCronJob, k as SYSTEM_CRON_PREFIX, l as setSetting, m as getMessageStats, n as resolveDbPath, nt as openDatabase, o as getUserByEmail, p as deleteOldMessages, q as getSession, r as resolveDbPathFromArgv, rt as optimizeDatabase, s as getUserById, tt as closeDatabase, u as deleteOldLogs, v as completeJob, w as getJobStats, x as deleteOldJobs, y as createJob, z as listCronJobs } from "./db-DAXqDqAz.mjs";
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-C9ESgb0d.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { parseArgs, styleText } from "node:util";
4
4
  import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -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: ${"e70043fd3f928c23a5fd3728b655509809d9b0b5".substring(0, 7)}`);
126
- buildInfo.push(`built: 2026-03-05T11:55:24+04:00`);
125
+ buildInfo.push(`commit: ${"536f400d5d95ecfb7467a216235dcdd8e7d9573f".substring(0, 7)}`);
126
+ buildInfo.push(`built: 2026-03-05T15:03:39+04:00`);
127
127
  const buildString = buildInfo.length > 0 ? ` (${buildInfo.join(", ")})` : "";
128
128
  const VERSION = `browserbird ${pkg.version}${buildString}`;
129
129
  const BIRD = [
@@ -248,8 +248,7 @@ function validateConfig(config) {
248
248
  if (!Array.isArray(agent.channels) || agent.channels.length === 0) throw new Error(`agent "${agent.id}": "channels" must be a non-empty array`);
249
249
  if (agent.fallbackModel && agent.fallbackModel === agent.model) throw new Error(`agent "${agent.id}": fallbackModel cannot be the same as model ("${agent.model}")`);
250
250
  }
251
- const browserMode = process.env["BROWSER_MODE"] ?? "persistent";
252
- if (config.browser.enabled && browserMode === "persistent" && config.sessions.maxConcurrent > 1) logger.warn("persistent browser mode with maxConcurrent > 1 will cause lock contention; use \"isolated\" or set maxConcurrent to 1");
251
+ if (config.browser.enabled && getBrowserMode() === "persistent" && config.sessions.maxConcurrent > 1) logger.warn("persistent browser mode with maxConcurrent > 1 will cause lock contention; use \"isolated\" or set maxConcurrent to 1");
253
252
  }
254
253
  /**
255
254
  * Reads and merges JSON config with DEFAULTS but skips env: resolution.
@@ -313,6 +312,10 @@ function ensureMcpConfig(config, configDir) {
313
312
  config.browser.mcpConfigPath = mcpPath;
314
313
  logger.info(`generated mcp config at ${mcpPath} (host: ${host})`);
315
314
  }
315
+ /** Returns the browser mode from BROWSER_MODE env var, defaulting to 'persistent'. */
316
+ function getBrowserMode() {
317
+ return process.env["BROWSER_MODE"] ?? "persistent";
318
+ }
316
319
  /** Atomic write: writes to a .tmp file then renames over the target. */
317
320
  function saveConfig(configPath, data) {
318
321
  const tmp = configPath + ".tmp";
@@ -908,7 +911,7 @@ function sanitizeConfig(config) {
908
911
  birds: config.birds,
909
912
  browser: {
910
913
  enabled: config.browser.enabled,
911
- mode: process.env["BROWSER_MODE"] ?? "persistent",
914
+ mode: getBrowserMode(),
912
915
  novncHost: config.browser.novncHost,
913
916
  vncPort: config.browser.vncPort,
914
917
  novncPort: config.browser.novncPort
@@ -2103,6 +2106,7 @@ function splitLines(buffer, chunk) {
2103
2106
 
2104
2107
  //#endregion
2105
2108
  //#region src/provider/claude.ts
2109
+ /** @fileoverview Claude Code CLI provider: arg building and stream-json parsing. */
2106
2110
  function buildCommand$1(options) {
2107
2111
  const { message, sessionId, agent, mcpConfigPath } = options;
2108
2112
  const args = [
@@ -2123,10 +2127,12 @@ function buildCommand$1(options) {
2123
2127
  args.push("--dangerously-skip-permissions");
2124
2128
  const oauthToken = process.env["CLAUDE_CODE_OAUTH_TOKEN"];
2125
2129
  const apiKey = process.env["ANTHROPIC_API_KEY"];
2130
+ const env = oauthToken ? { CLAUDE_CODE_OAUTH_TOKEN: oauthToken } : apiKey ? { ANTHROPIC_API_KEY: apiKey } : {};
2131
+ env["CLAUDE_CONFIG_DIR"] = resolve(".browserbird", "claude");
2126
2132
  return {
2127
2133
  binary: "claude",
2128
2134
  args,
2129
- env: oauthToken ? { CLAUDE_CODE_OAUTH_TOKEN: oauthToken } : apiKey ? { ANTHROPIC_API_KEY: apiKey } : {}
2135
+ env
2130
2136
  };
2131
2137
  }
2132
2138
  /**
@@ -2861,6 +2867,29 @@ function truncate(text, maxLength) {
2861
2867
  return text.slice(0, maxLength) + "...";
2862
2868
  }
2863
2869
 
2870
+ //#endregion
2871
+ //#region src/browser/lock.ts
2872
+ /** @fileoverview SQLite-based browser lock for persistent browser mode. */
2873
+ /**
2874
+ * Attempts to acquire the browser lock for a given holder.
2875
+ * Uses a single conditional UPSERT: inserts if absent, overwrites if stale.
2876
+ * Returns false when an active (non-stale) lock is held by someone else.
2877
+ */
2878
+ function acquireBrowserLock(holder, timeoutMs) {
2879
+ const timeoutSeconds = Math.ceil(timeoutMs / 1e3);
2880
+ const result = getDb().prepare(`INSERT INTO browser_lock (id, holder, acquired_at)
2881
+ VALUES (1, ?, datetime('now'))
2882
+ ON CONFLICT(id) DO UPDATE SET
2883
+ holder = excluded.holder,
2884
+ acquired_at = excluded.acquired_at
2885
+ WHERE acquired_at <= datetime('now', '-' || ? || ' seconds')`).run(holder, timeoutSeconds);
2886
+ return Number(result.changes) > 0;
2887
+ }
2888
+ /** Releases the browser lock only if the caller is the current holder. */
2889
+ function releaseBrowserLock(holder) {
2890
+ getDb().prepare("DELETE FROM browser_lock WHERE id = 1 AND holder = ?").run(holder);
2891
+ }
2892
+
2864
2893
  //#endregion
2865
2894
  //#region src/cron/scheduler.ts
2866
2895
  const TICK_INTERVAL_MS = 6e4;
@@ -2898,40 +2927,48 @@ function startScheduler(config, signal, deps) {
2898
2927
  const payload = raw;
2899
2928
  const agent = config.agents.find((a) => a.id === payload.agentId);
2900
2929
  if (!agent) throw new Error(`agent "${payload.agentId}" not found`);
2901
- const { events } = spawnProvider(agent.provider, {
2902
- message: payload.prompt,
2903
- agent,
2904
- mcpConfigPath: config.browser.mcpConfigPath
2905
- }, signal);
2906
- if (payload.channelId) logMessage(payload.channelId, null, agent.id, "in", payload.prompt);
2907
- let result = "";
2908
- let completion;
2909
- for await (const event of events) if (event.type === "text_delta") result += redact(event.delta);
2910
- else if (event.type === "completion") completion = event;
2911
- else if (event.type === "rate_limit") logger.debug(`bird ${shortUid(payload.cronJobUid)} rate limit window resets ${(/* @__PURE__ */ new Date(event.resetsAt * 1e3)).toISOString()}`);
2912
- else if (event.type === "error") {
2913
- const safeError = redact(event.error);
2914
- if (payload.channelId && deps?.postToSlack) {
2915
- const blocks = sessionErrorBlocks(safeError, { birdName: agent.name });
2916
- await deps.postToSlack(payload.channelId, `Bird failed: ${safeError}`, { blocks });
2917
- }
2918
- throw new Error(safeError);
2919
- }
2920
- if (completion && payload.channelId) logMessage(payload.channelId, null, agent.id, "out", result || void 0, completion.tokensIn, completion.tokensOut);
2921
- if (!result) {
2922
- logger.info(`bird ${shortUid(payload.cronJobUid)} completed (no output)`);
2923
- return "completed (no output)";
2930
+ const needsBrowserLock = config.browser.enabled && getBrowserMode() === "persistent";
2931
+ if (needsBrowserLock) {
2932
+ if (!acquireBrowserLock(payload.cronJobUid, config.sessions.processTimeoutMs)) throw new Error("browser is locked by another session");
2924
2933
  }
2925
- if (payload.channelId && deps?.postToSlack) {
2926
- await deps.postToSlack(payload.channelId, result);
2927
- if (completion) {
2928
- const blocks = sessionCompleteBlocks(completion, void 0, agent.name);
2929
- const fallback = `Bird ${agent.name} completed: ${completion.numTurns} turns`;
2930
- await deps.postToSlack(payload.channelId, fallback, { blocks });
2934
+ try {
2935
+ const { events } = spawnProvider(agent.provider, {
2936
+ message: payload.prompt,
2937
+ agent,
2938
+ mcpConfigPath: config.browser.mcpConfigPath
2939
+ }, signal);
2940
+ if (payload.channelId) logMessage(payload.channelId, null, agent.id, "in", payload.prompt);
2941
+ let result = "";
2942
+ let completion;
2943
+ for await (const event of events) if (event.type === "text_delta") result += redact(event.delta);
2944
+ else if (event.type === "completion") completion = event;
2945
+ else if (event.type === "rate_limit") logger.debug(`bird ${shortUid(payload.cronJobUid)} rate limit window resets ${(/* @__PURE__ */ new Date(event.resetsAt * 1e3)).toISOString()}`);
2946
+ else if (event.type === "error") {
2947
+ const safeError = redact(event.error);
2948
+ if (payload.channelId && deps?.postToSlack) {
2949
+ const blocks = sessionErrorBlocks(safeError, { birdName: agent.name });
2950
+ await deps.postToSlack(payload.channelId, `Bird failed: ${safeError}`, { blocks });
2951
+ }
2952
+ throw new Error(safeError);
2953
+ }
2954
+ if (completion && payload.channelId) logMessage(payload.channelId, null, agent.id, "out", result || void 0, completion.tokensIn, completion.tokensOut);
2955
+ if (!result) {
2956
+ logger.info(`bird ${shortUid(payload.cronJobUid)} completed (no output)`);
2957
+ return "completed (no output)";
2931
2958
  }
2932
- logger.info(`bird ${shortUid(payload.cronJobUid)} result posted to ${payload.channelId}`);
2933
- } else logger.info(`bird ${shortUid(payload.cronJobUid)} completed (${result.length} chars)`);
2934
- return result;
2959
+ if (payload.channelId && deps?.postToSlack) {
2960
+ await deps.postToSlack(payload.channelId, result);
2961
+ if (completion) {
2962
+ const blocks = sessionCompleteBlocks(completion, void 0, agent.name);
2963
+ const fallback = `Bird ${agent.name} completed: ${completion.numTurns} turns`;
2964
+ await deps.postToSlack(payload.channelId, fallback, { blocks });
2965
+ }
2966
+ logger.info(`bird ${shortUid(payload.cronJobUid)} result posted to ${payload.channelId}`);
2967
+ } else logger.info(`bird ${shortUid(payload.cronJobUid)} completed (${result.length} chars)`);
2968
+ return result;
2969
+ } finally {
2970
+ if (needsBrowserLock) releaseBrowserLock(payload.cronJobUid);
2971
+ }
2935
2972
  });
2936
2973
  registerHandler("system_cron_run", (raw) => {
2937
2974
  const payload = raw;
@@ -3162,6 +3199,12 @@ function createHandler(client, config, signal, getTeamId) {
3162
3199
  logger.warn("max concurrent sessions reached");
3163
3200
  return;
3164
3201
  }
3202
+ const needsBrowserLock = config.browser.enabled && getBrowserMode() === "persistent";
3203
+ if (needsBrowserLock && !acquireBrowserLock(key, config.sessions.processTimeoutMs)) {
3204
+ await client.postMessage(channelId, threadTs, "The browser is in use by another session. Your message will be processed when it finishes.");
3205
+ lock.queue.push(dispatch);
3206
+ return;
3207
+ }
3165
3208
  lock.processing = true;
3166
3209
  activeSpawns++;
3167
3210
  let sessionUid;
@@ -3202,6 +3245,7 @@ function createHandler(client, config, signal, getTeamId) {
3202
3245
  await client.postMessage(channelId, threadTs, `Something went wrong: ${errMsg}`, { blocks });
3203
3246
  } catch {}
3204
3247
  } finally {
3248
+ if (needsBrowserLock) releaseBrowserLock(key);
3205
3249
  activeSpawns--;
3206
3250
  lock.processing = false;
3207
3251
  lock.killCurrent = null;
@@ -3737,7 +3781,7 @@ async function handleBirdCreateSubmission(view, webClient, defaultTimezone) {
3737
3781
  logger.warn("bird_create submission missing required fields");
3738
3782
  return;
3739
3783
  }
3740
- const { createCronJob, setCronJobEnabled } = await import("./db-DAXqDqAz.mjs").then((n) => n.t);
3784
+ const { createCronJob, setCronJobEnabled } = await import("./db-C9ESgb0d.mjs").then((n) => n.t);
3741
3785
  const bird = createCronJob(name, schedule, prompt, channelId || void 0, "default", defaultTimezone);
3742
3786
  if (enabledValue !== "enabled") setCronJobEnabled(bird.uid, false);
3743
3787
  await webClient.chat.postMessage({
@@ -3751,7 +3795,7 @@ async function handleBirdCreateSubmission(view, webClient, defaultTimezone) {
3751
3795
  }
3752
3796
  async function handleSessionRetry(sessionUid, channelId, userId, config, handler) {
3753
3797
  try {
3754
- const { getSession, getLastInboundMessage } = await import("./db-DAXqDqAz.mjs").then((n) => n.t);
3798
+ const { getSession, getLastInboundMessage } = await import("./db-C9ESgb0d.mjs").then((n) => n.t);
3755
3799
  const session = getSession(sessionUid);
3756
3800
  if (!session) {
3757
3801
  logger.warn(`retry: session ${sessionUid} not found`);
@@ -3857,7 +3901,7 @@ async function startDaemon(options) {
3857
3901
  logger.success("browserbird orchestrator started");
3858
3902
  logger.info(`agents: ${config.agents.map((a) => a.id).join(", ")}`);
3859
3903
  logger.info(`max concurrent sessions: ${config.sessions.maxConcurrent}`);
3860
- if (config.browser.enabled) logger.info(`browser mode: ${process.env["BROWSER_MODE"] ?? "persistent"}`);
3904
+ if (config.browser.enabled) logger.info(`browser mode: ${getBrowserMode()}`);
3861
3905
  };
3862
3906
  const onLaunch = async () => {
3863
3907
  loadDotEnv(envPath);
@@ -4622,7 +4666,7 @@ function handleConfig(argv) {
4622
4666
  allowPositionals: false,
4623
4667
  strict: false
4624
4668
  });
4625
- printConfig(values.config);
4669
+ printConfig(values.config ?? process.env["BROWSERBIRD_CONFIG"]);
4626
4670
  }
4627
4671
  function printConfig(configPath) {
4628
4672
  const config = loadRawConfig(configPath);
@@ -4652,7 +4696,7 @@ function printConfig(configPath) {
4652
4696
  console.log(`\n${c("cyan", "browser:")}`);
4653
4697
  console.log(` ${c("dim", "enabled:")} ${config.browser.enabled ? "yes" : "no"}`);
4654
4698
  if (config.browser.enabled) {
4655
- console.log(` ${c("dim", "mode:")} ${process.env["BROWSER_MODE"] ?? "persistent"}`);
4699
+ console.log(` ${c("dim", "mode:")} ${getBrowserMode()}`);
4656
4700
  console.log(` ${c("dim", "vnc port:")} ${config.browser.vncPort}`);
4657
4701
  console.log(` ${c("dim", "novnc port:")} ${config.browser.novncPort}`);
4658
4702
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owloops/browserbird",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "Self-hosted AI agent for Slack with a real browser, a scheduler, and a web dashboard",
5
5
  "type": "module",
6
6
  "bin": {