@inetafrica/open-claudia 3.5.19 → 3.5.20

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.
@@ -0,0 +1,540 @@
1
+ // Take over (SPEC "Bot apps v1", contracts C2, C3, C6, and the HTTP side of C4): a person drives
2
+ // the bot's own browser from Kazee Chat's Browser app, through Agent Space (the front door).
3
+ //
4
+ // core/browser-view.js stays watch-only; driving lives here:
5
+ // - take / renew / release a driver lock (core/browser-lock.js). While it is live the bot's own
6
+ // agent-browser commands are refused (core/tool-guard.js) and only the driver's input reaches
7
+ // the browser. It lasts 120 s; the page's heartbeat and every accepted input extend it.
8
+ // - one input WebSocket per agent-browser session (ws://127.0.0.1:<port>/?maxFps=2): events
9
+ // are checked strictly, field by field, and forwarded in order. The same socket keeps the
10
+ // latest frame, which Teach me saves as a click's picture.
11
+ // - hand back (release, a closed panel, or a lock found expired by the 30 s sweeper): the lock
12
+ // goes, the socket closes, any recording stops, and the bot is woken in the person's own chat
13
+ // to carry on (C6). A recording stopped at the same time folds into one wake-up.
14
+ //
15
+ // Every route answers { status, body }; web.js writes it. Agent Space reaches these routes with
16
+ // the control token only.
17
+
18
+ const WebSocket = require("ws");
19
+ const lockFile = require("./browser-lock");
20
+ const view = require("./browser-view");
21
+ const teach = require("./browser-teach");
22
+
23
+ const LOCK_TTL_MS = lockFile.LOCK_TTL_MS;
24
+ // Inputs extend the lock; the file (fsynced) is rewritten at most this often, so the lock ends
25
+ // within a second of 120 s after the last input.
26
+ const LOCK_REWRITE_MS = 1000;
27
+ const SWEEP_INTERVAL_MS = 30 * 1000;
28
+ const SOCKET_OPEN_TIMEOUT_MS = 3000;
29
+ const INPUT_MAX_FPS = 2;
30
+ const MAX_EVENTS = 50;
31
+ const MAX_PENDING_EVENTS = 1000;
32
+ const COORD_MAX = 10000;
33
+ const ID_RE = /^[A-Za-z0-9._:-]{1,100}$/;
34
+
35
+ const HAND_BACK_REASONS = {
36
+ closed: " (they closed the Browser)",
37
+ expired: " (their Browser went quiet for 2 minutes, so control came back on its own)",
38
+ };
39
+
40
+ let deps = {
41
+ now: () => Date.now(),
42
+ listSessions: () => view.listSessions(),
43
+ connect: (url) => new WebSocket(url),
44
+ scheduler: null, // core/scheduler by default (through core/cards.js wakeOrigin)
45
+ runFor: null, // the chat's provider, settings and live session; activeRun() by default
46
+ };
47
+
48
+ function reply(status, body) {
49
+ return { status, body };
50
+ }
51
+
52
+ function iso(ms) {
53
+ return new Date(ms).toISOString();
54
+ }
55
+
56
+ function validSession(name) {
57
+ return typeof name === "string" && view.SESSION_NAME.test(name);
58
+ }
59
+
60
+ function validId(value) {
61
+ return (typeof value === "string" || typeof value === "number") && ID_RE.test(String(value));
62
+ }
63
+
64
+ function validWho(who, { needChat = false } = {}) {
65
+ if (!who || typeof who !== "object" || !validId(who.userId)) return false;
66
+ return !needChat || validId(who.chatId);
67
+ }
68
+
69
+ function sameUser(lock, who) {
70
+ return !!lock && !!who && String(lock.userId) === String(who.userId);
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // C3: input events, checked field by field. Only the listed fields are forwarded.
75
+
76
+ const MOUSE_EVENTS = new Set(["mousePressed", "mouseReleased", "mouseMoved", "mouseWheel"]);
77
+ const MOUSE_BUTTONS = new Set(["none", "left", "middle", "right"]);
78
+ const KEY_EVENTS = new Set(["keyDown", "keyUp", "char"]);
79
+ const TOUCH_EVENTS = new Set(["touchStart", "touchMove", "touchEnd", "touchCancel"]);
80
+
81
+ const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
82
+ const inRange = (v, lo, hi) => isNumber(v) && v >= lo && v <= hi;
83
+ const intIn = (v, lo, hi) => Number.isInteger(v) && v >= lo && v <= hi;
84
+
85
+ // The event to forward, or null when anything about it is wrong.
86
+ function checkEvent(ev) {
87
+ if (!ev || typeof ev !== "object" || Array.isArray(ev)) return null;
88
+ const optional = (out, key, ok) => {
89
+ if (ev[key] === undefined) return true;
90
+ if (!ok(ev[key])) return false;
91
+ out[key] = ev[key];
92
+ return true;
93
+ };
94
+ const modifiers = (v) => intIn(v, 0, 15);
95
+ if (ev.type === "input_mouse") {
96
+ if (!MOUSE_EVENTS.has(ev.eventType) || !inRange(ev.x, 0, COORD_MAX) || !inRange(ev.y, 0, COORD_MAX)) return null;
97
+ const out = { type: "input_mouse", eventType: ev.eventType, x: ev.x, y: ev.y };
98
+ if (!optional(out, "button", (v) => MOUSE_BUTTONS.has(v))) return null;
99
+ if (!optional(out, "clickCount", (v) => intIn(v, 0, 3))) return null;
100
+ if (!optional(out, "modifiers", modifiers)) return null;
101
+ if (!optional(out, "deltaX", (v) => inRange(v, -COORD_MAX, COORD_MAX))) return null;
102
+ if (!optional(out, "deltaY", (v) => inRange(v, -COORD_MAX, COORD_MAX))) return null;
103
+ if (ev.eventType === "mouseWheel") {
104
+ // The browser wants both deltas on a wheel event; a missing one is no scroll that way.
105
+ out.deltaX = out.deltaX || 0;
106
+ out.deltaY = out.deltaY || 0;
107
+ }
108
+ return out;
109
+ }
110
+ if (ev.type === "input_keyboard") {
111
+ if (!KEY_EVENTS.has(ev.eventType)) return null;
112
+ const out = { type: "input_keyboard", eventType: ev.eventType };
113
+ const short = (max) => (v) => typeof v === "string" && v.length <= max;
114
+ if (!optional(out, "key", short(32))) return null;
115
+ if (!optional(out, "code", short(32))) return null;
116
+ if (!optional(out, "text", short(8))) return null;
117
+ if (!optional(out, "modifiers", modifiers)) return null;
118
+ if (!optional(out, "windowsVirtualKeyCode", (v) => intIn(v, 0, 255))) return null;
119
+ return out;
120
+ }
121
+ if (ev.type === "input_touch") {
122
+ if (!TOUCH_EVENTS.has(ev.eventType) || !Array.isArray(ev.touchPoints) || ev.touchPoints.length > 5) return null;
123
+ const touchPoints = [];
124
+ for (const p of ev.touchPoints) {
125
+ if (!p || typeof p !== "object" || Array.isArray(p) || !isNumber(p.x) || !isNumber(p.y)) return null;
126
+ const point = { x: p.x, y: p.y };
127
+ // A touch's identifier keeps fingers apart; Safari's are large integers.
128
+ if (p.id !== undefined) {
129
+ if (!intIn(p.id, 0, Number.MAX_SAFE_INTEGER)) return null;
130
+ point.id = p.id;
131
+ }
132
+ touchPoints.push(point);
133
+ }
134
+ const out = { type: "input_touch", eventType: ev.eventType, touchPoints };
135
+ if (!optional(out, "modifiers", modifiers)) return null;
136
+ return out;
137
+ }
138
+ return null;
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Input sockets: one per agent-browser session, opened on take (or on the first input after a
143
+ // restart), closed on hand back.
144
+
145
+ const sockets = new Map();
146
+
147
+ function sessionInfo(name) {
148
+ try {
149
+ return deps.listSessions().find((s) => s.name === name) || null;
150
+ } catch (_) {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ function openInputSocket(name) {
156
+ const existing = sockets.get(name);
157
+ if (existing && !existing.closed) return existing;
158
+ const info = sessionInfo(name);
159
+ if (!info) return null;
160
+ const entry = { name: info.name, dir: info.dir || null, port: info.port, ws: null, open: false, closed: false, frame: null, ready: null };
161
+ const finish = () => {
162
+ entry.closed = true;
163
+ entry.open = false;
164
+ if (sockets.get(name) === entry) sockets.delete(name);
165
+ };
166
+ let ws;
167
+ try {
168
+ ws = deps.connect(`ws://127.0.0.1:${info.port}/?maxFps=${INPUT_MAX_FPS}`);
169
+ } catch (_) {
170
+ return null;
171
+ }
172
+ entry.ws = ws;
173
+ entry.ready = new Promise((resolve) => {
174
+ ws.on("open", () => { entry.open = !entry.closed; resolve(entry.open); });
175
+ ws.on("close", () => { finish(); resolve(false); });
176
+ ws.on("error", () => {
177
+ finish();
178
+ resolve(false);
179
+ try { ws.terminate(); } catch (_) { /* already down */ }
180
+ });
181
+ });
182
+ // Frames only: the newest one is kept for Teach me's pictures. Page addresses and the rest
183
+ // are not needed here.
184
+ ws.on("message", (data) => {
185
+ let msg;
186
+ try { msg = JSON.parse(String(data)); } catch (_) { return; }
187
+ if (msg && msg.type === "frame" && typeof msg.data === "string") {
188
+ entry.frame = { data: msg.data, metadata: msg.metadata || {}, at: deps.now() };
189
+ }
190
+ });
191
+ sockets.set(name, entry);
192
+ return entry;
193
+ }
194
+
195
+ function closeInputSocket(name) {
196
+ const entry = sockets.get(name);
197
+ if (!entry) return;
198
+ sockets.delete(name);
199
+ entry.closed = true;
200
+ entry.open = false;
201
+ entry.frame = null;
202
+ try { entry.ws.close(); } catch (_) { /* already closed */ }
203
+ }
204
+
205
+ function socketReady(entry) {
206
+ if (entry.open) return Promise.resolve(true);
207
+ if (entry.closed) return Promise.resolve(false);
208
+ return new Promise((resolve) => {
209
+ const timer = setTimeout(() => resolve(false), SOCKET_OPEN_TIMEOUT_MS);
210
+ if (timer.unref) timer.unref();
211
+ entry.ready.then((ok) => { clearTimeout(timer); resolve(ok); });
212
+ });
213
+ }
214
+
215
+ // One queue per session: input batches, and the recording changes that must not cut a batch
216
+ // in half (stop, cancel, hand back), run strictly in arrival order.
217
+ const queues = new Map();
218
+
219
+ function runQueued(name, count, fn) {
220
+ let q = queues.get(name);
221
+ if (!q) {
222
+ q = { tail: Promise.resolve(), pending: 0 };
223
+ queues.set(name, q);
224
+ }
225
+ if (count > 0 && q.pending + count > MAX_PENDING_EVENTS) return null;
226
+ q.pending += count;
227
+ const job = q.tail.then(() => fn());
228
+ q.tail = job.then(() => {}, () => {}).then(() => { q.pending -= count; });
229
+ return job;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // C6: waking the bot in the person's direct chat
234
+
235
+ function kazeeAdapterId() {
236
+ try {
237
+ const adapter = require("./adapter-registry").getAdapters().find((a) => a && a.type === "kazee");
238
+ return adapter ? adapter.id : "";
239
+ } catch (_) {
240
+ return "";
241
+ }
242
+ }
243
+
244
+ // The provider this chat runs on now (the room's choice, else the person's), its settings, and
245
+ // the chat's live session, so the wake-up carries on the same conversation.
246
+ function activeRun({ chatId, userId, canonicalUserId, project }) {
247
+ const { runInChat } = require("./context");
248
+ const state = require("./state");
249
+ return runInChat({ channelId: String(chatId), canonicalUserId, userId: String(userId), transport: "kazee", conversationKey: project, roomKey: project }, () => {
250
+ const st = state.getUserState(canonicalUserId);
251
+ let provider = state.getActiveProvider(st);
252
+ if (!provider) {
253
+ try { provider = require("./providers").selectDefaultProvider().providerId || null; } catch (_) { provider = null; }
254
+ }
255
+ let providerSettings = {};
256
+ let sessionId = null;
257
+ if (provider) {
258
+ try { providerSettings = structuredClone(state.resolveProviderSettings(st, provider) || {}); } catch (_) { providerSettings = {}; }
259
+ try { sessionId = state.getProviderSession(st, provider, project) || null; } catch (_) { sessionId = null; }
260
+ }
261
+ return { provider, providerSettings, sessionId };
262
+ });
263
+ }
264
+
265
+ function chatOrigin({ chatId, userId }) {
266
+ const { conversationKeyFor, canonicalForChannel } = require("./identity");
267
+ const project = conversationKeyFor("kazee", chatId, null);
268
+ const canonicalUserId = canonicalForChannel("kazee", userId);
269
+ const run = (deps.runFor || activeRun)({ chatId: String(chatId), userId: String(userId), canonicalUserId, project }) || {};
270
+ return {
271
+ adapter: kazeeAdapterId(),
272
+ adapterType: "kazee",
273
+ channelId: String(chatId),
274
+ threadId: null,
275
+ canonicalUserId,
276
+ userId: String(userId),
277
+ provider: run.provider || null,
278
+ providerSettings: run.providerSettings || {},
279
+ project,
280
+ sessionId: run.sessionId || null,
281
+ };
282
+ }
283
+
284
+ function wakeChat({ chatId, userId }, prompt, label) {
285
+ try {
286
+ const origin = chatOrigin({ chatId, userId });
287
+ const opts = { source: "browser" };
288
+ if (deps.scheduler) opts.scheduler = deps.scheduler;
289
+ return require("./cards").wakeOrigin(origin, prompt, label, opts);
290
+ } catch (e) {
291
+ console.error(`[browser] could not wake the bot in chat ${chatId}: ${e.message}`);
292
+ return null;
293
+ }
294
+ }
295
+
296
+ function handBackLine(name, reason) {
297
+ const who = teach.cleanName(name) || "Someone";
298
+ return `[Browser] ${who} handed the browser back${HAND_BACK_REASONS[reason] || ""}. If you were waiting on them, check the page first (it may have changed) and carry on; otherwise just note it.`;
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // C2: take, renew, release, status
303
+
304
+ function lockBody(lock) {
305
+ return { driving: true, driver: { name: lock.name } };
306
+ }
307
+
308
+ async function take({ session, who } = {}) {
309
+ if (!validSession(session) || !validWho(who, { needChat: true })) return reply(400, { error: "bad-request" });
310
+ // A lock that ran out but has not been swept yet is handed back first, the sweeper's way: its
311
+ // driver's chat is woken and their recording stopped, whoever takes over now.
312
+ const stale = lockFile.readLock();
313
+ if (stale && lockFile.lockExpiresAt(stale) <= deps.now()) await handBack(stale, { reason: "expired" });
314
+ const now = deps.now();
315
+ const current = lockFile.liveLock(now);
316
+ if (current && !sameUser(current, who)) return reply(409, lockBody(current));
317
+ if (!sessionInfo(session)) return reply(404, { driving: false, driver: null, error: "not-running" });
318
+ if (current && current.session !== session) closeInputSocket(current.session);
319
+ const lock = {
320
+ session,
321
+ userId: String(who.userId),
322
+ name: teach.cleanName(who.name) || "Someone",
323
+ chatId: String(who.chatId),
324
+ since: current && current.session === session ? current.since : iso(now),
325
+ expiresAt: iso(now + LOCK_TTL_MS),
326
+ };
327
+ lockFile.writeLock(lock);
328
+ openInputSocket(session);
329
+ return reply(200, lockBody(lock));
330
+ }
331
+
332
+ function renew({ session, who } = {}) {
333
+ if (!validWho(who) || (session !== undefined && !validSession(session))) return reply(400, { error: "bad-request" });
334
+ const now = deps.now();
335
+ const current = lockFile.liveLock(now);
336
+ if (!current || !sameUser(current, who) || (session !== undefined && current.session !== session)) {
337
+ return reply(409, { driving: false, driver: current ? { name: current.name } : null, error: "not-driving" });
338
+ }
339
+ current.expiresAt = iso(now + LOCK_TTL_MS);
340
+ lockFile.writeLock(current);
341
+ openInputSocket(current.session); // after a restart the socket comes back here
342
+ return reply(200, lockBody(current));
343
+ }
344
+
345
+ // Hand the browser back: claim the lock (synchronously, so a release and the sweeper never both
346
+ // act on one lock), close the socket and stop any recording after the input already queued, then
347
+ // wake the bot once.
348
+ async function handBack(expected, { reason = null } = {}) {
349
+ const current = lockFile.readLock();
350
+ if (!current || current.userId !== expected.userId || current.session !== expected.session || current.since !== expected.since) return false;
351
+ lockFile.removeLock();
352
+ const info = sessionInfo(current.session);
353
+ let stopped = null;
354
+ await runQueued(current.session, 0, async () => {
355
+ closeInputSocket(current.session);
356
+ try {
357
+ stopped = await teach.stopRecording(current.userId, { session: info });
358
+ } catch (e) {
359
+ console.error(`[browser] stopping the recording on hand back failed: ${e.message}`);
360
+ }
361
+ });
362
+ const line = handBackLine(current.name, reason);
363
+ if (stopped && !stopped.discarded) wakeChat(current, teach.teachPrompt(stopped, { handBack: line }), "Teach me");
364
+ else wakeChat(current, line, "Browser");
365
+ console.log(`[browser] ${current.userId} handed the browser back${reason ? ` (${reason})` : ""}`);
366
+ return true;
367
+ }
368
+
369
+ async function release({ session, who, reason } = {}) {
370
+ if (!validWho(who) || (session !== undefined && !validSession(session))) return reply(400, { error: "bad-request" });
371
+ const current = lockFile.readLock();
372
+ if (current && sameUser(current, who) && (session === undefined || current.session === session)) {
373
+ await handBack(current, { reason: typeof reason === "string" ? reason : null });
374
+ }
375
+ return reply(200, { driving: false });
376
+ }
377
+
378
+ async function control(body = {}) {
379
+ if (body.action === "take") return take(body);
380
+ if (body.action === "renew") return renew(body);
381
+ if (body.action === "release") return release(body);
382
+ return reply(400, { error: "bad-action" });
383
+ }
384
+
385
+ function status() {
386
+ const lock = lockFile.liveLock(deps.now());
387
+ return reply(200, {
388
+ driving: !!lock,
389
+ driver: lock ? { name: lock.name, userId: lock.userId } : null,
390
+ recording: teach.activeSummary(),
391
+ });
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // C3: input
396
+
397
+ async function input({ session, events, who } = {}) {
398
+ if (!validSession(session) || !validWho(who)) return reply(400, { error: "bad-request" });
399
+ if (!Array.isArray(events) || events.length > MAX_EVENTS) return reply(400, { error: "bad-events" });
400
+ const messages = [];
401
+ for (let i = 0; i < events.length; i += 1) {
402
+ const msg = checkEvent(events[i]);
403
+ if (!msg) return reply(400, { error: "bad-event", index: i });
404
+ messages.push(msg);
405
+ }
406
+ const now = deps.now();
407
+ const lock = lockFile.liveLock(now);
408
+ if (!lock || lock.session !== session || !sameUser(lock, who)) return reply(409, { error: "not-driving" });
409
+ // Every accepted input extends the lock; the file is rewritten only every few seconds.
410
+ if (lockFile.lockExpiresAt(lock) - now < LOCK_TTL_MS - LOCK_REWRITE_MS) {
411
+ lock.expiresAt = iso(now + LOCK_TTL_MS);
412
+ lockFile.writeLock(lock);
413
+ }
414
+ if (!messages.length) return reply(200, { ok: true, accepted: 0 });
415
+ const entry = openInputSocket(session);
416
+ if (!entry) return reply(404, { error: "not-running" });
417
+ const job = runQueued(session, messages.length, async () => {
418
+ if (!(await socketReady(entry))) return reply(503, { error: "browser-unavailable", accepted: 0 });
419
+ const recorder = teach.recorderFor(session, who.userId);
420
+ let sent = 0;
421
+ for (const msg of messages) {
422
+ if (recorder) {
423
+ try {
424
+ await recorder.onEvent(msg, { session: { name: entry.name, dir: entry.dir }, frame: entry.frame });
425
+ } catch (e) {
426
+ console.error(`[browser] recording a step failed: ${e.message}`);
427
+ }
428
+ }
429
+ if (!entry.open || entry.closed) return reply(503, { error: "browser-unavailable", accepted: sent });
430
+ entry.ws.send(JSON.stringify(msg));
431
+ sent += 1;
432
+ }
433
+ return reply(200, { ok: true, accepted: sent });
434
+ });
435
+ if (!job) return reply(429, { error: "busy" });
436
+ return job;
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // C4: Teach me, start / stop / cancel
441
+
442
+ async function teachAction({ session, action, title, who } = {}) {
443
+ if (!validWho(who)) return reply(400, { error: "bad-request" });
444
+ if (action === "start") {
445
+ if (!validSession(session) || (title !== undefined && title !== null && typeof title !== "string")) return reply(400, { error: "bad-request" });
446
+ const lock = lockFile.liveLock(deps.now());
447
+ if (!lock || lock.session !== session || !sameUser(lock, who)) return reply(409, { error: "not-driving" });
448
+ return runQueued(session, 0, () => {
449
+ try {
450
+ const rec = teach.startRecording({
451
+ session,
452
+ userId: lock.userId,
453
+ name: who.name || lock.name,
454
+ chatId: lock.chatId,
455
+ title,
456
+ });
457
+ console.log(`[browser] ${rec.userId} started recording ${rec.id}`);
458
+ return reply(200, { recording: teach.summary(rec) });
459
+ } catch (e) {
460
+ if (e.code === "already-recording") return reply(409, { error: "already-recording", recording: e.recording || null });
461
+ throw e;
462
+ }
463
+ });
464
+ }
465
+ if (action === "stop" || action === "cancel") {
466
+ const mine = teach.activeOf(who.userId);
467
+ if (!mine) return reply(404, action === "cancel" ? { cancelled: false, error: "not-recording" } : { error: "not-recording" });
468
+ if (action === "cancel") {
469
+ const cancelled = await runQueued(mine.session, 0, () => teach.cancelActive(who.userId));
470
+ if (cancelled) console.log(`[browser] ${who.userId} cancelled recording ${mine.id}`);
471
+ return cancelled ? reply(200, { cancelled: true }) : reply(404, { cancelled: false, error: "not-recording" });
472
+ }
473
+ const info = sessionInfo(mine.session);
474
+ const stopped = await runQueued(mine.session, 0, () => teach.stopRecording(who.userId, { session: info }));
475
+ if (!stopped) return reply(404, { error: "not-recording" });
476
+ const body = { recording: { id: stopped.id, title: stopped.title, steps: stopped.steps.length } };
477
+ if (stopped.discarded) body.discarded = true;
478
+ else wakeChat({ chatId: stopped.chatId, userId: stopped.userId }, teach.teachPrompt(stopped), "Teach me");
479
+ console.log(`[browser] ${who.userId} stopped recording ${stopped.id} (${stopped.steps.length} steps${stopped.discarded ? ", discarded" : ""})`);
480
+ return reply(200, body);
481
+ }
482
+ return reply(400, { error: "bad-action" });
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // The sweeper: a lock nobody renewed for 120 s is handed back.
487
+
488
+ async function sweepLock(now = deps.now()) {
489
+ const lock = lockFile.readLock();
490
+ if (!lock || lockFile.lockExpiresAt(lock) > now) return false;
491
+ return handBack(lock, { reason: "expired" });
492
+ }
493
+
494
+ let sweepTimer = null;
495
+ function startSweeper({ intervalMs = SWEEP_INTERVAL_MS } = {}) {
496
+ if (sweepTimer) return sweepTimer;
497
+ sweepTimer = setInterval(() => {
498
+ sweepLock().catch((e) => console.error(`[browser] lock sweep failed: ${e.message}`));
499
+ }, intervalMs);
500
+ if (sweepTimer.unref) sweepTimer.unref();
501
+ return sweepTimer;
502
+ }
503
+
504
+ // Tests swap the clock, the session list, the socket factory, the scheduler and the run lookup.
505
+ function _setDeps(overrides = {}) {
506
+ deps = { ...deps, ...overrides };
507
+ }
508
+ function _reset() {
509
+ for (const name of [...sockets.keys()]) closeInputSocket(name);
510
+ queues.clear();
511
+ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
512
+ deps = {
513
+ now: () => Date.now(),
514
+ listSessions: () => view.listSessions(),
515
+ connect: (url) => new WebSocket(url),
516
+ scheduler: null,
517
+ runFor: null,
518
+ };
519
+ }
520
+
521
+ module.exports = {
522
+ LOCK_TTL_MS,
523
+ MAX_EVENTS,
524
+ SWEEP_INTERVAL_MS,
525
+ checkEvent,
526
+ chatOrigin,
527
+ control,
528
+ handBackLine,
529
+ input,
530
+ release,
531
+ renew,
532
+ startSweeper,
533
+ status,
534
+ sweepLock,
535
+ take,
536
+ teachAction,
537
+ wakeChat,
538
+ _reset,
539
+ _setDeps,
540
+ };
@@ -0,0 +1,51 @@
1
+ // The Browser app's driver lock (SPEC contract C2): while a person drives the bot's browser from
2
+ // Kazee Chat, <configDir>/browser-driver.json says who, on which agent-browser session, until
3
+ // when. It is a file because the bot's own agent-browser commands are refused by the tool-guard
4
+ // hook (core/tool-guard.js, C7), which runs as a separate process for every tool call and
5
+ // re-reads it each time, like the tooling-mode switch (core/tooling-mode.js).
6
+ //
7
+ // { session, userId, name, chatId, since, expiresAt } (ISO times; written atomically, 0600)
8
+ //
9
+ // An expired lock is no lock: it never blocks the bot, and the sweeper in
10
+ // core/browser-control.js hands the browser back.
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const { atomicWriteFileSync } = require("./fsutil");
15
+
16
+ const LOCK_TTL_MS = 120 * 1000;
17
+
18
+ function driverFile() {
19
+ return process.env.OC_BROWSER_DRIVER_FILE || path.join(require("../config-dir"), "browser-driver.json");
20
+ }
21
+
22
+ function readLock() {
23
+ try {
24
+ const lock = JSON.parse(fs.readFileSync(driverFile(), "utf8"));
25
+ if (!lock || typeof lock !== "object" || !lock.userId || !lock.session) return null;
26
+ return lock;
27
+ } catch (_) {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function lockExpiresAt(lock) {
33
+ if (!lock) return 0;
34
+ const at = typeof lock.expiresAt === "number" ? lock.expiresAt : Date.parse(lock.expiresAt);
35
+ return Number.isFinite(at) ? at : 0;
36
+ }
37
+
38
+ function liveLock(now = Date.now()) {
39
+ const lock = readLock();
40
+ return lock && lockExpiresAt(lock) > now ? lock : null;
41
+ }
42
+
43
+ function writeLock(lock) {
44
+ atomicWriteFileSync(driverFile(), `${JSON.stringify(lock)}\n`, { mode: 0o600 });
45
+ }
46
+
47
+ function removeLock() {
48
+ try { fs.rmSync(driverFile(), { force: true }); } catch (_) { /* already gone */ }
49
+ }
50
+
51
+ module.exports = { LOCK_TTL_MS, driverFile, readLock, lockExpiresAt, liveLock, writeLock, removeLock };