@inetafrica/open-claudia 3.5.24 → 3.5.26

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.5.26
4
+
5
+ The Browser app's phone and web view stays sharp after a page load. A canary build for the Browser canary bot first; the fleet waits for Sumeet's OK.
6
+
7
+ - **Sharp after every page.** On the canary's Chrome 150 (Linux) the driver's pictures dropped from the view's density to 1x at the first page load (the view kept its density; the picture did not). After a main-frame navigation or a load, the window is now resized by a pixel and back, which brings the density back: 906x1760 pictures for a 412x800 phone at 2.2x on duckduckgo.com, en.m.wikipedia.org and example.com, 1 to 4 s after each load.
8
+ - **Tests:** `test-browser-drive.js`.
9
+
10
+ ## 3.5.25
11
+
12
+ 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.
13
+
14
+ - **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`.
15
+ - **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.
16
+ - **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.
17
+ - **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.
18
+ - **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.
19
+ - **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.
20
+ - **Tests:** `test-browser-drive.js` (new), `test-browser-control.js`, `test-web-browser-app.js`.
21
+
3
22
  ## 3.5.24
4
23
 
5
24
  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,