@owloops/browserbird 1.2.6 → 1.2.8

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.
@@ -209,7 +209,6 @@ const MIGRATIONS = [{
209
209
  target_channel_id TEXT,
210
210
  active_hours_start TEXT,
211
211
  active_hours_end TEXT,
212
- timezone TEXT DEFAULT 'UTC',
213
212
  enabled INTEGER NOT NULL DEFAULT 1,
214
213
  failure_count INTEGER NOT NULL DEFAULT 0,
215
214
  last_run TEXT,
@@ -536,11 +535,11 @@ function listCronJobs(page = 1, perPage = DEFAULT_PER_PAGE, includeSystem = fals
536
535
  function getEnabledCronJobs() {
537
536
  return getDb().prepare("SELECT * FROM cron_jobs WHERE enabled = 1 ORDER BY created_at").all();
538
537
  }
539
- function createCronJob(name, schedule, prompt, targetChannelId, agentId, timezone, activeHoursStart, activeHoursEnd) {
538
+ function createCronJob(name, schedule, prompt, targetChannelId, agentId, activeHoursStart, activeHoursEnd) {
540
539
  const uid = generateUid(UID_PREFIX.bird);
541
- return getDb().prepare(`INSERT INTO cron_jobs (uid, name, schedule, prompt, target_channel_id, agent_id, timezone, active_hours_start, active_hours_end)
542
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
543
- RETURNING *`).get(uid, name, schedule, prompt, targetChannelId ?? null, agentId ?? "default", timezone ?? "UTC", activeHoursStart ?? null, activeHoursEnd ?? null);
540
+ return getDb().prepare(`INSERT INTO cron_jobs (uid, name, schedule, prompt, target_channel_id, agent_id, active_hours_start, active_hours_end)
541
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
542
+ RETURNING *`).get(uid, name, schedule, prompt, targetChannelId ?? null, agentId ?? "default", activeHoursStart ?? null, activeHoursEnd ?? null);
544
543
  }
545
544
  function updateCronJobStatus(jobUid, status, failureCount) {
546
545
  getDb().prepare(`UPDATE cron_jobs SET last_run = datetime('now'), last_status = ?, failure_count = ? WHERE uid = ?`).run(status, failureCount, jobUid);
@@ -575,10 +574,6 @@ function updateCronJob(jobUid, fields) {
575
574
  sets.push("agent_id = ?");
576
575
  params.push(fields.agentId);
577
576
  }
578
- if (fields.timezone !== void 0) {
579
- sets.push("timezone = ?");
580
- params.push(fields.timezone);
581
- }
582
577
  if (fields.activeHoursStart !== void 0) {
583
578
  sets.push("active_hours_start = ?");
584
579
  params.push(fields.activeHoursStart);
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 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";
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-Da2zSpkY.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: ${"536f400d5d95ecfb7467a216235dcdd8e7d9573f".substring(0, 7)}`);
126
- buildInfo.push(`built: 2026-03-05T15:03:39+04:00`);
125
+ buildInfo.push(`commit: ${"a5294d87d4c94fa7527751eea09349373839b7cd".substring(0, 7)}`);
126
+ buildInfo.push(`built: 2026-03-06T15:38:21+04:00`);
127
127
  const buildString = buildInfo.length > 0 ? ` (${buildInfo.join(", ")})` : "";
128
128
  const VERSION = `browserbird ${pkg.version}${buildString}`;
129
129
  const BIRD = [
@@ -374,6 +374,37 @@ function loadDotEnv(envPath) {
374
374
  }
375
375
  }
376
376
 
377
+ //#endregion
378
+ //#region src/browser/lock.ts
379
+ /** @fileoverview SQLite-based browser lock for persistent browser mode. */
380
+ /**
381
+ * Attempts to acquire the browser lock for a given holder.
382
+ * Uses a single conditional UPSERT: inserts if absent, overwrites if stale.
383
+ * Returns false when an active (non-stale) lock is held by someone else.
384
+ */
385
+ function acquireBrowserLock(holder, timeoutMs) {
386
+ const timeoutSeconds = Math.ceil(timeoutMs / 1e3);
387
+ const result = getDb().prepare(`INSERT INTO browser_lock (id, holder, acquired_at)
388
+ VALUES (1, ?, datetime('now'))
389
+ ON CONFLICT(id) DO UPDATE SET
390
+ holder = excluded.holder,
391
+ acquired_at = excluded.acquired_at
392
+ WHERE acquired_at <= datetime('now', '-' || ? || ' seconds')`).run(holder, timeoutSeconds);
393
+ return Number(result.changes) > 0;
394
+ }
395
+ /** Refreshes the lock timestamp to prevent staleness during long operations. */
396
+ function refreshBrowserLock(holder) {
397
+ getDb().prepare(`UPDATE browser_lock SET acquired_at = datetime('now') WHERE id = 1 AND holder = ?`).run(holder);
398
+ }
399
+ /** Releases the browser lock only if the caller is the current holder. */
400
+ function releaseBrowserLock(holder) {
401
+ getDb().prepare("DELETE FROM browser_lock WHERE id = 1 AND holder = ?").run(holder);
402
+ }
403
+ /** Clears any browser lock unconditionally. Called on startup before any sessions exist. */
404
+ function clearBrowserLock() {
405
+ getDb().prepare("DELETE FROM browser_lock WHERE id = 1").run();
406
+ }
407
+
377
408
  //#endregion
378
409
  //#region src/server/auth.ts
379
410
  /** @fileoverview Password hashing, token signing, and verification using node:crypto. */
@@ -1353,7 +1384,7 @@ function buildRoutes(getConfig, startedAt, getDeps, options) {
1353
1384
  for (const bird of getEnabledCronJobs()) {
1354
1385
  if (bird.name.startsWith(SYSTEM_CRON_PREFIX)) continue;
1355
1386
  try {
1356
- const next = nextCronMatch(parseCron(bird.schedule), now, bird.timezone);
1387
+ const next = nextCronMatch(parseCron(bird.schedule), now, getConfig().timezone);
1357
1388
  if (next) upcoming.push({
1358
1389
  uid: bird.uid,
1359
1390
  name: bird.name,
@@ -1417,7 +1448,7 @@ function buildRoutes(getConfig, startedAt, getDeps, options) {
1417
1448
  jsonError(res, "\"prompt\" is required", 400);
1418
1449
  return;
1419
1450
  }
1420
- const job = createCronJob(deriveBirdName(body.prompt), body.schedule.trim(), body.prompt.trim(), body.channel?.trim() || void 0, body.agent?.trim() || void 0, body.timezone?.trim() || getConfig().timezone, body.activeHoursStart?.trim() || void 0, body.activeHoursEnd?.trim() || void 0);
1451
+ const job = createCronJob(deriveBirdName(body.prompt), body.schedule.trim(), body.prompt.trim(), body.channel?.trim() || void 0, body.agent?.trim() || void 0, body.activeHoursStart?.trim() || void 0, body.activeHoursEnd?.trim() || void 0);
1421
1452
  broadcastSSE("invalidate", { resource: "birds" });
1422
1453
  json(res, job, 201);
1423
1454
  }
@@ -1441,7 +1472,6 @@ function buildRoutes(getConfig, startedAt, getDeps, options) {
1441
1472
  name: body.prompt ? deriveBirdName(body.prompt) : void 0,
1442
1473
  targetChannelId: body.channel !== void 0 ? body.channel?.trim() || null : void 0,
1443
1474
  agentId: body.agent?.trim() || void 0,
1444
- timezone: body.timezone?.trim() || void 0,
1445
1475
  activeHoursStart: body.activeHoursStart !== void 0 ? body.activeHoursStart?.trim() || null : void 0,
1446
1476
  activeHoursEnd: body.activeHoursEnd !== void 0 ? body.activeHoursEnd?.trim() || null : void 0
1447
1477
  });
@@ -2121,7 +2151,10 @@ function buildCommand$1(options) {
2121
2151
  String(agent.maxTurns)
2122
2152
  ];
2123
2153
  if (sessionId) args.push("--resume", sessionId);
2124
- if (agent.systemPrompt) args.push("--append-system-prompt", agent.systemPrompt);
2154
+ const systemParts = [];
2155
+ if (agent.systemPrompt) systemParts.push(agent.systemPrompt);
2156
+ if (options.timezone) systemParts.push(`System timezone: ${options.timezone}. All cron expressions and scheduled times use this timezone.`);
2157
+ if (systemParts.length > 0) args.push("--append-system-prompt", systemParts.join(" "));
2125
2158
  if (mcpConfigPath) args.push("--mcp-config", mcpConfigPath);
2126
2159
  if (agent.fallbackModel) args.push("--fallback-model", agent.fallbackModel);
2127
2160
  args.push("--dangerously-skip-permissions");
@@ -2210,6 +2243,10 @@ function parseAssistantContent(parsed) {
2210
2243
  type: "text_delta",
2211
2244
  delta: b["text"]
2212
2245
  });
2246
+ else if (b["type"] === "tool_use" && typeof b["name"] === "string") events.push({
2247
+ type: "tool_use",
2248
+ toolName: b["name"]
2249
+ });
2213
2250
  }
2214
2251
  return events;
2215
2252
  }
@@ -2315,7 +2352,10 @@ function ensureWorkspace(mcpConfigPath, systemPrompt) {
2315
2352
  */
2316
2353
  function buildCommand(options) {
2317
2354
  const { message, sessionId, agent, mcpConfigPath } = options;
2318
- ensureWorkspace(mcpConfigPath, agent.systemPrompt);
2355
+ const systemParts = [];
2356
+ if (agent.systemPrompt) systemParts.push(agent.systemPrompt);
2357
+ if (options.timezone) systemParts.push(`System timezone: ${options.timezone}. All cron expressions and scheduled times use this timezone.`);
2358
+ ensureWorkspace(mcpConfigPath, systemParts.join(" ") || void 0);
2319
2359
  const args = [
2320
2360
  "run",
2321
2361
  "--format",
@@ -2428,6 +2468,14 @@ function parseStreamLine(line) {
2428
2468
  accumulators.delete(sid);
2429
2469
  return [completion];
2430
2470
  }
2471
+ case "tool_use": {
2472
+ const toolName = typeof part?.["tool"] === "string" && part["tool"] || "";
2473
+ if (toolName) return [{
2474
+ type: "tool_use",
2475
+ toolName
2476
+ }];
2477
+ return [];
2478
+ }
2431
2479
  case "error": {
2432
2480
  const err = parsed["error"];
2433
2481
  const data = err?.["data"];
@@ -2504,7 +2552,9 @@ function spawnProvider(provider, options, signal) {
2504
2552
  proc.stderr.on("data", (chunk) => {
2505
2553
  stderrBuf += chunk.toString("utf-8");
2506
2554
  });
2555
+ let timedOut = false;
2507
2556
  const timeout = setTimeout(() => {
2557
+ timedOut = true;
2508
2558
  logger.warn(`${cmd.binary} timed out after ${timeoutMs}ms, killing`);
2509
2559
  gracefulKill(proc);
2510
2560
  }, timeoutMs);
@@ -2517,6 +2567,10 @@ function spawnProvider(provider, options, signal) {
2517
2567
  buffer = b;
2518
2568
  });
2519
2569
  if (buffer.trim()) yield* mod.parseStreamLine(buffer);
2570
+ if (timedOut) yield {
2571
+ type: "timeout",
2572
+ timeoutMs
2573
+ };
2520
2574
  } finally {
2521
2575
  clearTimeout(timeout);
2522
2576
  signal.removeEventListener("abort", onAbort);
@@ -2720,6 +2774,20 @@ function sessionErrorBlocks(errorMessage, opts) {
2720
2774
  if (fieldPairs.length > 0) blocks.push(fields(...fieldPairs));
2721
2775
  return blocks;
2722
2776
  }
2777
+ function sessionTimeoutBlocks(timeoutMs, opts) {
2778
+ const minutes = Math.round(timeoutMs / 6e4);
2779
+ const blocks = [header("Session Timed Out"), section(`The session was stopped after *${minutes} minute${minutes === 1 ? "" : "s"}* (the configured limit).\n\nReply to continue in a new session, or increase \`sessions.processTimeoutMs\` in your config to allow longer runs.`)];
2780
+ if (opts?.sessionUid) blocks.push({
2781
+ type: "actions",
2782
+ elements: [{
2783
+ type: "button",
2784
+ text: plain("Retry"),
2785
+ action_id: "session_retry",
2786
+ value: `retry:${opts.sessionUid}`
2787
+ }]
2788
+ });
2789
+ return blocks;
2790
+ }
2723
2791
  function busyBlocks(activeCount, maxConcurrent) {
2724
2792
  return [section("*Too many active sessions*"), context(`${activeCount}/${maxConcurrent} slots in use. Try again shortly.`)];
2725
2793
  }
@@ -2867,31 +2935,10 @@ function truncate(text, maxLength) {
2867
2935
  return text.slice(0, maxLength) + "...";
2868
2936
  }
2869
2937
 
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
-
2893
2938
  //#endregion
2894
2939
  //#region src/cron/scheduler.ts
2940
+ const BROWSER_TOOL_PREFIX$1 = "mcp__playwright__";
2941
+ const LOCK_HEARTBEAT_MS$1 = 3e4;
2895
2942
  const TICK_INTERVAL_MS = 6e4;
2896
2943
  const MAX_SCHEDULE_ERRORS = 3;
2897
2944
  const systemHandlers = /* @__PURE__ */ new Map();
@@ -2928,19 +2975,26 @@ function startScheduler(config, signal, deps) {
2928
2975
  const agent = config.agents.find((a) => a.id === payload.agentId);
2929
2976
  if (!agent) throw new Error(`agent "${payload.agentId}" not found`);
2930
2977
  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");
2933
- }
2978
+ let browserLockAcquired = false;
2979
+ let heartbeatTimer;
2934
2980
  try {
2935
2981
  const { events } = spawnProvider(agent.provider, {
2936
2982
  message: payload.prompt,
2937
2983
  agent,
2938
- mcpConfigPath: config.browser.mcpConfigPath
2984
+ mcpConfigPath: config.browser.mcpConfigPath,
2985
+ timezone: config.timezone
2939
2986
  }, signal);
2940
2987
  if (payload.channelId) logMessage(payload.channelId, null, agent.id, "in", payload.prompt);
2941
2988
  let result = "";
2942
2989
  let completion;
2943
- for await (const event of events) if (event.type === "text_delta") result += redact(event.delta);
2990
+ for await (const event of events) if (event.type === "tool_use") {
2991
+ if (needsBrowserLock && !browserLockAcquired && event.toolName.startsWith(BROWSER_TOOL_PREFIX$1)) {
2992
+ if (!acquireBrowserLock(payload.cronJobUid, config.sessions.processTimeoutMs)) throw new Error("browser is locked by another session");
2993
+ browserLockAcquired = true;
2994
+ heartbeatTimer = setInterval(() => refreshBrowserLock(payload.cronJobUid), LOCK_HEARTBEAT_MS$1);
2995
+ logger.info(`browser lock acquired lazily for bird ${shortUid(payload.cronJobUid)}`);
2996
+ }
2997
+ } else if (event.type === "text_delta") result += redact(event.delta);
2944
2998
  else if (event.type === "completion") completion = event;
2945
2999
  else if (event.type === "rate_limit") logger.debug(`bird ${shortUid(payload.cronJobUid)} rate limit window resets ${(/* @__PURE__ */ new Date(event.resetsAt * 1e3)).toISOString()}`);
2946
3000
  else if (event.type === "error") {
@@ -2967,7 +3021,8 @@ function startScheduler(config, signal, deps) {
2967
3021
  } else logger.info(`bird ${shortUid(payload.cronJobUid)} completed (${result.length} chars)`);
2968
3022
  return result;
2969
3023
  } finally {
2970
- if (needsBrowserLock) releaseBrowserLock(payload.cronJobUid);
3024
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
3025
+ if (browserLockAcquired) releaseBrowserLock(payload.cronJobUid);
2971
3026
  }
2972
3027
  });
2973
3028
  registerHandler("system_cron_run", (raw) => {
@@ -3000,8 +3055,8 @@ function startScheduler(config, signal, deps) {
3000
3055
  } else logger.warn(`bird ${shortUid(job.uid)}: invalid expression "${job.schedule}" (attempt ${count}/${MAX_SCHEDULE_ERRORS})`);
3001
3056
  continue;
3002
3057
  }
3003
- if (!matchesCron(schedule, now, job.timezone)) continue;
3004
- if (!isWithinActiveHours(job.active_hours_start, job.active_hours_end, now, job.timezone)) {
3058
+ if (!matchesCron(schedule, now, config.timezone)) continue;
3059
+ if (!isWithinActiveHours(job.active_hours_start, job.active_hours_end, now, config.timezone)) {
3005
3060
  logger.debug(`bird ${shortUid(job.uid)} skipped: outside active hours`);
3006
3061
  continue;
3007
3062
  }
@@ -3101,6 +3156,8 @@ function createCoalescer(config, onDispatch) {
3101
3156
 
3102
3157
  //#endregion
3103
3158
  //#region src/channel/handler.ts
3159
+ const BROWSER_TOOL_PREFIX = "mcp__playwright__";
3160
+ const LOCK_HEARTBEAT_MS = 3e4;
3104
3161
  function createHandler(client, config, signal, getTeamId) {
3105
3162
  const locks = /* @__PURE__ */ new Map();
3106
3163
  let activeSpawns = 0;
@@ -3132,6 +3189,8 @@ function createHandler(client, config, signal, getTeamId) {
3132
3189
  let fullText = "";
3133
3190
  let completion;
3134
3191
  let hasError = false;
3192
+ let timedOut = false;
3193
+ let timedOutMs = 0;
3135
3194
  for await (const event of events) {
3136
3195
  if (signal.aborted) break;
3137
3196
  logger.debug(`stream event: ${event.type}`);
@@ -3148,6 +3207,9 @@ function createHandler(client, config, signal, getTeamId) {
3148
3207
  case "tool_images":
3149
3208
  await uploadImages(event.images, channelId, threadTs);
3150
3209
  break;
3210
+ case "tool_use":
3211
+ meta.onToolUse?.(event.toolName);
3212
+ break;
3151
3213
  case "completion":
3152
3214
  completion = event;
3153
3215
  logger.info(`completion [${event.subtype}]: ${event.tokensIn}in/${event.tokensOut}out, $${event.costUsd.toFixed(4)}, ${event.numTurns} turns`);
@@ -3164,10 +3226,19 @@ function createHandler(client, config, signal, getTeamId) {
3164
3226
  await streamer.append({ markdown_text: `\n\nError: ${safeError}` });
3165
3227
  break;
3166
3228
  }
3229
+ case "timeout":
3230
+ timedOut = true;
3231
+ timedOutMs = event.timeoutMs;
3232
+ logger.warn(`session timed out after ${event.timeoutMs}ms`);
3233
+ break;
3167
3234
  }
3168
3235
  }
3169
3236
  const footerBlocks = completion ? completionFooterBlocks(completion, hasError, meta.birdName, userId) : void 0;
3170
3237
  await streamer.stop(footerBlocks ? { blocks: footerBlocks } : {});
3238
+ if (timedOut && !completion) {
3239
+ const blocks = sessionTimeoutBlocks(timedOutMs, { sessionUid });
3240
+ await client.postMessage(channelId, threadTs, `Session timed out after ${Math.round(timedOutMs / 6e4)} minutes.`, { blocks });
3241
+ }
3171
3242
  }
3172
3243
  async function uploadImages(images, channelId, threadTs) {
3173
3244
  for (let i = 0; i < images.length; i++) {
@@ -3200,11 +3271,8 @@ function createHandler(client, config, signal, getTeamId) {
3200
3271
  return;
3201
3272
  }
3202
3273
  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
- }
3274
+ let browserLockAcquired = false;
3275
+ let heartbeatTimer;
3208
3276
  lock.processing = true;
3209
3277
  activeSpawns++;
3210
3278
  let sessionUid;
@@ -3227,7 +3295,8 @@ function createHandler(client, config, signal, getTeamId) {
3227
3295
  message: prompt,
3228
3296
  sessionId: existingSessionId,
3229
3297
  agent,
3230
- mcpConfigPath: config.browser.mcpConfigPath
3298
+ mcpConfigPath: config.browser.mcpConfigPath,
3299
+ timezone: config.timezone
3231
3300
  }, signal);
3232
3301
  lock.killCurrent = kill;
3233
3302
  client.setStatus?.(channelId, threadTs, "is thinking...").catch(() => {});
@@ -3235,7 +3304,22 @@ function createHandler(client, config, signal, getTeamId) {
3235
3304
  const title = prompt.length > 60 ? prompt.slice(0, 57) + "..." : prompt;
3236
3305
  client.setTitle?.(channelId, threadTs, title).catch(() => {});
3237
3306
  }
3238
- await streamToChannel(events, channelId, threadTs, session.uid, getTeamId(), userId, { birdName: agent.name });
3307
+ const onToolUse = (toolName) => {
3308
+ if (!needsBrowserLock || browserLockAcquired) return;
3309
+ if (!toolName.startsWith(BROWSER_TOOL_PREFIX)) return;
3310
+ if (acquireBrowserLock(key, config.sessions.processTimeoutMs)) {
3311
+ browserLockAcquired = true;
3312
+ heartbeatTimer = setInterval(() => refreshBrowserLock(key), LOCK_HEARTBEAT_MS);
3313
+ logger.info(`browser lock acquired lazily for ${key} (tool: ${toolName})`);
3314
+ } else {
3315
+ logger.warn(`browser lock unavailable for ${key} (tool: ${toolName})`);
3316
+ client.postMessage(channelId, threadTs, "The browser is in use by another session.").catch(() => {});
3317
+ }
3318
+ };
3319
+ await streamToChannel(events, channelId, threadTs, session.uid, getTeamId(), userId, {
3320
+ birdName: agent.name,
3321
+ onToolUse
3322
+ });
3239
3323
  } catch (err) {
3240
3324
  const errMsg = err instanceof Error ? err.message : String(err);
3241
3325
  logger.error(`handler error: ${errMsg}`);
@@ -3245,7 +3329,8 @@ function createHandler(client, config, signal, getTeamId) {
3245
3329
  await client.postMessage(channelId, threadTs, `Something went wrong: ${errMsg}`, { blocks });
3246
3330
  } catch {}
3247
3331
  } finally {
3248
- if (needsBrowserLock) releaseBrowserLock(key);
3332
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
3333
+ if (browserLockAcquired) releaseBrowserLock(key);
3249
3334
  activeSpawns--;
3250
3335
  lock.processing = false;
3251
3336
  lock.killCurrent = null;
@@ -3656,7 +3741,7 @@ function createSlackChannel(config, signal) {
3656
3741
  const interactionType = body["type"];
3657
3742
  if (interactionType === "view_submission") {
3658
3743
  const view = body["view"];
3659
- if (view?.["callback_id"] === "bird_create") await handleBirdCreateSubmission(view, webClient, config.timezone);
3744
+ if (view?.["callback_id"] === "bird_create") await handleBirdCreateSubmission(view, webClient);
3660
3745
  }
3661
3746
  if (interactionType === "block_actions") {
3662
3747
  const actionsArr = body["actions"];
@@ -3769,7 +3854,7 @@ function createSlackChannel(config, signal) {
3769
3854
  postMessage
3770
3855
  };
3771
3856
  }
3772
- async function handleBirdCreateSubmission(view, webClient, defaultTimezone) {
3857
+ async function handleBirdCreateSubmission(view, webClient) {
3773
3858
  try {
3774
3859
  const stateValues = view["state"]?.["values"] ?? {};
3775
3860
  const name = stateValues["bird_name"]?.["name_input"]?.["value"] ?? "";
@@ -3781,8 +3866,8 @@ async function handleBirdCreateSubmission(view, webClient, defaultTimezone) {
3781
3866
  logger.warn("bird_create submission missing required fields");
3782
3867
  return;
3783
3868
  }
3784
- const { createCronJob, setCronJobEnabled } = await import("./db-C9ESgb0d.mjs").then((n) => n.t);
3785
- const bird = createCronJob(name, schedule, prompt, channelId || void 0, "default", defaultTimezone);
3869
+ const { createCronJob, setCronJobEnabled } = await import("./db-Da2zSpkY.mjs").then((n) => n.t);
3870
+ const bird = createCronJob(name, schedule, prompt, channelId || void 0, "default");
3786
3871
  if (enabledValue !== "enabled") setCronJobEnabled(bird.uid, false);
3787
3872
  await webClient.chat.postMessage({
3788
3873
  channel: channelId || "general",
@@ -3795,7 +3880,7 @@ async function handleBirdCreateSubmission(view, webClient, defaultTimezone) {
3795
3880
  }
3796
3881
  async function handleSessionRetry(sessionUid, channelId, userId, config, handler) {
3797
3882
  try {
3798
- const { getSession, getLastInboundMessage } = await import("./db-C9ESgb0d.mjs").then((n) => n.t);
3883
+ const { getSession, getLastInboundMessage } = await import("./db-Da2zSpkY.mjs").then((n) => n.t);
3799
3884
  const session = getSession(sessionUid);
3800
3885
  if (!session) {
3801
3886
  logger.warn(`retry: session ${sessionUid} not found`);
@@ -3872,6 +3957,7 @@ async function startDaemon(options) {
3872
3957
  const configDir = dirname(configPath);
3873
3958
  const envPath = resolve(configDir, ".env");
3874
3959
  openDatabase(resolveDbPath(options.flags.db));
3960
+ clearBrowserLock();
3875
3961
  startWorker(controller.signal);
3876
3962
  loadDotEnv(envPath);
3877
3963
  let currentConfig = loadConfig(configPath);
@@ -4102,7 +4188,6 @@ ${c("dim", "options:")}
4102
4188
  ${c("yellow", "--agent")} <id> target agent id
4103
4189
  ${c("yellow", "--schedule")} <expr> cron schedule expression
4104
4190
  ${c("yellow", "--prompt")} <text> prompt text
4105
- ${c("yellow", "--timezone")} <tz> IANA timezone (default: UTC)
4106
4191
  ${c("yellow", "--active-hours")} <range> restrict runs to a time window (e.g. "09:00-17:00")
4107
4192
  ${c("yellow", "--limit")} <n> number of flights to show (default: 10)
4108
4193
  ${c("yellow", "--json")} output as JSON (with list, flights)
@@ -4150,7 +4235,6 @@ function handleBirds(argv) {
4150
4235
  agent: { type: "string" },
4151
4236
  schedule: { type: "string" },
4152
4237
  prompt: { type: "string" },
4153
- timezone: { type: "string" },
4154
4238
  "active-hours": { type: "string" },
4155
4239
  limit: { type: "string" },
4156
4240
  json: {
@@ -4224,7 +4308,7 @@ function handleBirds(argv) {
4224
4308
  activeStart = parsed.start;
4225
4309
  activeEnd = parsed.end;
4226
4310
  }
4227
- const job = createCronJob(deriveBirdName(prompt), schedule, prompt, values.channel, values.agent, values.timezone, activeStart, activeEnd);
4311
+ const job = createCronJob(deriveBirdName(prompt), schedule, prompt, values.channel, values.agent, activeStart, activeEnd);
4228
4312
  logger.success(`bird ${shortUid(job.uid)} created: "${schedule}"`);
4229
4313
  process.stderr.write(c("dim", ` hint: run 'browserbird birds fly ${shortUid(job.uid)}' to trigger it now`) + "\n");
4230
4314
  break;
@@ -4232,7 +4316,7 @@ function handleBirds(argv) {
4232
4316
  case "edit": {
4233
4317
  const uidPrefix = positionals[0];
4234
4318
  if (!uidPrefix) {
4235
- logger.error("usage: browserbird birds edit <uid> [--schedule <expr>] [--prompt <text>] [--channel <id>] [--agent <id>] [--timezone <tz>] [--active-hours <range>]");
4319
+ logger.error("usage: browserbird birds edit <uid> [--schedule <expr>] [--prompt <text>] [--channel <id>] [--agent <id>] [--active-hours <range>]");
4236
4320
  process.exitCode = 1;
4237
4321
  return;
4238
4322
  }
@@ -4242,7 +4326,6 @@ function handleBirds(argv) {
4242
4326
  const agent = values.agent;
4243
4327
  const schedule = values.schedule;
4244
4328
  const prompt = values.prompt;
4245
- const timezone = values.timezone;
4246
4329
  const editActiveHoursRaw = values["active-hours"];
4247
4330
  let editActiveStart;
4248
4331
  let editActiveEnd;
@@ -4259,8 +4342,8 @@ function handleBirds(argv) {
4259
4342
  editActiveStart = parsed.start;
4260
4343
  editActiveEnd = parsed.end;
4261
4344
  }
4262
- if (!schedule && !prompt && !channel && !agent && !timezone && editActiveStart === void 0) {
4263
- logger.error("provide at least one of: --schedule, --prompt, --channel, --agent, --timezone, --active-hours");
4345
+ if (!schedule && !prompt && !channel && !agent && editActiveStart === void 0) {
4346
+ logger.error("provide at least one of: --schedule, --prompt, --channel, --agent, --active-hours");
4264
4347
  process.exitCode = 1;
4265
4348
  return;
4266
4349
  }
@@ -4270,7 +4353,6 @@ function handleBirds(argv) {
4270
4353
  name: prompt ? deriveBirdName(prompt) : void 0,
4271
4354
  targetChannelId: channel !== void 0 ? channel || null : void 0,
4272
4355
  agentId: agent,
4273
- timezone,
4274
4356
  activeHoursStart: editActiveStart,
4275
4357
  activeHoursEnd: editActiveEnd
4276
4358
  })) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owloops/browserbird",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
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": {