@inetafrica/open-claudia 3.5.27 → 3.5.28

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.
@@ -13,18 +13,30 @@
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.
20
+ // - always on: a person who opens the Browser app while the bot's browser is not open gets it
21
+ // opened on a start page, in the bot's own default session (the one its `agent-browser open`
22
+ // uses), with the bot's own agent-browser environment, so the bot carries on in that tab.
16
23
  // - 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.
24
+ // goes, the view and the socket close, any recording stops, and the bot is woken in the
25
+ // person's own chat (C6), but only when it waits for them: it sent them an Open Browser card,
26
+ // or one of its browser commands was refused while they drove. A recording stopped at the
27
+ // same time folds into one wake-up. Otherwise the hand back is only logged.
19
28
  //
20
29
  // Every route answers { status, body }; web.js writes it. Agent Space reaches these routes with
21
30
  // the control token only.
22
31
 
32
+ const os = require("os");
33
+ const path = require("path");
23
34
  const WebSocket = require("ws");
24
35
  const lockFile = require("./browser-lock");
25
36
  const view = require("./browser-view");
26
37
  const teach = require("./browser-teach");
27
38
  const address = require("./browser-address");
39
+ const drive = require("./browser-drive");
28
40
  const { runAgentBrowser } = require("./agent-browser-cli");
29
41
 
30
42
  const LOCK_TTL_MS = lockFile.LOCK_TTL_MS;
@@ -41,6 +53,13 @@ const ID_RE = /^[A-Za-z0-9._:-]{1,100}$/;
41
53
  // An address-bar command still running after this is killed, and the person told it failed.
42
54
  const NAVIGATE_TIMEOUT_MS = 20 * 1000;
43
55
  const NAVIGATIONS = new Set(["open", "back", "forward", "reload"]);
56
+ // Always on: the page a browser opened for a person starts on, and the session it opens (the one
57
+ // the bot's own `agent-browser` commands use when they name none).
58
+ const START_PAGE = "https://www.google.com/";
59
+ const START_SESSION = "default";
60
+
61
+ // An Open Browser card counts as a request for a person this long.
62
+ const REQUEST_MAX_AGE_MS = 24 * 60 * 60 * 1000;
44
63
 
45
64
  const HAND_BACK_REASONS = {
46
65
  closed: " (they closed the Browser)",
@@ -107,6 +126,7 @@ const MOUSE_EVENTS = new Set(["mousePressed", "mouseReleased", "mouseMoved", "mo
107
126
  const MOUSE_BUTTONS = new Set(["none", "left", "middle", "right"]);
108
127
  const KEY_EVENTS = new Set(["keyDown", "keyUp", "char"]);
109
128
  const TOUCH_EVENTS = new Set(["touchStart", "touchMove", "touchEnd", "touchCancel"]);
129
+ const TEXT_MAX = drive.TEXT_MAX;
110
130
 
111
131
  const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
112
132
  const inRange = (v, lo, hi) => isNumber(v) && v >= lo && v <= hi;
@@ -163,11 +183,34 @@ function checkEvent(ev) {
163
183
  }
164
184
  const out = { type: "input_touch", eventType: ev.eventType, touchPoints };
165
185
  if (!optional(out, "modifiers", modifiers)) return null;
186
+ // When the finger moved, on the driver's device (ms): keeps a swipe's speed (core/browser-drive.js).
187
+ if (!optional(out, "t", (v) => inRange(v, 0, 1e13))) return null;
166
188
  return out;
167
189
  }
190
+ // Text the driver typed (their phone keyboard, a paste), inserted where the page's caret is.
191
+ if (ev.type === "input_text") {
192
+ if (typeof ev.text !== "string" || !ev.text || Array.from(ev.text).length > TEXT_MAX) return null;
193
+ return { type: "input_text", text: ev.text };
194
+ }
168
195
  return null;
169
196
  }
170
197
 
198
+ // input_text as key presses, for a bot without the driver's view (agent-browser's socket takes
199
+ // no text) and for Teach me, which counts typing from key presses.
200
+ function textAsKeys(text) {
201
+ const out = [];
202
+ for (const ch of Array.from(text)) {
203
+ if (ch === "\n" || ch === "\r") {
204
+ out.push({ type: "input_keyboard", eventType: "keyDown", key: "Enter", code: "Enter", text: "\r", windowsVirtualKeyCode: 13 });
205
+ out.push({ type: "input_keyboard", eventType: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 });
206
+ } else {
207
+ out.push({ type: "input_keyboard", eventType: "keyDown", key: ch, text: ch });
208
+ out.push({ type: "input_keyboard", eventType: "keyUp", key: ch });
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+
171
214
  // ---------------------------------------------------------------------------
172
215
  // Input sockets: one per agent-browser session, opened on take (or on the first input after a
173
216
  // restart), closed on hand back.
@@ -325,7 +368,23 @@ function wakeChat({ chatId, userId }, prompt, label) {
325
368
 
326
369
  function handBackLine(name, reason) {
327
370
  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.`;
371
+ 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.`;
372
+ }
373
+
374
+ // Was the bot waiting for this person when they handed back? It asked for them with an Open
375
+ // Browser card in their chat (since the day before they took over), or one of its own browser
376
+ // commands was refused while they drove.
377
+ function botWasWaiting(lock) {
378
+ let asked = [];
379
+ try {
380
+ const since = Date.parse(lock.since);
381
+ asked = require("./cards").takeBrowserRequests(lock.chatId, (Number.isFinite(since) ? since : deps.now()) - REQUEST_MAX_AGE_MS, deps.now());
382
+ } catch (e) {
383
+ console.error(`[browser] reading the bot's Open Browser cards failed: ${e.message}`);
384
+ }
385
+ const refused = lockFile.waitedDuring(lock);
386
+ lockFile.clearWaiting();
387
+ return asked.length > 0 || refused;
329
388
  }
330
389
 
331
390
  // ---------------------------------------------------------------------------
@@ -335,7 +394,39 @@ function lockBody(lock) {
335
394
  return { driving: true, driver: { name: lock.name } };
336
395
  }
337
396
 
338
- async function take({ session, who } = {}) {
397
+ // The driver's view on this lock's session: start it (or move it to a new screen) and remember it
398
+ // in the lock, so a restarted bot can bring it back. -> the fit, or null (the browser keeps its size).
399
+ async function startView(lock, viewport) {
400
+ if (!viewport || !drive.supported()) return null;
401
+ const info = sessionInfo(lock.session);
402
+ if (!info) return null;
403
+ const r = drive.active(lock.session)
404
+ ? await drive.fit(viewport)
405
+ : await drive.start({ session: info, viewport, savedBounds: lock.bounds || null });
406
+ const now = lockFile.readLock();
407
+ // The lock changed hands (or was handed back) while the view started: undo it.
408
+ if (!now || now.since !== lock.since || now.userId !== lock.userId) {
409
+ if (r.ok && drive.active(lock.session)) await drive.stop();
410
+ return null;
411
+ }
412
+ if (!r.ok) return null;
413
+ now.fit = r.fit;
414
+ if (r.bounds) now.bounds = r.bounds;
415
+ lockFile.writeLock(now);
416
+ return r.fit;
417
+ }
418
+
419
+ // After a bot restart the lock remembers a view that is no longer live: bring it back. Once at a
420
+ // time, in the background.
421
+ let reviving = null;
422
+ function reviveView(lock) {
423
+ if (!lock || !lock.fit || reviving || drive.active(lock.session) || !drive.supported()) return;
424
+ reviving = startView(lock, lock.fit)
425
+ .catch((e) => console.error(`[browser] bringing the driver's view back failed: ${e.message}`))
426
+ .finally(() => { reviving = null; });
427
+ }
428
+
429
+ async function take({ session, who, viewport } = {}) {
339
430
  if (!validSession(session) || !validWho(who, { needChat: true })) return reply(400, { error: "bad-request" });
340
431
  // A lock that ran out but has not been swept yet is handed back first, the sweeper's way: its
341
432
  // driver's chat is woken and their recording stopped, whoever takes over now.
@@ -354,9 +445,25 @@ async function take({ session, who } = {}) {
354
445
  since: current && current.session === session ? current.since : iso(now),
355
446
  expiresAt: iso(now + LOCK_TTL_MS),
356
447
  };
448
+ // The same person taking again (a reloaded page) keeps the window's size from before their view.
449
+ if (current && current.session === session && sameUser(current, who) && current.bounds) lock.bounds = current.bounds;
357
450
  lockFile.writeLock(lock);
358
451
  openInputSocket(session);
359
- return reply(200, lockBody(lock));
452
+ if (!viewport) return reply(200, lockBody(lock));
453
+ // fit: the driver's view now live, or null (this bot's browser keeps its own size).
454
+ const fitted = await startView(lock, viewport);
455
+ return reply(200, { ...lockBody(lock), fit: fitted });
456
+ }
457
+
458
+ // The driver's screen changed (a turned phone, a resized panel): { session, who, viewport }.
459
+ async function fitView({ session, who, viewport } = {}) {
460
+ if (!validSession(session) || !validWho(who)) return reply(400, { error: "bad-request" });
461
+ if (!drive.checkViewport(viewport)) return reply(400, { error: "bad-viewport" });
462
+ const lock = extendDriverLock(session, who);
463
+ if (!lock) return reply(409, { driving: false, error: "not-driving" });
464
+ if (!drive.supported()) return reply(200, { ...lockBody(lock), fit: null });
465
+ const fitted = await startView(lock, viewport);
466
+ return reply(200, { ...lockBody(lock), fit: fitted });
360
467
  }
361
468
 
362
469
  function renew({ session, who } = {}) {
@@ -369,7 +476,8 @@ function renew({ session, who } = {}) {
369
476
  current.expiresAt = iso(now + LOCK_TTL_MS);
370
477
  lockFile.writeLock(current);
371
478
  openInputSocket(current.session); // after a restart the socket comes back here
372
- return reply(200, lockBody(current));
479
+ reviveView(current); // and the driver's view
480
+ return reply(200, current.fit ? { ...lockBody(current), fit: drive.active(current.session) ? current.fit : null } : lockBody(current));
373
481
  }
374
482
 
375
483
  // Hand the browser back: claim the lock (synchronously, so a release and the sweeper never both
@@ -383,6 +491,13 @@ async function handBack(expected, { reason = null } = {}) {
383
491
  let stopped = null;
384
492
  await runQueued(current.session, 0, async () => {
385
493
  closeInputSocket(current.session);
494
+ // The bot's tab goes back to its own size before the bot carries on.
495
+ try {
496
+ if (drive.active(current.session)) await drive.stop();
497
+ else if (current.bounds) await drive.restoreWindow(info, current.bounds);
498
+ } catch (e) {
499
+ console.error(`[browser] ending the driver's view failed: ${e.message}`);
500
+ }
386
501
  try {
387
502
  stopped = await teach.stopRecording(current.userId, { session: info });
388
503
  } catch (e) {
@@ -390,9 +505,10 @@ async function handBack(expected, { reason = null } = {}) {
390
505
  }
391
506
  });
392
507
  const line = handBackLine(current.name, reason);
508
+ const waited = botWasWaiting(current);
393
509
  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})` : ""}`);
510
+ else if (waited) wakeChat(current, line, "Browser");
511
+ 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
512
  return true;
397
513
  }
398
514
 
@@ -409,10 +525,13 @@ async function control(body = {}) {
409
525
  if (body.action === "take") return take(body);
410
526
  if (body.action === "renew") return renew(body);
411
527
  if (body.action === "release") return release(body);
528
+ if (body.action === "fit") return fitView(body);
412
529
  return reply(400, { error: "bad-action" });
413
530
  }
414
531
 
415
- // navigate: true tells the front door this bot has the address bar (POST /api/browser/navigate).
532
+ // navigate: true tells the front door this bot has the address bar (POST /api/browser/navigate);
533
+ // fit: true that a take can bring the driver's view (core/browser-drive.js) and input_text works;
534
+ // start: true that it opens its browser on a start page when there is none (POST /api/browser/start).
416
535
  function status() {
417
536
  const lock = lockFile.liveLock(deps.now());
418
537
  return reply(200, {
@@ -420,9 +539,50 @@ function status() {
420
539
  driver: lock ? { name: lock.name, userId: lock.userId } : null,
421
540
  recording: teach.activeSummary(),
422
541
  navigate: true,
542
+ fit: drive.supported(),
543
+ start: true,
423
544
  });
424
545
  }
425
546
 
547
+ // ---------------------------------------------------------------------------
548
+ // Always on: POST /api/browser/start
549
+
550
+ // The socket folder the bot's own agent-browser commands use (its agent runs without
551
+ // XDG_RUNTIME_DIR): AGENT_BROWSER_SOCKET_DIR, else ~/.agent-browser.
552
+ function agentSocketDir(env = process.env, home = os.homedir()) {
553
+ if (env.AGENT_BROWSER_SOCKET_DIR) return env.AGENT_BROWSER_SOCKET_DIR;
554
+ return home ? path.join(home, ".agent-browser") : null;
555
+ }
556
+
557
+ let starting = null;
558
+
559
+ // { who: { userId } }. A browser already open is answered as it is (the newest session); otherwise
560
+ // the bot's default session opens on the start page, as the bot's own `agent-browser open` would,
561
+ // and two people opening the app at once start one browser. { session, started }.
562
+ async function start({ who } = {}) {
563
+ if (!validWho(who)) return reply(400, { error: "bad-request" });
564
+ const open = deps.listSessions();
565
+ if (open.length) return reply(200, { session: open[0].name, started: false });
566
+ if (!starting) {
567
+ starting = (async () => {
568
+ const dir = agentSocketDir();
569
+ const run = await runAgentBrowser({ name: START_SESSION, dir }, ["open", START_PAGE], {
570
+ timeoutMs: deps.navigateTimeoutMs,
571
+ execFile: deps.execFile || undefined,
572
+ });
573
+ const listed = run.ok ? deps.listSessions().find((s) => s.name === START_SESSION) : null;
574
+ if (!listed) {
575
+ const why = run.timedOut ? `agent-browser took longer than ${Math.ceil(deps.navigateTimeoutMs / 1000)} s and was stopped` : run.ok ? "the browser did not come up" : "agent-browser failed";
576
+ console.error(`[browser] could not open the browser for ${who.userId}: ${why}`);
577
+ return reply(503, { error: "browser-unavailable" });
578
+ }
579
+ console.log(`[browser] opened the browser on the start page for ${who.userId}`);
580
+ return reply(200, { session: listed.name, started: true });
581
+ })().finally(() => { starting = null; });
582
+ }
583
+ return starting;
584
+ }
585
+
426
586
  // ---------------------------------------------------------------------------
427
587
  // C3: input
428
588
 
@@ -436,24 +596,41 @@ async function input({ session, events, who } = {}) {
436
596
  messages.push(msg);
437
597
  }
438
598
  // Every accepted input extends the lock.
439
- if (!extendDriverLock(session, who)) return reply(409, { error: "not-driving" });
599
+ const lock = extendDriverLock(session, who);
600
+ if (!lock) return reply(409, { error: "not-driving" });
440
601
  if (!messages.length) return reply(200, { ok: true, accepted: 0 });
602
+ if (lock.fit && !drive.active(session)) reviveView(lock);
441
603
  const entry = openInputSocket(session);
442
- if (!entry) return reply(404, { error: "not-running" });
604
+ if (!entry && !drive.active(session)) return reply(404, { error: "not-running" });
443
605
  const job = runQueued(session, messages.length, async () => {
444
- if (!(await socketReady(entry))) return reply(503, { error: "browser-unavailable", accepted: 0 });
445
606
  const recorder = teach.recorderFor(session, who.userId);
446
- let sent = 0;
447
- for (const msg of messages) {
448
- if (recorder) {
607
+ const record = async (msg, frame, info) => {
608
+ if (!recorder) return;
609
+ // Teach me counts typing from key presses: typed text is shown to it as keys.
610
+ for (const ev of msg.type === "input_text" ? textAsKeys(msg.text) : [msg]) {
449
611
  try {
450
- await recorder.onEvent(msg, { session: { name: entry.name, dir: entry.dir }, frame: entry.frame });
612
+ await recorder.onEvent(ev, { session: info, frame });
451
613
  } catch (e) {
452
614
  console.error(`[browser] recording a step failed: ${e.message}`);
453
615
  }
454
616
  }
617
+ };
618
+ // The driver's view: straight to Chrome, in order, without waiting for each answer.
619
+ if (drive.active(session)) {
620
+ const info = sessionInfo(session) || { name: session, dir: entry ? entry.dir : null };
621
+ for (const msg of messages) {
622
+ await record(msg, drive.lastFrame(), { name: info.name, dir: info.dir });
623
+ if (drive.dispatch([msg]) < 0) return reply(503, { error: "browser-unavailable" });
624
+ }
625
+ return reply(200, { ok: true, accepted: messages.length });
626
+ }
627
+ if (!entry || !(await socketReady(entry))) return reply(503, { error: "browser-unavailable", accepted: 0 });
628
+ let sent = 0;
629
+ for (const msg of messages) {
630
+ await record(msg, entry.frame, { name: entry.name, dir: entry.dir });
455
631
  if (!entry.open || entry.closed) return reply(503, { error: "browser-unavailable", accepted: sent });
456
- entry.ws.send(JSON.stringify(msg));
632
+ // agent-browser's socket takes no text: typed text goes as key presses.
633
+ for (const ev of msg.type === "input_text" ? textAsKeys(msg.text) : [msg]) entry.ws.send(JSON.stringify(ev));
457
634
  sent += 1;
458
635
  }
459
636
  return reply(200, { ok: true, accepted: sent });
@@ -602,6 +779,8 @@ function _reset() {
602
779
  for (const name of [...sockets.keys()]) closeInputSocket(name);
603
780
  queues.clear();
604
781
  if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
782
+ reviving = null;
783
+ drive._reset();
605
784
  deps = defaultDeps();
606
785
  }
607
786
 
@@ -609,15 +788,20 @@ module.exports = {
609
788
  LOCK_TTL_MS,
610
789
  MAX_EVENTS,
611
790
  NAVIGATE_TIMEOUT_MS,
791
+ START_PAGE,
792
+ START_SESSION,
612
793
  SWEEP_INTERVAL_MS,
794
+ agentSocketDir,
613
795
  checkEvent,
614
796
  chatOrigin,
615
797
  control,
798
+ fitView,
616
799
  handBackLine,
617
800
  input,
618
801
  navigate,
619
802
  release,
620
803
  renew,
804
+ start,
621
805
  startSweeper,
622
806
  status,
623
807
  sweepLock,