@owloops/browserbird 1.2.4 → 1.2.5

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: ${"6c46374ad2f83536a49768a46c02b8a043b1304a".substring(0, 7)}`);
126
+ buildInfo.push(`built: 2026-03-05T12:20:55+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
@@ -2861,6 +2864,29 @@ function truncate(text, maxLength) {
2861
2864
  return text.slice(0, maxLength) + "...";
2862
2865
  }
2863
2866
 
2867
+ //#endregion
2868
+ //#region src/browser/lock.ts
2869
+ /** @fileoverview SQLite-based browser lock for persistent browser mode. */
2870
+ /**
2871
+ * Attempts to acquire the browser lock for a given holder.
2872
+ * Uses a single conditional UPSERT: inserts if absent, overwrites if stale.
2873
+ * Returns false when an active (non-stale) lock is held by someone else.
2874
+ */
2875
+ function acquireBrowserLock(holder, timeoutMs) {
2876
+ const timeoutSeconds = Math.ceil(timeoutMs / 1e3);
2877
+ const result = getDb().prepare(`INSERT INTO browser_lock (id, holder, acquired_at)
2878
+ VALUES (1, ?, datetime('now'))
2879
+ ON CONFLICT(id) DO UPDATE SET
2880
+ holder = excluded.holder,
2881
+ acquired_at = excluded.acquired_at
2882
+ WHERE acquired_at <= datetime('now', '-' || ? || ' seconds')`).run(holder, timeoutSeconds);
2883
+ return Number(result.changes) > 0;
2884
+ }
2885
+ /** Releases the browser lock only if the caller is the current holder. */
2886
+ function releaseBrowserLock(holder) {
2887
+ getDb().prepare("DELETE FROM browser_lock WHERE id = 1 AND holder = ?").run(holder);
2888
+ }
2889
+
2864
2890
  //#endregion
2865
2891
  //#region src/cron/scheduler.ts
2866
2892
  const TICK_INTERVAL_MS = 6e4;
@@ -2898,40 +2924,48 @@ function startScheduler(config, signal, deps) {
2898
2924
  const payload = raw;
2899
2925
  const agent = config.agents.find((a) => a.id === payload.agentId);
2900
2926
  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);
2927
+ const needsBrowserLock = config.browser.enabled && getBrowserMode() === "persistent";
2928
+ if (needsBrowserLock) {
2929
+ if (!acquireBrowserLock(payload.cronJobUid, config.sessions.processTimeoutMs)) throw new Error("browser is locked by another session");
2919
2930
  }
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)";
2924
- }
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 });
2931
+ try {
2932
+ const { events } = spawnProvider(agent.provider, {
2933
+ message: payload.prompt,
2934
+ agent,
2935
+ mcpConfigPath: config.browser.mcpConfigPath
2936
+ }, signal);
2937
+ if (payload.channelId) logMessage(payload.channelId, null, agent.id, "in", payload.prompt);
2938
+ let result = "";
2939
+ let completion;
2940
+ for await (const event of events) if (event.type === "text_delta") result += redact(event.delta);
2941
+ else if (event.type === "completion") completion = event;
2942
+ else if (event.type === "rate_limit") logger.debug(`bird ${shortUid(payload.cronJobUid)} rate limit window resets ${(/* @__PURE__ */ new Date(event.resetsAt * 1e3)).toISOString()}`);
2943
+ else if (event.type === "error") {
2944
+ const safeError = redact(event.error);
2945
+ if (payload.channelId && deps?.postToSlack) {
2946
+ const blocks = sessionErrorBlocks(safeError, { birdName: agent.name });
2947
+ await deps.postToSlack(payload.channelId, `Bird failed: ${safeError}`, { blocks });
2948
+ }
2949
+ throw new Error(safeError);
2950
+ }
2951
+ if (completion && payload.channelId) logMessage(payload.channelId, null, agent.id, "out", result || void 0, completion.tokensIn, completion.tokensOut);
2952
+ if (!result) {
2953
+ logger.info(`bird ${shortUid(payload.cronJobUid)} completed (no output)`);
2954
+ return "completed (no output)";
2931
2955
  }
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;
2956
+ if (payload.channelId && deps?.postToSlack) {
2957
+ await deps.postToSlack(payload.channelId, result);
2958
+ if (completion) {
2959
+ const blocks = sessionCompleteBlocks(completion, void 0, agent.name);
2960
+ const fallback = `Bird ${agent.name} completed: ${completion.numTurns} turns`;
2961
+ await deps.postToSlack(payload.channelId, fallback, { blocks });
2962
+ }
2963
+ logger.info(`bird ${shortUid(payload.cronJobUid)} result posted to ${payload.channelId}`);
2964
+ } else logger.info(`bird ${shortUid(payload.cronJobUid)} completed (${result.length} chars)`);
2965
+ return result;
2966
+ } finally {
2967
+ if (needsBrowserLock) releaseBrowserLock(payload.cronJobUid);
2968
+ }
2935
2969
  });
2936
2970
  registerHandler("system_cron_run", (raw) => {
2937
2971
  const payload = raw;
@@ -3162,6 +3196,12 @@ function createHandler(client, config, signal, getTeamId) {
3162
3196
  logger.warn("max concurrent sessions reached");
3163
3197
  return;
3164
3198
  }
3199
+ const needsBrowserLock = config.browser.enabled && getBrowserMode() === "persistent";
3200
+ if (needsBrowserLock && !acquireBrowserLock(key, config.sessions.processTimeoutMs)) {
3201
+ await client.postMessage(channelId, threadTs, "The browser is in use by another session. Your message will be processed when it finishes.");
3202
+ lock.queue.push(dispatch);
3203
+ return;
3204
+ }
3165
3205
  lock.processing = true;
3166
3206
  activeSpawns++;
3167
3207
  let sessionUid;
@@ -3202,6 +3242,7 @@ function createHandler(client, config, signal, getTeamId) {
3202
3242
  await client.postMessage(channelId, threadTs, `Something went wrong: ${errMsg}`, { blocks });
3203
3243
  } catch {}
3204
3244
  } finally {
3245
+ if (needsBrowserLock) releaseBrowserLock(key);
3205
3246
  activeSpawns--;
3206
3247
  lock.processing = false;
3207
3248
  lock.killCurrent = null;
@@ -3737,7 +3778,7 @@ async function handleBirdCreateSubmission(view, webClient, defaultTimezone) {
3737
3778
  logger.warn("bird_create submission missing required fields");
3738
3779
  return;
3739
3780
  }
3740
- const { createCronJob, setCronJobEnabled } = await import("./db-DAXqDqAz.mjs").then((n) => n.t);
3781
+ const { createCronJob, setCronJobEnabled } = await import("./db-C9ESgb0d.mjs").then((n) => n.t);
3741
3782
  const bird = createCronJob(name, schedule, prompt, channelId || void 0, "default", defaultTimezone);
3742
3783
  if (enabledValue !== "enabled") setCronJobEnabled(bird.uid, false);
3743
3784
  await webClient.chat.postMessage({
@@ -3751,7 +3792,7 @@ async function handleBirdCreateSubmission(view, webClient, defaultTimezone) {
3751
3792
  }
3752
3793
  async function handleSessionRetry(sessionUid, channelId, userId, config, handler) {
3753
3794
  try {
3754
- const { getSession, getLastInboundMessage } = await import("./db-DAXqDqAz.mjs").then((n) => n.t);
3795
+ const { getSession, getLastInboundMessage } = await import("./db-C9ESgb0d.mjs").then((n) => n.t);
3755
3796
  const session = getSession(sessionUid);
3756
3797
  if (!session) {
3757
3798
  logger.warn(`retry: session ${sessionUid} not found`);
@@ -3857,7 +3898,7 @@ async function startDaemon(options) {
3857
3898
  logger.success("browserbird orchestrator started");
3858
3899
  logger.info(`agents: ${config.agents.map((a) => a.id).join(", ")}`);
3859
3900
  logger.info(`max concurrent sessions: ${config.sessions.maxConcurrent}`);
3860
- if (config.browser.enabled) logger.info(`browser mode: ${process.env["BROWSER_MODE"] ?? "persistent"}`);
3901
+ if (config.browser.enabled) logger.info(`browser mode: ${getBrowserMode()}`);
3861
3902
  };
3862
3903
  const onLaunch = async () => {
3863
3904
  loadDotEnv(envPath);
@@ -4622,7 +4663,7 @@ function handleConfig(argv) {
4622
4663
  allowPositionals: false,
4623
4664
  strict: false
4624
4665
  });
4625
- printConfig(values.config);
4666
+ printConfig(values.config ?? process.env["BROWSERBIRD_CONFIG"]);
4626
4667
  }
4627
4668
  function printConfig(configPath) {
4628
4669
  const config = loadRawConfig(configPath);
@@ -4652,7 +4693,7 @@ function printConfig(configPath) {
4652
4693
  console.log(`\n${c("cyan", "browser:")}`);
4653
4694
  console.log(` ${c("dim", "enabled:")} ${config.browser.enabled ? "yes" : "no"}`);
4654
4695
  if (config.browser.enabled) {
4655
- console.log(` ${c("dim", "mode:")} ${process.env["BROWSER_MODE"] ?? "persistent"}`);
4696
+ console.log(` ${c("dim", "mode:")} ${getBrowserMode()}`);
4656
4697
  console.log(` ${c("dim", "vnc port:")} ${config.browser.vncPort}`);
4657
4698
  console.log(` ${c("dim", "novnc port:")} ${config.browser.novncPort}`);
4658
4699
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owloops/browserbird",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
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": {