@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.
- package/dist/{db-C9ESgb0d.mjs → db-Da2zSpkY.mjs} +4 -9
- package/dist/index.mjs +142 -60
- package/package.json +1 -1
|
@@ -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,
|
|
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,
|
|
542
|
-
VALUES (?, ?, ?, ?, ?, ?, ?,
|
|
543
|
-
RETURNING *`).get(uid, name, schedule, prompt, targetChannelId ?? null, agentId ?? "default",
|
|
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-
|
|
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: ${"
|
|
126
|
-
buildInfo.push(`built: 2026-03-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2932
|
-
|
|
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 === "
|
|
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 (
|
|
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,
|
|
3004
|
-
if (!isWithinActiveHours(job.active_hours_start, job.active_hours_end, now,
|
|
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
|
-
|
|
3204
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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-
|
|
3785
|
-
const bird = createCronJob(name, schedule, prompt, channelId || void 0, "default"
|
|
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-
|
|
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,
|
|
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>] [--
|
|
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 &&
|
|
4263
|
-
logger.error("provide at least one of: --schedule, --prompt, --channel, --agent, --
|
|
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
|
})) {
|