@inetafrica/open-claudia 3.5.24 → 3.5.25

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.25
4
+
5
+ The Browser app feels like a browser on the phone and on the web (Sumeet, 10 Oct 08:53 EAT on his phone: "it feels like a browser on my phone… like Google Chrome app", and 09:18: "the web and mobile still need to also feel native… don't forget the website"). A canary build for the Browser canary bot first; the fleet waits for his OK.
6
+
7
+ - **The bot's tab takes the driver's screen.** With Chrome in its own container, a take (or a `fit` when the screen changes: a turned phone, a resized panel, a keyboard that opens) that carries the driver's screen (`viewport`: CSS size, pixel density, phone layout, touch) fits the bot's tab to it through `core/browser-drive.js`. A phone gets the page's phone layout, full screen and sharp; the web gets a desktop page at the panel's size. Handing back puts the tab back to its own size. The browser's identity (user agent) never changes, so sites signed into stay signed in after the hand back. `GET /api/browser/control` says `fit: true`.
8
+ - **The driver's input goes straight to Chrome,** through the view's own DevTools session, in order. Touches keep their timing, so a swipe flings; typed text is inserted whole (`input_text`, at most 500 characters: accents, emoji, the keyboard's corrections); every point is scaled by the view's density, because Chrome reads input in the drawn picture's pixels. A bot without the view takes `input_text` as key presses.
9
+ - **The page's text fields.** The view reads where the page's text fields are and what kind they are (never a value) and streams them as `fields` events, so the Browser page opens the phone's own keyboard of the right kind the moment a field is tapped.
10
+ - **Pictures.** The view streams its own screencast at the driver's size (at most 1.6 million pixels, 30 a second). `/api/browser/stream` serves it while someone drives, up to 30 fps, and keeps the newest frame while a slow viewer drains instead of dropping it, so the last picture of a scroll always arrives.
11
+ - **A hand back wakes the bot only when it waits for that person:** it sent them an Open Browser card (cards now record `openApp`), or one of its browser commands was refused while they drove (the tool guard marks it). Otherwise the hand back is only logged. On 9 Oct a test hand back set off a 28-minute tangent on the canary.
12
+ - **Correction to 3.5.24:** in container mode, sessions are not pinned to tabs (Agent Space dropped `AGENT_BROWSER_PIN_TAB` in a98f239); a session uses the tab that is open.
13
+ - **Tests:** `test-browser-drive.js` (new), `test-browser-control.js`, `test-web-browser-app.js`.
14
+
3
15
  ## 3.5.24
4
16
 
5
17
  The Browser app's address bar, and Chrome in its own container (Sumeet, 9 Oct 21:27 EAT on his phone: "I can't change the url on the mobile app"). A canary build for the Browser canary bot first; the fleet waits for his OK.
@@ -13,9 +13,15 @@
13
13
  // after the input before them, with the bot's own agent-browser environment and never a
14
14
  // launch flag (core/agent-browser-cli.js: other launch settings would restart the browser).
15
15
  // Only public internet addresses (core/browser-address.js).
16
+ // - the phone and web view (core/browser-drive.js): a take or a "fit" with the driver's screen
17
+ // makes the bot's tab take its size, phone layout and touch, and then the driver's input goes
18
+ // straight to Chrome through that view (typed text too: input_text). Only with Chrome in its
19
+ // own container; elsewhere the browser keeps its size and input goes through agent-browser.
16
20
  // - hand back (release, a closed panel, or a lock found expired by the 30 s sweeper): the lock
17
- // goes, the socket closes, any recording stops, and the bot is woken in the person's own chat
18
- // to carry on (C6). A recording stopped at the same time folds into one wake-up.
21
+ // goes, the view and the socket close, any recording stops, and the bot is woken in the
22
+ // person's own chat (C6), but only when it waits for them: it sent them an Open Browser card,
23
+ // or one of its browser commands was refused while they drove. A recording stopped at the
24
+ // same time folds into one wake-up. Otherwise the hand back is only logged.
19
25
  //
20
26
  // Every route answers { status, body }; web.js writes it. Agent Space reaches these routes with
21
27
  // the control token only.
@@ -25,6 +31,7 @@ const lockFile = require("./browser-lock");
25
31
  const view = require("./browser-view");
26
32
  const teach = require("./browser-teach");
27
33
  const address = require("./browser-address");
34
+ const drive = require("./browser-drive");
28
35
  const { runAgentBrowser } = require("./agent-browser-cli");
29
36
 
30
37
  const LOCK_TTL_MS = lockFile.LOCK_TTL_MS;
@@ -42,6 +49,9 @@ const ID_RE = /^[A-Za-z0-9._:-]{1,100}$/;
42
49
  const NAVIGATE_TIMEOUT_MS = 20 * 1000;
43
50
  const NAVIGATIONS = new Set(["open", "back", "forward", "reload"]);
44
51
 
52
+ // An Open Browser card counts as a request for a person this long.
53
+ const REQUEST_MAX_AGE_MS = 24 * 60 * 60 * 1000;
54
+
45
55
  const HAND_BACK_REASONS = {
46
56
  closed: " (they closed the Browser)",
47
57
  expired: " (their Browser went quiet for 2 minutes, so control came back on its own)",
@@ -107,6 +117,7 @@ const MOUSE_EVENTS = new Set(["mousePressed", "mouseReleased", "mouseMoved", "mo
107
117
  const MOUSE_BUTTONS = new Set(["none", "left", "middle", "right"]);
108
118
  const KEY_EVENTS = new Set(["keyDown", "keyUp", "char"]);
109
119
  const TOUCH_EVENTS = new Set(["touchStart", "touchMove", "touchEnd", "touchCancel"]);
120
+ const TEXT_MAX = drive.TEXT_MAX;
110
121
 
111
122
  const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
112
123
  const inRange = (v, lo, hi) => isNumber(v) && v >= lo && v <= hi;
@@ -163,11 +174,34 @@ function checkEvent(ev) {
163
174
  }
164
175
  const out = { type: "input_touch", eventType: ev.eventType, touchPoints };
165
176
  if (!optional(out, "modifiers", modifiers)) return null;
177
+ // When the finger moved, on the driver's device (ms): keeps a swipe's speed (core/browser-drive.js).
178
+ if (!optional(out, "t", (v) => inRange(v, 0, 1e13))) return null;
166
179
  return out;
167
180
  }
181
+ // Text the driver typed (their phone keyboard, a paste), inserted where the page's caret is.
182
+ if (ev.type === "input_text") {
183
+ if (typeof ev.text !== "string" || !ev.text || Array.from(ev.text).length > TEXT_MAX) return null;
184
+ return { type: "input_text", text: ev.text };
185
+ }
168
186
  return null;
169
187
  }
170
188
 
189
+ // input_text as key presses, for a bot without the driver's view (agent-browser's socket takes
190
+ // no text) and for Teach me, which counts typing from key presses.
191
+ function textAsKeys(text) {
192
+ const out = [];
193
+ for (const ch of Array.from(text)) {
194
+ if (ch === "\n" || ch === "\r") {
195
+ out.push({ type: "input_keyboard", eventType: "keyDown", key: "Enter", code: "Enter", text: "\r", windowsVirtualKeyCode: 13 });
196
+ out.push({ type: "input_keyboard", eventType: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 });
197
+ } else {
198
+ out.push({ type: "input_keyboard", eventType: "keyDown", key: ch, text: ch });
199
+ out.push({ type: "input_keyboard", eventType: "keyUp", key: ch });
200
+ }
201
+ }
202
+ return out;
203
+ }
204
+
171
205
  // ---------------------------------------------------------------------------
172
206
  // Input sockets: one per agent-browser session, opened on take (or on the first input after a
173
207
  // restart), closed on hand back.
@@ -325,7 +359,23 @@ function wakeChat({ chatId, userId }, prompt, label) {
325
359
 
326
360
  function handBackLine(name, reason) {
327
361
  const who = teach.cleanName(name) || "Someone";
328
- 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.`;
362
+ return `[Browser] ${who} handed the browser back${HAND_BACK_REASONS[reason] || ""}. Check the page first (it may have changed), then carry on with what you were waiting on them for.`;
363
+ }
364
+
365
+ // Was the bot waiting for this person when they handed back? It asked for them with an Open
366
+ // Browser card in their chat (since the day before they took over), or one of its own browser
367
+ // commands was refused while they drove.
368
+ function botWasWaiting(lock) {
369
+ let asked = [];
370
+ try {
371
+ const since = Date.parse(lock.since);
372
+ asked = require("./cards").takeBrowserRequests(lock.chatId, (Number.isFinite(since) ? since : deps.now()) - REQUEST_MAX_AGE_MS, deps.now());
373
+ } catch (e) {
374
+ console.error(`[browser] reading the bot's Open Browser cards failed: ${e.message}`);
375
+ }
376
+ const refused = lockFile.waitedDuring(lock);
377
+ lockFile.clearWaiting();
378
+ return asked.length > 0 || refused;
329
379
  }
330
380
 
331
381
  // ---------------------------------------------------------------------------
@@ -335,7 +385,39 @@ function lockBody(lock) {
335
385
  return { driving: true, driver: { name: lock.name } };
336
386
  }
337
387
 
338
- async function take({ session, who } = {}) {
388
+ // The driver's view on this lock's session: start it (or move it to a new screen) and remember it
389
+ // in the lock, so a restarted bot can bring it back. -> the fit, or null (the browser keeps its size).
390
+ async function startView(lock, viewport) {
391
+ if (!viewport || !drive.supported()) return null;
392
+ const info = sessionInfo(lock.session);
393
+ if (!info) return null;
394
+ const r = drive.active(lock.session)
395
+ ? await drive.fit(viewport)
396
+ : await drive.start({ session: info, viewport, savedBounds: lock.bounds || null });
397
+ const now = lockFile.readLock();
398
+ // The lock changed hands (or was handed back) while the view started: undo it.
399
+ if (!now || now.since !== lock.since || now.userId !== lock.userId) {
400
+ if (r.ok && drive.active(lock.session)) await drive.stop();
401
+ return null;
402
+ }
403
+ if (!r.ok) return null;
404
+ now.fit = r.fit;
405
+ if (r.bounds) now.bounds = r.bounds;
406
+ lockFile.writeLock(now);
407
+ return r.fit;
408
+ }
409
+
410
+ // After a bot restart the lock remembers a view that is no longer live: bring it back. Once at a
411
+ // time, in the background.
412
+ let reviving = null;
413
+ function reviveView(lock) {
414
+ if (!lock || !lock.fit || reviving || drive.active(lock.session) || !drive.supported()) return;
415
+ reviving = startView(lock, lock.fit)
416
+ .catch((e) => console.error(`[browser] bringing the driver's view back failed: ${e.message}`))
417
+ .finally(() => { reviving = null; });
418
+ }
419
+
420
+ async function take({ session, who, viewport } = {}) {
339
421
  if (!validSession(session) || !validWho(who, { needChat: true })) return reply(400, { error: "bad-request" });
340
422
  // A lock that ran out but has not been swept yet is handed back first, the sweeper's way: its
341
423
  // driver's chat is woken and their recording stopped, whoever takes over now.
@@ -354,9 +436,25 @@ async function take({ session, who } = {}) {
354
436
  since: current && current.session === session ? current.since : iso(now),
355
437
  expiresAt: iso(now + LOCK_TTL_MS),
356
438
  };
439
+ // The same person taking again (a reloaded page) keeps the window's size from before their view.
440
+ if (current && current.session === session && sameUser(current, who) && current.bounds) lock.bounds = current.bounds;
357
441
  lockFile.writeLock(lock);
358
442
  openInputSocket(session);
359
- return reply(200, lockBody(lock));
443
+ if (!viewport) return reply(200, lockBody(lock));
444
+ // fit: the driver's view now live, or null (this bot's browser keeps its own size).
445
+ const fitted = await startView(lock, viewport);
446
+ return reply(200, { ...lockBody(lock), fit: fitted });
447
+ }
448
+
449
+ // The driver's screen changed (a turned phone, a resized panel): { session, who, viewport }.
450
+ async function fitView({ session, who, viewport } = {}) {
451
+ if (!validSession(session) || !validWho(who)) return reply(400, { error: "bad-request" });
452
+ if (!drive.checkViewport(viewport)) return reply(400, { error: "bad-viewport" });
453
+ const lock = extendDriverLock(session, who);
454
+ if (!lock) return reply(409, { driving: false, error: "not-driving" });
455
+ if (!drive.supported()) return reply(200, { ...lockBody(lock), fit: null });
456
+ const fitted = await startView(lock, viewport);
457
+ return reply(200, { ...lockBody(lock), fit: fitted });
360
458
  }
361
459
 
362
460
  function renew({ session, who } = {}) {
@@ -369,7 +467,8 @@ function renew({ session, who } = {}) {
369
467
  current.expiresAt = iso(now + LOCK_TTL_MS);
370
468
  lockFile.writeLock(current);
371
469
  openInputSocket(current.session); // after a restart the socket comes back here
372
- return reply(200, lockBody(current));
470
+ reviveView(current); // and the driver's view
471
+ return reply(200, current.fit ? { ...lockBody(current), fit: drive.active(current.session) ? current.fit : null } : lockBody(current));
373
472
  }
374
473
 
375
474
  // Hand the browser back: claim the lock (synchronously, so a release and the sweeper never both
@@ -383,6 +482,13 @@ async function handBack(expected, { reason = null } = {}) {
383
482
  let stopped = null;
384
483
  await runQueued(current.session, 0, async () => {
385
484
  closeInputSocket(current.session);
485
+ // The bot's tab goes back to its own size before the bot carries on.
486
+ try {
487
+ if (drive.active(current.session)) await drive.stop();
488
+ else if (current.bounds) await drive.restoreWindow(info, current.bounds);
489
+ } catch (e) {
490
+ console.error(`[browser] ending the driver's view failed: ${e.message}`);
491
+ }
386
492
  try {
387
493
  stopped = await teach.stopRecording(current.userId, { session: info });
388
494
  } catch (e) {
@@ -390,9 +496,10 @@ async function handBack(expected, { reason = null } = {}) {
390
496
  }
391
497
  });
392
498
  const line = handBackLine(current.name, reason);
499
+ const waited = botWasWaiting(current);
393
500
  if (stopped && !stopped.discarded) wakeChat(current, teach.teachPrompt(stopped, { handBack: line }), "Teach me");
394
- else wakeChat(current, line, "Browser");
395
- console.log(`[browser] ${current.userId} handed the browser back${reason ? ` (${reason})` : ""}`);
501
+ else if (waited) wakeChat(current, line, "Browser");
502
+ console.log(`[browser] ${current.userId} handed the browser back${reason ? ` (${reason})` : ""}${waited || (stopped && !stopped.discarded) ? "" : "; the bot was not waiting for them, so it is not woken"}`);
396
503
  return true;
397
504
  }
398
505
 
@@ -409,10 +516,12 @@ async function control(body = {}) {
409
516
  if (body.action === "take") return take(body);
410
517
  if (body.action === "renew") return renew(body);
411
518
  if (body.action === "release") return release(body);
519
+ if (body.action === "fit") return fitView(body);
412
520
  return reply(400, { error: "bad-action" });
413
521
  }
414
522
 
415
- // navigate: true tells the front door this bot has the address bar (POST /api/browser/navigate).
523
+ // navigate: true tells the front door this bot has the address bar (POST /api/browser/navigate);
524
+ // fit: true that a take can bring the driver's view (core/browser-drive.js) and input_text works.
416
525
  function status() {
417
526
  const lock = lockFile.liveLock(deps.now());
418
527
  return reply(200, {
@@ -420,6 +529,7 @@ function status() {
420
529
  driver: lock ? { name: lock.name, userId: lock.userId } : null,
421
530
  recording: teach.activeSummary(),
422
531
  navigate: true,
532
+ fit: drive.supported(),
423
533
  });
424
534
  }
425
535
 
@@ -436,24 +546,41 @@ async function input({ session, events, who } = {}) {
436
546
  messages.push(msg);
437
547
  }
438
548
  // Every accepted input extends the lock.
439
- if (!extendDriverLock(session, who)) return reply(409, { error: "not-driving" });
549
+ const lock = extendDriverLock(session, who);
550
+ if (!lock) return reply(409, { error: "not-driving" });
440
551
  if (!messages.length) return reply(200, { ok: true, accepted: 0 });
552
+ if (lock.fit && !drive.active(session)) reviveView(lock);
441
553
  const entry = openInputSocket(session);
442
- if (!entry) return reply(404, { error: "not-running" });
554
+ if (!entry && !drive.active(session)) return reply(404, { error: "not-running" });
443
555
  const job = runQueued(session, messages.length, async () => {
444
- if (!(await socketReady(entry))) return reply(503, { error: "browser-unavailable", accepted: 0 });
445
556
  const recorder = teach.recorderFor(session, who.userId);
446
- let sent = 0;
447
- for (const msg of messages) {
448
- if (recorder) {
557
+ const record = async (msg, frame, info) => {
558
+ if (!recorder) return;
559
+ // Teach me counts typing from key presses: typed text is shown to it as keys.
560
+ for (const ev of msg.type === "input_text" ? textAsKeys(msg.text) : [msg]) {
449
561
  try {
450
- await recorder.onEvent(msg, { session: { name: entry.name, dir: entry.dir }, frame: entry.frame });
562
+ await recorder.onEvent(ev, { session: info, frame });
451
563
  } catch (e) {
452
564
  console.error(`[browser] recording a step failed: ${e.message}`);
453
565
  }
454
566
  }
567
+ };
568
+ // The driver's view: straight to Chrome, in order, without waiting for each answer.
569
+ if (drive.active(session)) {
570
+ const info = sessionInfo(session) || { name: session, dir: entry ? entry.dir : null };
571
+ for (const msg of messages) {
572
+ await record(msg, drive.lastFrame(), { name: info.name, dir: info.dir });
573
+ if (drive.dispatch([msg]) < 0) return reply(503, { error: "browser-unavailable" });
574
+ }
575
+ return reply(200, { ok: true, accepted: messages.length });
576
+ }
577
+ if (!entry || !(await socketReady(entry))) return reply(503, { error: "browser-unavailable", accepted: 0 });
578
+ let sent = 0;
579
+ for (const msg of messages) {
580
+ await record(msg, entry.frame, { name: entry.name, dir: entry.dir });
455
581
  if (!entry.open || entry.closed) return reply(503, { error: "browser-unavailable", accepted: sent });
456
- entry.ws.send(JSON.stringify(msg));
582
+ // agent-browser's socket takes no text: typed text goes as key presses.
583
+ for (const ev of msg.type === "input_text" ? textAsKeys(msg.text) : [msg]) entry.ws.send(JSON.stringify(ev));
457
584
  sent += 1;
458
585
  }
459
586
  return reply(200, { ok: true, accepted: sent });
@@ -602,6 +729,8 @@ function _reset() {
602
729
  for (const name of [...sockets.keys()]) closeInputSocket(name);
603
730
  queues.clear();
604
731
  if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
732
+ reviving = null;
733
+ drive._reset();
605
734
  deps = defaultDeps();
606
735
  }
607
736
 
@@ -613,6 +742,7 @@ module.exports = {
613
742
  checkEvent,
614
743
  chatOrigin,
615
744
  control,
745
+ fitView,
616
746
  handBackLine,
617
747
  input,
618
748
  navigate,
@@ -0,0 +1,589 @@
1
+ // The Browser app's phone and web view (bot apps v2): while a person drives the bot's browser and
2
+ // Chrome runs in its own container (AGENT_BROWSER_CDP), the bot's tab takes the driver's screen.
3
+ // Its size and pixel density, a phone's page layout and touch on a phone, a desktop's on the web.
4
+ // This module's own DevTools connection then carries the driver's whole session:
5
+ // - the picture: a screencast at the driver's size, so text is sharp on their screen;
6
+ // - their input: mouse, keys, touch and typed text, straight to Chrome, in order;
7
+ // - the page's text fields (where they are, what kind), so the driver's own keyboard opens the
8
+ // moment they tap one.
9
+ // Handing back (stop) closes the connection. Chrome drops the size, layout and touch with it, and
10
+ // the window goes back to the size it had.
11
+ //
12
+ // Why the window is resized too: Chrome's screencast pictures the window, so a phone-size view in
13
+ // a desktop-size window comes out boxed in a corner after the next page load (proven 10 Oct on
14
+ // Chrome 149). Order matters: the view first, then the window, or the first pictures stay at 1x.
15
+ // The browser's identity (user agent) never changes: sites the bot or the person signed into
16
+ // must see the same browser after the hand back.
17
+ //
18
+ // Only one drive at a time (the driver lock is single). Nothing here decides who may drive:
19
+ // core/browser-control.js does, and calls start / fit / dispatch / stop.
20
+
21
+ const WebSocket = require("ws");
22
+ const view = require("./browser-view");
23
+
24
+ const QUALITY = 65;
25
+ const MAX_FPS = 30;
26
+ // A drive's picture is at most this many device pixels (a phone at 2x is about 1.3 million).
27
+ const MAX_PIXELS = 1600000;
28
+ const MIN_SIDE = 240;
29
+ const MAX_SIDE = 1920;
30
+ const MAX_DPR = 3;
31
+ const CALL_TIMEOUT_MS = 4000;
32
+ const CONNECT_TIMEOUT_MS = 3000;
33
+ // The text fields are read this long after the page stops changing (and after a tap).
34
+ const FIELDS_SETTLE_MS = 250;
35
+ const MAX_FIELDS = 60;
36
+ const TEXT_MAX = 500;
37
+
38
+ function defaultDeps() {
39
+ return {
40
+ now: () => Date.now(),
41
+ endpoint: () => view.cdpEndpoint(),
42
+ fetchJson: async (url) => {
43
+ const ctrl = new AbortController();
44
+ const timer = setTimeout(() => ctrl.abort(), CONNECT_TIMEOUT_MS);
45
+ if (timer.unref) timer.unref();
46
+ try {
47
+ const res = await fetch(url, { signal: ctrl.signal });
48
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
49
+ return await res.json();
50
+ } finally {
51
+ clearTimeout(timer);
52
+ }
53
+ },
54
+ connect: (url) => new WebSocket(url, { perMessageDeflate: false }),
55
+ targetOf: (session) => view.sessionTargetId(session),
56
+ };
57
+ }
58
+
59
+ let deps = defaultDeps();
60
+ let current = null;
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // The driver's screen, checked and capped
64
+
65
+ function round2(n) {
66
+ return Math.round(n * 100) / 100;
67
+ }
68
+
69
+ // { width, height, dpr, mobile, touch } in CSS pixels, or null when it is not a screen.
70
+ function checkViewport(vp) {
71
+ if (!vp || typeof vp !== "object" || Array.isArray(vp)) return null;
72
+ const width = Math.round(Number(vp.width));
73
+ const height = Math.round(Number(vp.height));
74
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
75
+ if (width < MIN_SIDE || height < MIN_SIDE || width > MAX_SIDE || height > MAX_SIDE) return null;
76
+ let dpr = Number(vp.dpr);
77
+ if (!Number.isFinite(dpr) || dpr < 1) dpr = 1;
78
+ dpr = Math.min(dpr, MAX_DPR, Math.sqrt(MAX_PIXELS / (width * height)));
79
+ dpr = Math.max(1, round2(dpr));
80
+ return { width, height, dpr, mobile: vp.mobile === true, touch: vp.touch === true };
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // One DevTools connection
85
+
86
+ async function openConnection() {
87
+ const endpoint = deps.endpoint();
88
+ if (!endpoint) throw Object.assign(new Error("Chrome is not in its own container"), { code: "unsupported" });
89
+ const version = await deps.fetchJson(`${endpoint}/json/version`);
90
+ const reported = version && typeof version.webSocketDebuggerUrl === "string" ? version.webSocketDebuggerUrl : "";
91
+ let path;
92
+ try {
93
+ path = new URL(reported).pathname;
94
+ } catch (_) {
95
+ path = "";
96
+ }
97
+ if (!/^\/devtools\/browser\/[A-Za-z0-9-]{1,80}$/.test(path)) throw new Error("Chrome gave no DevTools address");
98
+ // Loopback only: the address is built from AGENT_BROWSER_CDP, never from what Chrome reports.
99
+ const ws = deps.connect(`ws://${new URL(endpoint).host}${path}`);
100
+ const conn = { ws, nextId: 0, calls: new Map(), handlers: [], closed: false };
101
+ ws.on("message", (data) => {
102
+ let msg;
103
+ try { msg = JSON.parse(String(data)); } catch (_) { return; }
104
+ if (msg && msg.id && conn.calls.has(msg.id)) {
105
+ const call = conn.calls.get(msg.id);
106
+ conn.calls.delete(msg.id);
107
+ clearTimeout(call.timer);
108
+ if (msg.error) call.reject(new Error(`${call.method}: ${msg.error.message || "failed"}`));
109
+ else call.resolve(msg.result || {});
110
+ return;
111
+ }
112
+ if (msg && msg.method) for (const fn of conn.handlers) fn(msg);
113
+ });
114
+ const fail = () => {
115
+ if (conn.closed) return;
116
+ conn.closed = true;
117
+ for (const call of conn.calls.values()) { clearTimeout(call.timer); call.reject(new Error("DevTools connection closed")); }
118
+ conn.calls.clear();
119
+ for (const fn of conn.handlers) fn({ method: "_closed" });
120
+ };
121
+ ws.on("close", fail);
122
+ ws.on("error", fail);
123
+ await new Promise((resolve, reject) => {
124
+ const timer = setTimeout(() => reject(new Error("DevTools connection timed out")), CONNECT_TIMEOUT_MS);
125
+ if (timer.unref) timer.unref();
126
+ ws.on("open", () => { clearTimeout(timer); resolve(); });
127
+ ws.on("error", (e) => { clearTimeout(timer); reject(e instanceof Error ? e : new Error("DevTools connection failed")); });
128
+ });
129
+ return conn;
130
+ }
131
+
132
+ // A call that waits for Chrome's answer.
133
+ function call(conn, method, params = {}, sessionId = null) {
134
+ if (!conn || conn.closed) return Promise.reject(new Error("DevTools connection closed"));
135
+ const id = ++conn.nextId;
136
+ return new Promise((resolve, reject) => {
137
+ const timer = setTimeout(() => {
138
+ conn.calls.delete(id);
139
+ reject(new Error(`${method} timed out`));
140
+ }, CALL_TIMEOUT_MS);
141
+ if (timer.unref) timer.unref();
142
+ conn.calls.set(id, { resolve, reject, timer, method });
143
+ try {
144
+ conn.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
145
+ } catch (e) {
146
+ clearTimeout(timer);
147
+ conn.calls.delete(id);
148
+ reject(e);
149
+ }
150
+ });
151
+ }
152
+
153
+ // A call whose answer nobody waits for (input, frame acks): sent in order, failures only logged.
154
+ function fire(conn, method, params, sessionId) {
155
+ call(conn, method, params, sessionId).catch((e) => {
156
+ if (!conn.closed) console.error(`[browser] ${e.message}`);
157
+ });
158
+ }
159
+
160
+ function closeConnection(conn) {
161
+ if (!conn || conn.closed) return;
162
+ conn.closed = true;
163
+ for (const c of conn.calls.values()) { clearTimeout(c.timer); c.reject(new Error("DevTools connection closed")); }
164
+ conn.calls.clear();
165
+ try { conn.ws.close(); } catch (_) { /* already closed */ }
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // The page's text fields: where they are on the driver's screen (CSS pixels of the view, after
170
+ // any zoom), what kind, and whether one has the focus. Never a value.
171
+
172
+ const FIELDS_SCRIPT = `(() => {
173
+ const vv = window.visualViewport || { offsetLeft: 0, offsetTop: 0, scale: 1, width: innerWidth, height: innerHeight };
174
+ const sw = vv.width * vv.scale, sh = vv.height * vv.scale;
175
+ const SKIP = { hidden: 1, checkbox: 1, radio: 1, button: 1, submit: 1, reset: 1, image: 1, file: 1, range: 1, color: 1 };
176
+ const KINDS = { password: 1, email: 1, number: 1, tel: 1, url: 1, search: 1 };
177
+ const attr = (el, n, max) => String(el.getAttribute(n) || "").slice(0, max);
178
+ const out = [];
179
+ let focused = -1;
180
+ const active = document.activeElement;
181
+ const list = document.querySelectorAll('input, textarea, [contenteditable=""], [contenteditable="true"], [role="textbox"], [role="searchbox"], [role="combobox"]');
182
+ for (const el of list) {
183
+ if (out.length >= ${MAX_FIELDS}) break;
184
+ if (el.disabled || el.readOnly) continue;
185
+ let kind = "text";
186
+ if (el.tagName === "INPUT") {
187
+ const t = String(el.type || "text").toLowerCase();
188
+ if (SKIP[t]) continue;
189
+ if (KINDS[t]) kind = t;
190
+ } else if (el.tagName === "TEXTAREA") kind = "multiline";
191
+ const r = el.getBoundingClientRect();
192
+ if (r.width < 4 || r.height < 4) continue;
193
+ const x = (r.left - vv.offsetLeft) * vv.scale, y = (r.top - vv.offsetTop) * vv.scale;
194
+ const w = r.width * vv.scale, h = r.height * vv.scale;
195
+ if (x + w < 0 || y + h < 0 || x > sw || y > sh) continue;
196
+ const st = getComputedStyle(el);
197
+ if (st.visibility === "hidden" || st.display === "none" || Number(st.opacity) === 0) continue;
198
+ if (el === active) focused = out.length;
199
+ out.push({ x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h), kind,
200
+ ac: attr(el, "autocomplete", 40), im: attr(el, "inputmode", 16), enter: attr(el, "enterkeyhint", 10) });
201
+ }
202
+ const editable = !!(active && (active.isContentEditable || active.tagName === "TEXTAREA" ||
203
+ (active.tagName === "INPUT" && !SKIP[String(active.type || "text").toLowerCase()])));
204
+ return JSON.stringify({ fields: out, focused, editable });
205
+ })()`;
206
+
207
+ function cleanFields(raw) {
208
+ let parsed;
209
+ try { parsed = JSON.parse(String(raw)); } catch (_) { return null; }
210
+ if (!parsed || !Array.isArray(parsed.fields)) return null;
211
+ const word = (v, max) => (typeof v === "string" ? v.slice(0, max).replace(/[^\w -]/g, "") : "");
212
+ const fields = parsed.fields.slice(0, MAX_FIELDS).filter((f) => f && [f.x, f.y, f.w, f.h].every(Number.isFinite)).map((f) => ({
213
+ x: Math.round(f.x), y: Math.round(f.y), w: Math.round(f.w), h: Math.round(f.h),
214
+ kind: word(f.kind, 12) || "text", ac: word(f.ac, 40), im: word(f.im, 16), enter: word(f.enter, 10),
215
+ }));
216
+ const focused = Number.isInteger(parsed.focused) && parsed.focused >= 0 && parsed.focused < fields.length ? parsed.focused : -1;
217
+ return { fields, focused, editable: parsed.editable === true };
218
+ }
219
+
220
+ function scheduleFields(drive, ms = FIELDS_SETTLE_MS) {
221
+ if (!drive || drive.stopped) return;
222
+ clearTimeout(drive.fieldsTimer);
223
+ drive.fieldsTimer = setTimeout(() => readFields(drive), ms);
224
+ if (drive.fieldsTimer.unref) drive.fieldsTimer.unref();
225
+ }
226
+
227
+ async function readFields(drive) {
228
+ if (!drive || drive.stopped) return;
229
+ try {
230
+ const res = await call(drive.conn, "Runtime.evaluate", { expression: FIELDS_SCRIPT, returnByValue: true, silent: true }, drive.sid);
231
+ const value = res && res.result ? res.result.value : null;
232
+ const fields = cleanFields(value);
233
+ if (!fields || drive.stopped) return;
234
+ const key = JSON.stringify(fields);
235
+ if (key === drive.fieldsKey) return;
236
+ drive.fieldsKey = key;
237
+ view.driveFeed.emit("fields", { session: drive.session.name, ...fields });
238
+ } catch (_) {
239
+ // a page in the middle of loading: the next frame reads them again
240
+ }
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Frames
245
+
246
+ function onFrame(drive, params) {
247
+ if (drive.stopped || !params || typeof params.data !== "string") return;
248
+ const md = params.metadata || {};
249
+ const vp = drive.viewport;
250
+ const frame = {
251
+ data: params.data,
252
+ // The driver's page is vp.width x vp.height CSS pixels; the picture is that times vp.dpr.
253
+ metadata: {
254
+ deviceWidth: vp.width,
255
+ deviceHeight: vp.height,
256
+ pageScaleFactor: Number.isFinite(md.pageScaleFactor) ? md.pageScaleFactor : 1,
257
+ scrollOffsetX: Number.isFinite(md.scrollOffsetX) ? md.scrollOffsetX : 0,
258
+ scrollOffsetY: Number.isFinite(md.scrollOffsetY) ? md.scrollOffsetY : 0,
259
+ timestamp: Number.isFinite(md.timestamp) ? Math.round(md.timestamp * 1000) : deps.now(),
260
+ fit: true,
261
+ },
262
+ seq: ++drive.seq,
263
+ at: deps.now(),
264
+ };
265
+ drive.lastFrame = frame;
266
+ view.driveFeed.last = { session: drive.session.name, frame };
267
+ view.driveFeed.emit("frame", { session: drive.session.name, frame });
268
+ // Chrome sends the next picture after this one is acknowledged: at most MAX_FPS a second.
269
+ const wait = Math.max(0, drive.lastAckAt + 1000 / MAX_FPS - deps.now());
270
+ const ack = () => {
271
+ drive.lastAckAt = deps.now();
272
+ if (!drive.stopped) fire(drive.conn, "Page.screencastFrameAck", { sessionId: params.sessionId }, drive.sid);
273
+ };
274
+ if (wait > 0) {
275
+ const t = setTimeout(ack, wait);
276
+ if (t.unref) t.unref();
277
+ } else ack();
278
+ scheduleFields(drive);
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // start / fit / stop
283
+
284
+ async function applyViewport(drive, vp) {
285
+ const { conn, sid } = drive;
286
+ // The view first, then the window (the other way round, the first pictures stay at 1x).
287
+ await call(conn, "Emulation.setDeviceMetricsOverride", {
288
+ width: vp.width,
289
+ height: vp.height,
290
+ deviceScaleFactor: vp.dpr,
291
+ mobile: vp.mobile,
292
+ scale: vp.dpr,
293
+ screenWidth: vp.width,
294
+ screenHeight: vp.height,
295
+ }, sid);
296
+ await call(conn, "Browser.setWindowBounds", {
297
+ windowId: drive.windowId,
298
+ bounds: {
299
+ width: Math.round(vp.width * vp.dpr) + drive.insets.width,
300
+ height: Math.round(vp.height * vp.dpr) + drive.insets.height,
301
+ windowState: "normal",
302
+ },
303
+ });
304
+ await call(conn, "Emulation.setTouchEmulationEnabled", { enabled: vp.touch, ...(vp.touch ? { maxTouchPoints: 5 } : {}) }, sid);
305
+ drive.viewport = vp;
306
+ // A new size needs a new screencast: its picture size is fixed when it starts.
307
+ if (drive.screencasting) await call(conn, "Page.stopScreencast", {}, sid).catch(() => {});
308
+ await call(conn, "Page.startScreencast", {
309
+ format: "jpeg",
310
+ quality: QUALITY,
311
+ maxWidth: Math.round(vp.width * vp.dpr),
312
+ maxHeight: Math.round(vp.height * vp.dpr),
313
+ everyNthFrame: 1,
314
+ }, sid);
315
+ drive.screencasting = true;
316
+ }
317
+
318
+ // session: { name, dir } (an agent-browser session in container mode); viewport: the driver's
319
+ // screen; savedBounds: the window's size before an earlier drive of this same lock (after a bot
320
+ // restart), so the hand back restores that and not the phone size.
321
+ // -> { ok: true, fit, bounds } | { ok: false, error }
322
+ async function start({ session, viewport, savedBounds = null } = {}) {
323
+ const vp = checkViewport(viewport);
324
+ if (!vp) return { ok: false, error: "bad-viewport" };
325
+ if (!session || !session.name) return { ok: false, error: "not-running" };
326
+ if (!deps.endpoint()) return { ok: false, error: "unsupported" };
327
+ const targetId = deps.targetOf(session);
328
+ if (!targetId) return { ok: false, error: "no-tab" };
329
+ if (current) await stop();
330
+ let conn;
331
+ try {
332
+ conn = await openConnection();
333
+ } catch (e) {
334
+ console.error(`[browser] phone/web view unavailable: ${e.message}`);
335
+ return { ok: false, error: e.code === "unsupported" ? "unsupported" : "browser-unavailable" };
336
+ }
337
+ const drive = {
338
+ session: { name: session.name, dir: session.dir || null },
339
+ targetId,
340
+ conn,
341
+ sid: null,
342
+ windowId: null,
343
+ savedBounds: null,
344
+ insets: { width: 0, height: 0 },
345
+ viewport: null,
346
+ screencasting: false,
347
+ stopped: false,
348
+ lastFrame: null,
349
+ lastAckAt: 0,
350
+ seq: 0,
351
+ fieldsTimer: null,
352
+ fieldsKey: "",
353
+ touchBase: null,
354
+ };
355
+ conn.handlers.push((msg) => {
356
+ if (msg.method === "Page.screencastFrame" && msg.sessionId === drive.sid) onFrame(drive, msg.params);
357
+ else if (msg.method === "_closed" || (msg.method === "Target.detachedFromTarget" && msg.params && msg.params.sessionId === drive.sid)) {
358
+ if (!drive.stopped) lost(drive);
359
+ }
360
+ });
361
+ try {
362
+ const attached = await call(conn, "Target.attachToTarget", { targetId, flatten: true });
363
+ drive.sid = attached.sessionId;
364
+ if (!drive.sid) throw new Error("no DevTools session for the tab");
365
+ await call(conn, "Target.activateTarget", { targetId }).catch(() => {});
366
+ const win = await call(conn, "Browser.getWindowForTarget", { targetId });
367
+ drive.windowId = win.windowId;
368
+ drive.savedBounds = savedBounds && Number.isFinite(savedBounds.width) && Number.isFinite(savedBounds.height)
369
+ ? { width: Math.round(savedBounds.width), height: Math.round(savedBounds.height) }
370
+ : { width: win.bounds.width, height: win.bounds.height };
371
+ // What the window adds around the page (Chrome's own bars, even headless).
372
+ const sizes = await call(conn, "Runtime.evaluate", {
373
+ expression: "JSON.stringify([outerWidth - innerWidth, outerHeight - innerHeight])",
374
+ returnByValue: true,
375
+ silent: true,
376
+ }, drive.sid);
377
+ try {
378
+ const [w, h] = JSON.parse(sizes.result.value);
379
+ if (Number.isFinite(w) && w >= 0 && w < 400) drive.insets.width = Math.round(w);
380
+ if (Number.isFinite(h) && h >= 0 && h < 400) drive.insets.height = Math.round(h);
381
+ } catch (_) { /* no insets known: none */ }
382
+ await call(conn, "Page.enable", {}, drive.sid);
383
+ await applyViewport(drive, vp);
384
+ } catch (e) {
385
+ console.error(`[browser] phone/web view failed: ${e.message}`);
386
+ await restore(drive);
387
+ return { ok: false, error: "browser-unavailable" };
388
+ }
389
+ current = drive;
390
+ view.driveFeed.active = drive.session.name;
391
+ view.driveFeed.emit("start", { session: drive.session.name, fit: vp });
392
+ scheduleFields(drive, 400);
393
+ console.log(`[browser] driver's view: ${vp.width}x${vp.height} at ${vp.dpr}x${vp.mobile ? ", phone layout" : ""}${vp.touch ? ", touch" : ""}`);
394
+ return { ok: true, fit: vp, bounds: drive.savedBounds };
395
+ }
396
+
397
+ // The driver's screen changed size (a turned phone, a resized panel). -> { ok, fit } | { ok: false, error }
398
+ async function fit(viewport) {
399
+ const drive = current;
400
+ if (!drive || drive.stopped) return { ok: false, error: "not-driving" };
401
+ const vp = checkViewport(viewport);
402
+ if (!vp) return { ok: false, error: "bad-viewport" };
403
+ try {
404
+ await applyViewport(drive, vp);
405
+ } catch (e) {
406
+ console.error(`[browser] resizing the driver's view failed: ${e.message}`);
407
+ return { ok: false, error: "browser-unavailable" };
408
+ }
409
+ scheduleFields(drive, 400);
410
+ view.driveFeed.emit("start", { session: drive.session.name, fit: vp });
411
+ return { ok: true, fit: vp };
412
+ }
413
+
414
+ // Undo everything this drive changed. The emulation goes with the DevTools session anyway; the
415
+ // window size does not.
416
+ async function restore(drive) {
417
+ drive.stopped = true;
418
+ clearTimeout(drive.fieldsTimer);
419
+ const { conn, sid } = drive;
420
+ if (conn && !conn.closed) {
421
+ if (sid && drive.screencasting) await call(conn, "Page.stopScreencast", {}, sid).catch(() => {});
422
+ if (sid) await call(conn, "Emulation.clearDeviceMetricsOverride", {}, sid).catch(() => {});
423
+ if (sid) await call(conn, "Emulation.setTouchEmulationEnabled", { enabled: false }, sid).catch(() => {});
424
+ if (drive.windowId !== null && drive.savedBounds) {
425
+ await call(conn, "Browser.setWindowBounds", { windowId: drive.windowId, bounds: { ...drive.savedBounds, windowState: "normal" } }).catch(() => {});
426
+ }
427
+ }
428
+ closeConnection(conn);
429
+ }
430
+
431
+ // The DevTools connection or the tab went away under a drive.
432
+ function lost(drive) {
433
+ if (current === drive) {
434
+ current = null;
435
+ view.driveFeed.active = null;
436
+ view.driveFeed.last = null;
437
+ }
438
+ drive.stopped = true;
439
+ clearTimeout(drive.fieldsTimer);
440
+ closeConnection(drive.conn);
441
+ view.driveFeed.emit("stop", { session: drive.session.name, reason: "lost" });
442
+ console.error("[browser] the driver's view lost its connection to Chrome");
443
+ }
444
+
445
+ async function stop() {
446
+ const drive = current;
447
+ if (!drive) return false;
448
+ current = null;
449
+ view.driveFeed.active = null;
450
+ view.driveFeed.last = null;
451
+ await restore(drive);
452
+ view.driveFeed.emit("stop", { session: drive.session.name, reason: "handed-back" });
453
+ return true;
454
+ }
455
+
456
+ // After a bot restart nothing is live, but the window may still be at the driver's size: put it
457
+ // back from the size the lock remembers. Best effort.
458
+ async function restoreWindow(session, bounds) {
459
+ if (current || !bounds || !Number.isFinite(bounds.width) || !Number.isFinite(bounds.height)) return false;
460
+ const targetId = session ? deps.targetOf(session) : null;
461
+ if (!targetId || !deps.endpoint()) return false;
462
+ let conn;
463
+ try {
464
+ conn = await openConnection();
465
+ const win = await call(conn, "Browser.getWindowForTarget", { targetId });
466
+ await call(conn, "Browser.setWindowBounds", { windowId: win.windowId, bounds: { width: Math.round(bounds.width), height: Math.round(bounds.height), windowState: "normal" } });
467
+ return true;
468
+ } catch (_) {
469
+ return false;
470
+ } finally {
471
+ closeConnection(conn);
472
+ }
473
+ }
474
+
475
+ function active(sessionName) {
476
+ return !!current && !current.stopped && (sessionName === undefined || current.session.name === sessionName);
477
+ }
478
+
479
+ function lastFrame() {
480
+ return current && current.lastFrame ? { data: current.lastFrame.data, metadata: current.lastFrame.metadata, at: current.lastFrame.at } : null;
481
+ }
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // Input: events already checked by core/browser-control.js checkEvent.
485
+
486
+ // A touch's time on the driver's device (ms, their clock) moved onto this machine's clock, so a
487
+ // swipe's speed (and its fling) survives the trip, in seconds as Chrome wants. A gesture's first
488
+ // touch lands TOUCH_SLACK_MS in the past, so the touches that arrive with it (a batch spans about
489
+ // one round trip) keep their spacing instead of being cut to "now".
490
+ const TOUCH_SLACK_MS = 150;
491
+ function touchTime(drive, ev) {
492
+ const now = deps.now();
493
+ if (!Number.isFinite(ev.t)) return now / 1000;
494
+ if (!drive.touchBase || (ev.eventType === "touchStart" && !drive.touching)) drive.touchBase = { offset: now - TOUCH_SLACK_MS - ev.t };
495
+ const at = ev.t + drive.touchBase.offset;
496
+ // Never in the future, never more than a few seconds behind.
497
+ return Math.min(now, Math.max(now - 5000, at)) / 1000;
498
+ }
499
+
500
+ // The driver's points are the page's CSS pixels; with the view drawn at its density (Emulation
501
+ // scale), Chrome reads input in the drawn picture's pixels, so every point and scroll is scaled
502
+ // by it (proven 10 Oct on Chrome 149: a tap at CSS (46,143) with scale 2 landed at (23,72)).
503
+ function toCdp(drive, ev) {
504
+ const k = drive.viewport && drive.viewport.dpr > 0 ? drive.viewport.dpr : 1;
505
+ const at = (n) => Math.round(n * k * 100) / 100;
506
+ if (ev.type === "input_mouse") {
507
+ const p = { type: ev.eventType, x: at(ev.x), y: at(ev.y), button: ev.button || "none", clickCount: ev.clickCount || 0, modifiers: ev.modifiers || 0, pointerType: "mouse" };
508
+ if (ev.eventType === "mouseWheel") { p.deltaX = at(ev.deltaX || 0); p.deltaY = at(ev.deltaY || 0); }
509
+ return ["Input.dispatchMouseEvent", p];
510
+ }
511
+ if (ev.type === "input_keyboard") {
512
+ const p = { type: ev.eventType === "keyDown" && !ev.text ? "rawKeyDown" : ev.eventType, modifiers: ev.modifiers || 0 };
513
+ if (ev.key !== undefined) p.key = ev.key;
514
+ if (ev.code !== undefined) p.code = ev.code;
515
+ if (ev.text !== undefined) { p.text = ev.text; p.unmodifiedText = ev.text; }
516
+ if (ev.windowsVirtualKeyCode !== undefined) { p.windowsVirtualKeyCode = ev.windowsVirtualKeyCode; p.nativeVirtualKeyCode = ev.windowsVirtualKeyCode; }
517
+ return ["Input.dispatchKeyEvent", p];
518
+ }
519
+ if (ev.type === "input_touch") {
520
+ const ending = ev.eventType === "touchEnd" || ev.eventType === "touchCancel";
521
+ const p = {
522
+ type: ev.eventType,
523
+ // Chrome wants no points on an end or a cancel.
524
+ touchPoints: ending ? [] : ev.touchPoints.map((t, i) => ({ x: at(t.x), y: at(t.y), id: t.id !== undefined ? t.id : i, radiusX: 1, radiusY: 1, force: 1 })),
525
+ modifiers: ev.modifiers || 0,
526
+ timestamp: touchTime(drive, ev),
527
+ };
528
+ drive.touching = !ending;
529
+ return ["Input.dispatchTouchEvent", p];
530
+ }
531
+ if (ev.type === "input_text") return ["Input.insertText", { text: String(ev.text).slice(0, TEXT_MAX) }];
532
+ return null;
533
+ }
534
+
535
+ // Send a batch to Chrome in order without waiting for each answer. -> number sent
536
+ function dispatch(events) {
537
+ const drive = current;
538
+ if (!drive || drive.stopped || drive.conn.closed) return -1;
539
+ let sent = 0;
540
+ let tapped = false;
541
+ for (const ev of events) {
542
+ const cdp = toCdp(drive, ev);
543
+ if (!cdp) continue;
544
+ fire(drive.conn, cdp[0], cdp[1], drive.sid);
545
+ sent += 1;
546
+ if (ev.eventType === "touchEnd" || ev.eventType === "mouseReleased" || ev.type === "input_text" || (ev.type === "input_keyboard" && ev.key === "Tab")) tapped = true;
547
+ }
548
+ // A tap or a Tab can move the focus: read the fields again soon.
549
+ if (tapped) scheduleFields(drive, 200);
550
+ return sent;
551
+ }
552
+
553
+ function _setDeps(overrides = {}) {
554
+ deps = { ...deps, ...overrides };
555
+ }
556
+
557
+ async function _reset() {
558
+ if (current) {
559
+ const drive = current;
560
+ current = null;
561
+ drive.stopped = true;
562
+ clearTimeout(drive.fieldsTimer);
563
+ closeConnection(drive.conn);
564
+ }
565
+ view.driveFeed.active = null;
566
+ view.driveFeed.last = null;
567
+ view.driveFeed.removeAllListeners();
568
+ deps = defaultDeps();
569
+ }
570
+
571
+ module.exports = {
572
+ FIELDS_SCRIPT,
573
+ MAX_FPS,
574
+ MAX_PIXELS,
575
+ QUALITY,
576
+ TEXT_MAX,
577
+ active,
578
+ checkViewport,
579
+ cleanFields,
580
+ dispatch,
581
+ fit,
582
+ lastFrame,
583
+ restoreWindow,
584
+ start,
585
+ stop,
586
+ supported: () => !!deps.endpoint(),
587
+ _reset,
588
+ _setDeps,
589
+ };
@@ -4,7 +4,9 @@
4
4
  // hook (core/tool-guard.js, C7), which runs as a separate process for every tool call and
5
5
  // re-reads it each time, like the tooling-mode switch (core/tooling-mode.js).
6
6
  //
7
- // { session, userId, name, chatId, since, expiresAt } (ISO times; written atomically, 0600)
7
+ // { session, userId, name, chatId, since, expiresAt, fit?, bounds? } (ISO times; atomic, 0600)
8
+ // fit: the driver's view (core/browser-drive.js), bounds: the window's size before it, so a
9
+ // restarted bot can bring the view back, and the hand back can put the window back.
8
10
  //
9
11
  // An expired lock is no lock: it never blocks the bot, and the sweeper in
10
12
  // core/browser-control.js hands the browser back.
@@ -48,4 +50,33 @@ function removeLock() {
48
50
  try { fs.rmSync(driverFile(), { force: true }); } catch (_) { /* already gone */ }
49
51
  }
50
52
 
51
- module.exports = { LOCK_TTL_MS, driverFile, readLock, lockExpiresAt, liveLock, writeLock, removeLock };
53
+ // The bot was told to wait (core/tool-guard.js refused one of its agent-browser commands while
54
+ // this lock was live), so the hand back must wake it. Keyed by the lock's `since`: a marker from
55
+ // an earlier drive never counts. A separate file, because the tool guard runs in its own process
56
+ // and the lock file is rewritten every second while someone drives.
57
+ function waitingFile() {
58
+ return process.env.OC_BROWSER_WAITING_FILE || path.join(require("../config-dir"), "browser-waiting.json");
59
+ }
60
+
61
+ function markWaiting(lock) {
62
+ if (!lock || !lock.since) return;
63
+ try {
64
+ atomicWriteFileSync(waitingFile(), `${JSON.stringify({ since: lock.since, at: new Date().toISOString() })}\n`, { mode: 0o600 });
65
+ } catch (_) { /* best effort: the bot then just is not woken */ }
66
+ }
67
+
68
+ function waitedDuring(lock) {
69
+ if (!lock || !lock.since) return false;
70
+ try {
71
+ const mark = JSON.parse(fs.readFileSync(waitingFile(), "utf8"));
72
+ return !!mark && mark.since === lock.since;
73
+ } catch (_) {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function clearWaiting() {
79
+ try { fs.rmSync(waitingFile(), { force: true }); } catch (_) { /* already gone */ }
80
+ }
81
+
82
+ module.exports = { LOCK_TTL_MS, driverFile, readLock, lockExpiresAt, liveLock, writeLock, removeLock, markWaiting, waitedDuring, clearWaiting };
@@ -22,11 +22,22 @@
22
22
  const fs = require("fs");
23
23
  const os = require("os");
24
24
  const path = require("path");
25
+ const { EventEmitter } = require("events");
25
26
  const WebSocket = require("ws");
26
27
 
27
28
  const SESSION_NAME = /^[A-Za-z0-9._-]{1,64}$/;
28
29
  const DEFAULT_FPS = 5;
29
- const MAX_FPS = 10;
30
+ const MAX_FPS = 30;
31
+
32
+ // The phone and web view (core/browser-drive.js) publishes here while a person drives: "frame",
33
+ // "fields" (the page's text fields) and "start" / "stop", each with the session's name. A viewer
34
+ // of that session gets these instead of agent-browser's own frames, which follow neither the
35
+ // driver's size nor the page's real size. `active` and `last` let a viewer that opens mid-drive
36
+ // start with the picture at once.
37
+ const driveFeed = new EventEmitter();
38
+ driveFeed.setMaxListeners(200);
39
+ driveFeed.active = null;
40
+ driveFeed.last = null;
30
41
  const KEEPALIVE_MS = 25000;
31
42
  const ACTIVATE_TIMEOUT_MS = 1500;
32
43
  const TARGET_ID = /^[A-Za-z0-9]{8,64}$/; // static pages send no frames; comments keep proxies from closing the stream
@@ -213,18 +224,77 @@ function openStream(sessionName, { fps, sessions, connect = (url) => new WebSock
213
224
 
214
225
  // Server-sent events for one session: "frame" and "url" events, a final "end" event with the
215
226
  // reason, and ": ping" comments while the page is still. Every payload also carries its type,
216
- // for readers that only look at data lines. Frames are skipped, never queued, while a slow
217
- // viewer's connection is backed up.
218
- function serveStream(res, sessionName, { fps, sessions, connect, activate, keepaliveMs = KEEPALIVE_MS } = {}) {
227
+ // for readers that only look at data lines. While a slow viewer's connection is backed up,
228
+ // frames are not queued: only the newest waits, and goes out once the connection drains, so the
229
+ // last picture of a scroll is never lost. While a person drives in the phone or web view, the
230
+ // frames are the driver's (core/browser-drive.js), at most `fps` a second, with "fields" events
231
+ // (the page's text fields) and "fit" events (the driver's view started, changed or ended).
232
+ function serveStream(res, sessionName, { fps, sessions, connect, activate, keepaliveMs = KEEPALIVE_MS, feed = driveFeed } = {}) {
219
233
  res.writeHead(200, {
220
234
  "Content-Type": "text/event-stream",
221
235
  "Cache-Control": "no-cache, no-store",
222
236
  "Connection": "keep-alive",
223
237
  "X-Accel-Buffering": "no",
224
238
  });
239
+ let closed = false;
225
240
  const write = (event, obj) => {
241
+ if (closed) return;
226
242
  try { res.write(`event: ${event}\ndata: ${JSON.stringify(obj)}\n\n`); } catch (_) { /* viewer gone */ }
227
243
  };
244
+ const interval = 1000 / clampFps(fps);
245
+ let lastSent = 0;
246
+ let pending = null;
247
+ let slot = null;
248
+ const flush = () => {
249
+ if (!pending || closed || res.writableNeedDrain) return;
250
+ const now = Date.now();
251
+ const due = lastSent + interval;
252
+ if (now < due) {
253
+ if (!slot) {
254
+ slot = setTimeout(() => { slot = null; flush(); }, due - now);
255
+ if (slot.unref) slot.unref();
256
+ }
257
+ return;
258
+ }
259
+ const msg = pending;
260
+ pending = null;
261
+ lastSent = now;
262
+ write("frame", msg);
263
+ };
264
+ const offerFrame = (msg) => {
265
+ pending = msg;
266
+ flush();
267
+ };
268
+ if (typeof res.on === "function") res.on("drain", flush);
269
+ let driving = feed.active === sessionName;
270
+ const onDriveFrame = (ev) => {
271
+ if (ev.session !== sessionName || !driving) return;
272
+ offerFrame({ type: "frame", seq: ev.frame.seq, data: ev.frame.data, metadata: ev.frame.metadata });
273
+ };
274
+ const onDriveFields = (ev) => {
275
+ if (ev.session !== sessionName || !driving) return;
276
+ write("fields", { type: "fields", fields: ev.fields, focused: ev.focused, editable: ev.editable });
277
+ };
278
+ const onDriveStart = (ev) => {
279
+ if (ev.session !== sessionName) return;
280
+ driving = true;
281
+ write("fit", { type: "fit", fit: ev.fit || null });
282
+ };
283
+ const onDriveStop = (ev) => {
284
+ if (ev.session !== sessionName) return;
285
+ driving = false;
286
+ write("fit", { type: "fit", fit: null });
287
+ };
288
+ feed.on("frame", onDriveFrame);
289
+ feed.on("fields", onDriveFields);
290
+ feed.on("start", onDriveStart);
291
+ feed.on("stop", onDriveStop);
292
+ const unsubscribe = () => {
293
+ feed.removeListener("frame", onDriveFrame);
294
+ feed.removeListener("fields", onDriveFields);
295
+ feed.removeListener("start", onDriveStart);
296
+ feed.removeListener("stop", onDriveStop);
297
+ };
228
298
  const heartbeat = setInterval(() => { try { res.write(": ping\n\n"); } catch (_) { /* viewer gone */ } }, keepaliveMs);
229
299
  const stream = openStream(sessionName, {
230
300
  fps,
@@ -232,17 +302,30 @@ function serveStream(res, sessionName, { fps, sessions, connect, activate, keepa
232
302
  connect,
233
303
  ...(activate ? { activate } : {}),
234
304
  onMessage: (msg) => {
235
- if (msg.type === "frame" && res.writableNeedDrain) return;
305
+ if (msg.type === "frame") {
306
+ if (!driving) offerFrame(msg);
307
+ return;
308
+ }
236
309
  write(msg.type, msg);
237
310
  },
238
311
  onEnd: (reason) => {
239
312
  clearInterval(heartbeat);
313
+ unsubscribe();
240
314
  write("end", { type: "end", reason });
315
+ closed = true;
241
316
  try { res.end(); } catch (_) { /* viewer gone */ }
242
317
  },
243
318
  });
319
+ // A viewer that opens while someone drives starts with the driver's picture.
320
+ if (driving && feed.last && feed.last.session === sessionName) {
321
+ const f = feed.last.frame;
322
+ offerFrame({ type: "frame", seq: f.seq, data: f.data, metadata: f.metadata });
323
+ }
244
324
  res.on("close", () => {
325
+ closed = true;
245
326
  clearInterval(heartbeat);
327
+ if (slot) clearTimeout(slot);
328
+ unsubscribe();
246
329
  stream.close();
247
330
  });
248
331
  }
@@ -255,10 +338,12 @@ module.exports = {
255
338
  cdpEndpoint,
256
339
  clampFps,
257
340
  describeSessions,
341
+ driveFeed,
258
342
  listSessions,
259
343
  openStream,
260
344
  processAlive,
261
345
  safePageAddress,
262
346
  serveStream,
347
+ sessionTargetId,
263
348
  socketDirs,
264
349
  };
package/core/cards.js CHANGED
@@ -83,6 +83,23 @@ function recordSentCard(rec) {
83
83
  return cards[String(rec.messageId)];
84
84
  }
85
85
 
86
+ // Cards with an Open Browser button the bot sent in this chat since `sinceMs` and that no hand
87
+ // back has answered yet: the bot asked for a person in its browser and waits. Each one found is
88
+ // marked answered, so one hand back wakes the bot once.
89
+ function takeBrowserRequests(channelId, sinceMs, now = Date.now()) {
90
+ if (!channelId) return [];
91
+ const cards = loadCards();
92
+ const found = Object.values(cards).filter((c) =>
93
+ c && c.openApp === "browser" && !c.handedBackAt &&
94
+ c.origin && String(c.origin.channelId) === String(channelId) &&
95
+ Date.parse(c.sentAt || 0) >= sinceMs);
96
+ if (!found.length) return [];
97
+ const at = new Date(now).toISOString();
98
+ for (const c of found) cards[c.messageId] = { ...c, handedBackAt: at };
99
+ saveCards(cards);
100
+ return found.map((c) => c.messageId);
101
+ }
102
+
86
103
  function getCard(messageId) {
87
104
  return loadCards()[String(messageId)] || null;
88
105
  }
@@ -647,6 +664,7 @@ module.exports = {
647
664
  startDecisionPump,
648
665
  DECISIONS_FILE,
649
666
  recordSentCard,
667
+ takeBrowserRequests,
650
668
  getCard,
651
669
  updateCardRecord,
652
670
  noteCardUpdate,
package/core/loopback.js CHANGED
@@ -215,7 +215,10 @@ async function handleCardKind(res, kind, payload, { channelId, adapterId, adapte
215
215
  }
216
216
  if (!messageId) return reply(res, 502, { error: "Kazee Chat returned no message id for the card" });
217
217
  const wake = ["each", "first", "none"].includes(payload.wake) ? payload.wake : cards.defaultWake(card);
218
- cards.recordSentCard({ messageId, title: card.title, kind: card.kind, wake, origin: cardOrigin(caller, payload, own, threadId) });
218
+ // openApp: a card with an Open <app> button (the bot asking for a person in its Browser):
219
+ // that person's hand back wakes the bot (core/browser-control.js).
220
+ const openApp = card.open && typeof card.open === "object" && typeof card.open.app === "string" ? card.open.app.slice(0, 40) : null;
221
+ cards.recordSentCard({ messageId, title: card.title, kind: card.kind, wake, origin: cardOrigin(caller, payload, own, threadId), ...(openApp ? { openApp } : {}) });
219
222
  return reply(res, 200, { ok: true, messageId, wake });
220
223
  }
221
224
 
@@ -987,6 +987,7 @@ function browserDriverDenial(command, now = Date.now()) {
987
987
  return {
988
988
  subcommand: String(blocked.sub || "").slice(0, 40),
989
989
  message: `${name} is driving the browser right now. Wait for them to hand it back; you will be woken in their chat when they do.`,
990
+ lock,
990
991
  };
991
992
  } catch (e) {
992
993
  return null;
@@ -1066,6 +1067,8 @@ function runHook(payload) {
1066
1067
  // subcommand is logged: the bot's own fill/type commands can carry text.
1067
1068
  const driving = browserDriverDenial(command);
1068
1069
  if (driving) {
1070
+ // The bot now waits for the hand back: it must be woken then (core/browser-control.js).
1071
+ try { require("./browser-lock").markWaiting(driving.lock); } catch (_) { /* best effort */ }
1069
1072
  logGuardEvent({ kind: "deny", rule: "browser-driver", subcommand: driving.subcommand });
1070
1073
  return { exit: 2, stderr: driving.message };
1071
1074
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.5.24",
3
+ "version": "3.5.25",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  "setup": "node setup.js",
12
12
  "start": "node bot.js",
13
13
  "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-codex-output-schema.js && node test-codex-device-login.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-upgrade-path.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-kazee-context-media.js && node test-kazee-typing-join.js && node test-kazee-inbound-dedup.js && node test-typing-phase-push.js && node test-socket-reconnect.js && node test-speech-loopback.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-group-status-notes.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-call-speech.js && node test-kazee-call-session.js",
14
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && node test-codex-rollout.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-transcript-room-scope.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-mandate-hygiene.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-cards.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-threads.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-thread-lanes.js && node test-thread-titles.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-thread-stop.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-stop-queue.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-typing-heartbeat.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-stream-preview-purpose.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-loopback-threads.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-transcript-threads.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-union.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-subst-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-section-budgets.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-dedup-adjudicator.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-prospective.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-regressions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cost-report.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-mining.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-certification.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-apprenticeship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-attest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-attest.js && node test-sandbox-probe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-bot-endpoint.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cli-owner-gates.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-verify-contact.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-loopback-owner-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-watch-check-env.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-intro-adoption.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-grants.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-room-provider-settings.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-store-durability.js && node test-kazee-cards.js && node test-decision-upkeep.js && node test-passive-replies.js && node test-kazee-bot-loops.js && node test-kazee-bot-handoffs.js && node test-placeholder-reply.js && node test-media-speech.js && node test-passive-replies-router.js && node test-loopback-cards.js && node test-task-injection.js && node test-speaker-extras.js && node test-token-diet.js && node test-dream-tasks.js && node test-task-archive.js && node test-cron-tolerance.js && node test-dream-schedule.js && node test-job-failures.js && node test-scheduled-placeholder.js && node test-lessons-since.js && node test-pack-reinject.js && node test-turn-journal.js && node test-usage-limits.js && node test-limit-waits.js && node test-limit-pickup.js && node test-browser-view.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-browser-view.js && node test-browser-address.js && node test-browser-control.js && node test-browser-teach.js && node test-kazee-bot-apps.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-browser-app.js && node test-safe-filename.js && node test-media-filename-paths.js && node test-router-document-guard.js && OPEN_CLAUDIA_TEST=1 node test-web-dashboard-escape.js && OPEN_CLAUDIA_TEST=1 node test-web-bad-requests.js && OPEN_CLAUDIA_TEST=1 node test-kazee-download-limit.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-auth-routes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-otp-link-preview.js"
14
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && node test-codex-rollout.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-transcript-room-scope.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-mandate-hygiene.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-cards.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-threads.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-thread-lanes.js && node test-thread-titles.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-thread-stop.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-stop-queue.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-typing-heartbeat.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-stream-preview-purpose.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-loopback-threads.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-transcript-threads.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-union.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-subst-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-section-budgets.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-dedup-adjudicator.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-prospective.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-regressions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cost-report.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-mining.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-certification.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-apprenticeship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-attest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-attest.js && node test-sandbox-probe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-bot-endpoint.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cli-owner-gates.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-verify-contact.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-loopback-owner-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-watch-check-env.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-intro-adoption.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-grants.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-room-provider-settings.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-store-durability.js && node test-kazee-cards.js && node test-decision-upkeep.js && node test-passive-replies.js && node test-kazee-bot-loops.js && node test-kazee-bot-handoffs.js && node test-placeholder-reply.js && node test-media-speech.js && node test-passive-replies-router.js && node test-loopback-cards.js && node test-task-injection.js && node test-speaker-extras.js && node test-token-diet.js && node test-dream-tasks.js && node test-task-archive.js && node test-cron-tolerance.js && node test-dream-schedule.js && node test-job-failures.js && node test-scheduled-placeholder.js && node test-lessons-since.js && node test-pack-reinject.js && node test-turn-journal.js && node test-usage-limits.js && node test-limit-waits.js && node test-limit-pickup.js && node test-browser-view.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-browser-view.js && node test-browser-address.js && node test-browser-control.js && node test-browser-drive.js && node test-browser-teach.js && node test-kazee-bot-apps.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-browser-app.js && node test-safe-filename.js && node test-media-filename-paths.js && node test-router-document-guard.js && OPEN_CLAUDIA_TEST=1 node test-web-dashboard-escape.js && OPEN_CLAUDIA_TEST=1 node test-web-bad-requests.js && OPEN_CLAUDIA_TEST=1 node test-kazee-download-limit.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-auth-routes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test node test-web-otp-link-preview.js"
15
15
  },
16
16
  "files": [
17
17
  "bot.js",
@@ -610,7 +610,12 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
610
610
  // app's address bar, agent-browser's settings for the agent, and container mode for Agent
611
611
  // Space's browser container. A canary build for the Browser canary bot; the fleet waits
612
612
  // for his OK.
613
- assert.strictEqual(pkg.version, "3.5.24", "release version must remain unchanged without explicit approval");
613
+ // 3.5.25 approved under Sumeet's 2026-10-10 09:18 EAT go-ahead for the phone and web view
614
+ // ("Yeah, but remember the web and mobile still need to also feel native"), after 08:53
615
+ // ("it feels like a browser on my phone… like Google Chrome app"): the bot's tab takes
616
+ // the driver's screen, input straight to Chrome, the page's text fields, hand backs wake
617
+ // only a waiting bot. A canary build for the Browser canary bot; the fleet waits for his OK.
618
+ assert.strictEqual(pkg.version, "3.5.25", "release version must remain unchanged without explicit approval");
614
619
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
615
620
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
616
621
  }