symbiot 0.63.5 → 0.63.7

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/headless.mjs CHANGED
@@ -21,7 +21,8 @@
21
21
  // signed in. It's separate from your everyday browser profile.
22
22
  import { spawn, spawnSync } from "node:child_process";
23
23
  import { join, resolve as resolvePath } from "node:path";
24
- import { mkdirSync, existsSync, statSync, readlinkSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { mkdirSync, existsSync, statSync, readlinkSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
25
+ import { tmpdir } from "node:os";
25
26
  import { CONFIG_DIR, chromeBinary, loadConfig, saveConfig } from "./core.mjs";
26
27
  import { loadScreens, addPageScreen, center, stitchPng, stitchListPng } from "./screens.mjs";
27
28
 
@@ -150,30 +151,56 @@ async function closeOthers(c, keep) {
150
151
  let queue = Promise.resolve();
151
152
  const oneAtATime = (fn) => { const run = queue.then(fn, fn); queue = run.catch(() => {}); return run; };
152
153
 
153
- // The hidden browser, while it's open: { proc, c, page, shown, timer }. shown is
154
- // the id of the screen last mapped from its page, which is what the page shows
155
- // now. keepBrowserOpen(ms) keeps it open that long after each action (the app
156
- // does); with 0, a one-off `symbiot screens` command, it closes after each one.
154
+ // The hidden browser, while it's open: { proc, c, tabs, timer }. Each agent has a tab
155
+ // of its own in it (tabs: who → { page, shown, used }), so two lanes driving it at once
156
+ // (LinkedIn and the Twilio console, 2026-10-12) don't land on each other's page, and a
157
+ // post half-written in one keeps its text and picture between that agent's steps.
158
+ // shown is the id of the screen last mapped from that tab, which is what it shows now.
159
+ // keepBrowserOpen(ms) keeps it open that long after each action (the app does); with 0,
160
+ // a one-off `symbiot screens` command, it closes after each one.
157
161
  let live = null, KEEP = 0;
162
+ const MAIN = "symbiot", MAX_TABS = 6;
163
+ // Whose tab: the agent's run folder (SYMBIOT_AGENT, else where the command ran), or Symbiot's own.
164
+ const tabKey = (who) => String(who || "").trim().slice(0, 300) || MAIN;
158
165
  function keepBrowserOpen(ms) { KEEP = Math.max(0, Number(ms) || 0); if (!KEEP && live && !live.busy) closeBrowser(); }
159
166
  const browserOpen = () => !!(live && !live.c.closed());
160
167
  // Don't leave it running when Symbiot exits (a restart, Ctrl+C).
161
168
  process.once("exit", () => { if (live && live.proc.exitCode === null) try { live.proc.kill("SIGKILL"); } catch {} });
162
169
 
163
- // Is a visible Chrome (the Symbiot Browser) using Symbiot's profile? Its SingletonLock names the process
164
- // ("host-1234", Linux and Mac); a lock whose process has gone is a leftover.
170
+ // The Chrome holding Symbiot's profile: its SingletonLock names the process ("host-1234",
171
+ // Linux and Mac), and its own args say whether it's hidden. { pid, headless }, or null
172
+ // when none is (no lock, or a leftover one whose process has gone).
165
173
  const OWN = new Set(); // every hidden Chrome this Symbiot started
166
- function windowOpen() {
167
- try {
168
- const m = /-(\d+)$/.exec(readlinkSync(join(PROFILE, "SingletonLock")));
169
- if (!m) return false;
170
- if (OWN.has(Number(m[1]))) return false; // our own hidden browser (running, or still closing) isn't a window
171
- process.kill(Number(m[1]), 0);
172
- return true;
173
- } catch { return false; }
174
- }
175
- // Start the hidden browser and open a tab: { proc, c, page: { send, until, idle } }.
176
- async function launch() {
174
+ function argsOf(pid) {
175
+ try { return readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").join(" "); } catch {}
176
+ try { return spawnSync("ps", ["-ww", "-o", "args=", "-p", String(pid)], { encoding: "utf8", timeout: 5000 }).stdout || ""; } catch { return ""; }
177
+ }
178
+ function lockHolder() {
179
+ let pid = 0; try { pid = Number((/-(\d+)$/.exec(readlinkSync(join(PROFILE, "SingletonLock"))) || [])[1]) || 0; } catch {}
180
+ if (!pid) return null;
181
+ try { process.kill(pid, 0); } catch { return null; }
182
+ return { pid, headless: /--headless/.test(argsOf(pid)) };
183
+ }
184
+ // Is a visible Chrome (the Symbiot Browser) using Symbiot's profile? Not our own hidden
185
+ // browser (running, or still closing), and not a hidden one at all: one that isn't ours
186
+ // is a stray (strayGone). Taking any hidden Chrome for a window blocked every map and
187
+ // press while the app ran, with "click Done" in a window that didn't exist (2026-10-12).
188
+ function windowOpen() { const h = lockHolder(); return !!h && !h.headless && !OWN.has(h.pid); }
189
+ // Another Symbiot's hidden browser on the profile (a one-off `symbiot screens`, or an app
190
+ // from before an update that hasn't gone): it closes after its action, so it's given a
191
+ // moment; one still there after that is a stray, and is closed. Gives once it's gone.
192
+ const STRAY_WAIT = Number(process.env.SYMBIOT_STRAY_WAIT) || 10000;
193
+ async function strayGone(wait = STRAY_WAIT) {
194
+ const stray = () => { const h = lockHolder(); return h && h.headless && !OWN.has(h.pid) ? h.pid : 0; };
195
+ for (const end = Date.now() + wait; stray() && Date.now() < end;) await sleep(250);
196
+ const pid = stray(); if (!pid) return;
197
+ try { process.kill(pid, "SIGTERM"); } catch {}
198
+ for (let i = 0; i < 20 && stray() === pid; i++) await sleep(250);
199
+ if (stray() === pid) try { process.kill(pid, "SIGKILL"); } catch {}
200
+ for (let i = 0; i < 8 && stray() === pid; i++) await sleep(250);
201
+ }
202
+ // Start the hidden browser, with a tab for who: { proc, c, tabs }.
203
+ async function launch(who = MAIN) {
177
204
  const chrome = chromeBinary();
178
205
  if (!chrome) throw new Error(process.platform === "android" || process.env.SYMBIOT_ANDROID_APP === "1"
179
206
  ? "Mapping a page needs a desktop browser (Chrome, Chromium, Edge or Brave), which a phone doesn't have. Map pages from Symbiot on your computer."
@@ -182,11 +209,12 @@ async function launch() {
182
209
  // the Symbiot Browser (or a sign-in window) has the profile: a second Chrome would hand
183
210
  // over to it and pop an empty window into it, so wait for Done instead
184
211
  if (windowOpen()) throw new Error("The Symbiot Browser is open: Symbiot reads your sites once you click Done in it.");
212
+ await strayGone();
185
213
  const proc = spawn(chrome, browserArgs(true), { stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"] });
186
214
  if (proc.pid) OWN.add(proc.pid);
187
215
  let err = ""; proc.stderr.on("data", (d) => { err = (err + d).slice(-2000); });
188
216
  const failed = new Promise((resolve, reject) => { proc.on("error", reject); }); failed.catch(() => {});
189
- const c = connect(proc), b = { proc, c, shown: "", timer: null };
217
+ const c = connect(proc), b = { proc, c, tabs: new Map(), timer: null };
190
218
  const kill = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, START);
191
219
  try {
192
220
  const run = (async () => {
@@ -198,41 +226,57 @@ async function launch() {
198
226
  throw new Error("The browser didn't start: " + ((err.trim().split("\n").pop()) || e.message));
199
227
  }
200
228
  await closeOthers(c, targetId); await keepSessions(c);
201
- const { sessionId } = await c.send("Target.attachToTarget", { targetId, flatten: true });
202
- const send = (method, params) => c.send(method, params, sessionId);
203
- const until = (method, ms) => c.until((m) => m.sessionId === sessionId && m.method === method, ms);
204
- // Requests in flight, so "loaded" can wait for a page that fills itself in.
205
- const inflight = new Set();
206
- c.on((m) => {
207
- if (!m || m.sessionId !== sessionId) return;
208
- if (m.method === "Network.requestWillBeSent") inflight.add(m.params.requestId);
209
- else if (m.method === "Network.loadingFinished" || m.method === "Network.loadingFailed") inflight.delete(m.params.requestId);
210
- });
211
- // Quiet: no more than one request (a long poll, say) open for 600 ms, or 8 s at most.
212
- const idle = async () => {
213
- const end = Date.now() + 8000; let calm = 0;
214
- await sleep(400);
215
- while (Date.now() < end && calm < 600) { await sleep(100); calm = inflight.size <= 1 ? calm + 100 : 0; }
216
- };
217
- await send("Page.enable"); await send("Network.enable");
218
- const ss = loadSessions(); if (Object.keys(ss).length) try { await send("Page.addScriptToEvaluateOnNewDocument", { source: sessionFill(ss), worldName: "symbiot-sessions" }); } catch {}
219
- await send("Emulation.setDeviceMetricsOverride", { width: VIEW.w, height: VIEW.h, deviceScaleFactor: 1, mobile: false });
220
- // Sites serve "HeadlessChrome" something else (or a block page): look like the browser it is.
221
- const { userAgent } = await c.send("Browser.getVersion");
222
- if (userAgent) await send("Network.setUserAgentOverride", { userAgent: userAgent.replace(/HeadlessChrome/g, "Chrome") });
223
- // every `method` event on this tab, until the function it gives is called
224
- const on = (method, f) => c.on((m) => { if (m && m.sessionId === sessionId && m.method === method) f(m.params || {}); });
225
- b.page = { send, until, idle, on };
229
+ await openTab(b, who, targetId);
226
230
  return b;
227
231
  })();
228
232
  return await Promise.race([run, failed]);
229
233
  } catch (e) { await shut(b); throw e; }
230
234
  finally { clearTimeout(kill); }
231
235
  }
236
+ // A tab of its own for who (a new one, or targetId's): { who, targetId, page: { send, until,
237
+ // idle, on }, shown, used }. Past MAX_TABS, the one used longest ago is closed first.
238
+ async function openTab(b, who, targetId) {
239
+ const c = b.c;
240
+ if (b.tabs.size >= MAX_TABS) { const old = [...b.tabs.values()].sort((x, y) => x.used - y.used)[0]; if (old) await closeTab(b, old); }
241
+ if (!targetId) ({ targetId } = await c.send("Target.createTarget", { url: "about:blank" }));
242
+ const { sessionId } = await c.send("Target.attachToTarget", { targetId, flatten: true });
243
+ const send = (method, params) => c.send(method, params, sessionId);
244
+ const until = (method, ms) => c.until((m) => m.sessionId === sessionId && m.method === method, ms);
245
+ // Requests in flight, so "loaded" can wait for a page that fills itself in.
246
+ const inflight = new Set();
247
+ const unhook = c.on((m) => {
248
+ if (!m || m.sessionId !== sessionId) return;
249
+ if (m.method === "Network.requestWillBeSent") inflight.add(m.params.requestId);
250
+ else if (m.method === "Network.loadingFinished" || m.method === "Network.loadingFailed") inflight.delete(m.params.requestId);
251
+ });
252
+ // Quiet: no more than one request (a long poll, say) open for 600 ms, or 8 s at most.
253
+ const idle = async () => {
254
+ const end = Date.now() + 8000; let calm = 0;
255
+ await sleep(400);
256
+ while (Date.now() < end && calm < 600) { await sleep(100); calm = inflight.size <= 1 ? calm + 100 : 0; }
257
+ };
258
+ await send("Page.enable"); await send("Network.enable");
259
+ const ss = loadSessions(); if (Object.keys(ss).length) try { await send("Page.addScriptToEvaluateOnNewDocument", { source: sessionFill(ss), worldName: "symbiot-sessions" }); } catch {}
260
+ await send("Emulation.setDeviceMetricsOverride", { width: VIEW.w, height: VIEW.h, deviceScaleFactor: 1, mobile: false });
261
+ // Sites serve "HeadlessChrome" something else (or a block page): look like the browser it is.
262
+ const { userAgent } = await c.send("Browser.getVersion");
263
+ if (userAgent) await send("Network.setUserAgentOverride", { userAgent: userAgent.replace(/HeadlessChrome/g, "Chrome") });
264
+ // every `method` event on this tab, until the function it gives is called
265
+ const on = (method, f) => c.on((m) => { if (m && m.sessionId === sessionId && m.method === method) f(m.params || {}); });
266
+ const tab = { who, targetId, page: { send, until, idle, on }, shown: "", used: Date.now(), unhook };
267
+ b.tabs.set(who, tab);
268
+ return tab;
269
+ }
270
+ async function closeTab(b, tab) {
271
+ if (b.tabs.get(tab.who) === tab) b.tabs.delete(tab.who);
272
+ if (tab.unhook) tab.unhook();
273
+ try { await Promise.race([b.c.send("Target.closeTarget", { targetId: tab.targetId }), sleep(3000)]); } catch {}
274
+ }
232
275
  async function shut(b) {
233
276
  if (!b) return;
234
277
  clearTimeout(b.timer); if (live === b) live = null;
235
- if (!b.c.closed()) { try { if (b.page) { const own = await Promise.race([readSession(b.page.send), sleep(2500)]); if (own) saveSessions([own]); } await Promise.race([keepSessions(b.c), sleep(3000)]); await Promise.race([b.c.send("Browser.close"), sleep(3000)]); } catch {} }
278
+ const any = b.tabs && [...b.tabs.values()][0];
279
+ if (!b.c.closed()) { try { if (any) { const own = await Promise.race([readSession(any.page.send), sleep(2500)]); if (own) saveSessions([own]); } await Promise.race([keepSessions(b.c), sleep(3000)]); await Promise.race([b.c.send("Browser.close"), sleep(3000)]); } catch {} }
236
280
  // Chrome writes its cookies to disk as it shuts down: killed before that, a sign-in made
237
281
  // or kept since it started is lost. Give it a few seconds to finish.
238
282
  if (b.proc.exitCode === null) await Promise.race([new Promise((r) => b.proc.once("exit", r)), sleep(5000)]);
@@ -241,21 +285,27 @@ async function shut(b) {
241
285
  // Close the hidden browser now (the Sign in window needs its profile).
242
286
  function closeBrowser() { return shut(live); }
243
287
 
244
- // Give fn the hidden browser's page (the open one, else a new one), and what it
245
- // shows (b.shown). After: kept open for KEEP ms, or closed; closed on an error,
246
- // so the next action starts afresh.
247
- async function withPage(fn, keep = true) {
288
+ // Give fn who's tab in the hidden browser (the open one, else a new one), and what it
289
+ // shows (tab.shown). After: kept open for KEEP ms, or closed. On an error that tab is
290
+ // closed, so that agent's next action starts afresh, and the other agents' tabs stay
291
+ // as they are; the whole browser only when it's gone.
292
+ async function withPage(fn, keep = true, who = MAIN) {
248
293
  if (live && live.c.closed()) await shut(live);
294
+ who = tabKey(who);
249
295
  let b = live;
250
- if (b) clearTimeout(b.timer); else b = live = await launch();
296
+ if (b) clearTimeout(b.timer); else b = live = await launch(who);
251
297
  b.busy = true;
252
298
  const kill = setTimeout(() => { try { b.proc.kill("SIGKILL"); } catch {} }, TIMEOUT);
299
+ let tab = null;
253
300
  try {
254
- const r = await fn(b.page, b);
255
- // stay: nothing changed on the page, so it shows what fn left in b.shown
256
- if (r && r.stay) delete r.stay; else b.shown = r && r.id && !r.error ? r.id : "";
301
+ tab = b.tabs.get(who) || await openTab(b, who);
302
+ tab.used = Date.now();
303
+ try { await tab.page.send("Page.bringToFront"); } catch {} // a tab behind another doesn't paint
304
+ const r = await fn(tab.page, tab);
305
+ // stay: nothing changed on the page, so it shows what fn left in tab.shown
306
+ if (r && r.stay) delete r.stay; else tab.shown = r && r.id && !r.error ? r.id : "";
257
307
  return r;
258
- } catch (e) { await shut(b); throw e; }
308
+ } catch (e) { if (!tab || b.c.closed()) await shut(b); else await closeTab(b, tab); throw e; }
259
309
  finally {
260
310
  clearTimeout(kill); b.busy = false;
261
311
  if (live === b && KEEP && keep && !b.c.closed()) b.timer = setTimeout(() => oneAtATime(() => live === b && !b.busy ? shut(b) : null), KEEP);
@@ -523,7 +573,7 @@ async function snapshotList(page, name) {
523
573
  // Map a site: open it in the hidden browser and save what's on it as a screen.
524
574
  // whole: all of the page in one tall screen (snapshotWhole); a page whose list
525
575
  // scrolls inside it gets the usual map, with a note that says why.
526
- function mapPage(input, name, { whole = false } = {}) {
576
+ function mapPage(input, name, { whole = false, who = "" } = {}) {
527
577
  const url = siteUrl(input);
528
578
  if (!url) return Promise.resolve({ error: "Give a site to map: a name (gmail), a host (github.com/pulls) or a web address." });
529
579
  return oneAtATime(() => withPage(async (page) => {
@@ -533,12 +583,12 @@ function mapPage(input, name, { whole = false } = {}) {
533
583
  if (!s.inside) return s;
534
584
  const one = await snapshot(page, name);
535
585
  return one.error || one.note ? one : { ...one, note: s.error };
536
- })).catch((e) => ({ error: String((e && e.message) || e) }));
586
+ }, true, who)).catch((e) => ({ error: String((e && e.message) || e) }));
537
587
  }
538
588
 
539
589
  // The whole of a page you've mapped, as one tall screen (snapshotWhole). Only
540
590
  // looks, so it never asks first.
541
- function wholePage(id) {
591
+ function wholePage(id, { who = "" } = {}) {
542
592
  const s = loadScreens().find((x) => x.id === id); if (!s) return Promise.resolve({ error: "not found" });
543
593
  if (!s.page || !s.page.url) return Promise.resolve({ error: "Whole page works on a mapped page." });
544
594
  return oneAtATime(() => withPage(async (page, b) => {
@@ -546,7 +596,7 @@ function wholePage(id) {
546
596
  const next = await snapshotWhole(page, s.name.replace(/ ↓ \d+%$/, "").replace(/ \(whole page\)$/, ""));
547
597
  if (next.error) { delete next.inside; return next; }
548
598
  return { ...next, kept: here };
549
- })).catch((e) => ({ error: String((e && e.message) || e) }));
599
+ }, true, who)).catch((e) => ({ error: String((e && e.message) || e) }));
550
600
  }
551
601
 
552
602
  // Read a site as it is now, without saving a screen: { url, title, items, login }.
@@ -556,7 +606,9 @@ function wholePage(id) {
556
606
  // browser open after.
557
607
  // password: a page asking for a password counts as a sign-in page too (a site's own login
558
608
  // form at an address that doesn't say so), for a check of whether you're signed in.
559
- const ASKS_PASSWORD = `!![...document.querySelectorAll('input[type=password]')].find((e) => { const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0; })`;
609
+ // So does a page whose own button or link is just "Log in" or "Sign in": signed out, Reddit's
610
+ // home page has no password box, only its Log In button, and read as signed in.
611
+ const ASKS_PASSWORD = `!![...document.querySelectorAll('input[type=password], a, button, [role=button]')].find((e) => { const b = e.getBoundingClientRect(); if (!(b.width > 0 && b.height > 0)) return false; return e.matches('input') || /^(log ?in|sign ?in)$/i.test(String(e.innerText || e.textContent || '').replace(/\\s+/g, ' ').trim()); })`;
560
612
  function readPage(input, { password = false } = {}) {
561
613
  const url = siteUrl(input);
562
614
  if (!url) return Promise.resolve({ error: "Give a site to read: a name (gmail), a host or a web address." });
@@ -670,6 +722,47 @@ function pageClip(input, seconds = 8) {
670
722
  })).catch((e) => ({ error: String((e && e.message) || e) }));
671
723
  }
672
724
 
725
+ // A browser of its own for a capture (capture.mjs): a fresh profile in a temp folder, so
726
+ // nothing you're signed in to in Symbiot's browser shows in a product's pictures, and it
727
+ // never waits on the Symbiot Browser. fn gets its page ({ send, until, idle, on }); the
728
+ // browser and its profile go after. width × height: the page's size.
729
+ async function scratchPage(fn, { width = 1440, height = 900 } = {}) {
730
+ const chrome = chromeBinary();
731
+ if (!chrome) throw new Error("Capturing a product needs Chrome, Chromium, Edge or Brave, and none was found.");
732
+ const dir = mkdtempSync(join(tmpdir(), "symbiot-capture-"));
733
+ const proc = spawn(chrome, ["--headless=new", "--hide-scrollbars", "--mute-audio", "--remote-debugging-pipe", `--window-size=${width},${height}`,
734
+ "--user-data-dir=" + dir, "--no-first-run", "--no-default-browser-check", ...(process.platform === "linux" ? ["--password-store=basic"] : process.platform === "darwin" ? ["--use-mock-keychain"] : []), "about:blank"],
735
+ { stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"] });
736
+ let err = ""; proc.stderr.on("data", (d) => { err = (err + d).slice(-2000); });
737
+ const failed = new Promise((resolve, reject) => { proc.on("error", reject); }); failed.catch(() => {});
738
+ const c = connect(proc), kill = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, START);
739
+ try {
740
+ const page = await Promise.race([failed, (async () => {
741
+ let targetId; try { ({ targetId } = await c.send("Target.createTarget", { url: "about:blank" })); } catch (e) { throw new Error("The browser didn't start: " + ((err.trim().split("\n").pop()) || e.message)); }
742
+ const { sessionId } = await c.send("Target.attachToTarget", { targetId, flatten: true });
743
+ const send = (method, params) => c.send(method, params, sessionId);
744
+ const until = (method, ms) => c.until((m) => m.sessionId === sessionId && m.method === method, ms);
745
+ const inflight = new Set();
746
+ c.on((m) => { if (!m || m.sessionId !== sessionId) return; if (m.method === "Network.requestWillBeSent") inflight.add(m.params.requestId); else if (m.method === "Network.loadingFinished" || m.method === "Network.loadingFailed") inflight.delete(m.params.requestId); });
747
+ const idle = async () => { const end = Date.now() + 8000; let calm = 0; await sleep(400); while (Date.now() < end && calm < 600) { await sleep(100); calm = inflight.size <= 1 ? calm + 100 : 0; } };
748
+ const on = (method, f) => c.on((m) => { if (m && m.sessionId === sessionId && m.method === method) f(m.params || {}); });
749
+ await send("Page.enable"); await send("Network.enable");
750
+ await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: false });
751
+ const { userAgent } = await c.send("Browser.getVersion");
752
+ if (userAgent) await send("Network.setUserAgentOverride", { userAgent: userAgent.replace(/HeadlessChrome/g, "Chrome") });
753
+ return { send, until, idle, on };
754
+ })()]);
755
+ clearTimeout(kill);
756
+ return await fn(page);
757
+ } finally {
758
+ clearTimeout(kill);
759
+ try { await Promise.race([c.send("Browser.close"), sleep(3000)]); } catch {}
760
+ if (proc.exitCode === null) await Promise.race([new Promise((r) => proc.once("exit", r)), sleep(3000)]);
761
+ if (proc.exitCode === null) try { proc.kill("SIGKILL"); } catch {}
762
+ try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch {}
763
+ }
764
+ }
765
+
673
766
  // Trusted sites (Settings → Screens): on a page from one of these, press and type
674
767
  // go ahead without asking, for you and for agents. Anywhere else each one asks.
675
768
  // A host covers its subdomains (google.com covers mail.google.com). Only the app's
@@ -719,7 +812,7 @@ async function showScreen(page, b, s) {
719
812
  // is taller than its window, and a map only has what the window shows. Only looks,
720
813
  // so it never asks first. At the end already, it says so and maps nothing.
721
814
  const SCROLLS = ["down", "up", "top", "bottom"];
722
- function scrollPage(id, to = "down") {
815
+ function scrollPage(id, to = "down", { who = "" } = {}) {
723
816
  to = String(to || "down").toLowerCase();
724
817
  if (!SCROLLS.includes(to)) return Promise.resolve({ error: `Scroll ${SCROLLS.join(", ")}: not "${to}".` });
725
818
  const s = loadScreens().find((x) => x.id === id); if (!s) return Promise.resolve({ error: "not found" });
@@ -736,7 +829,7 @@ function scrollPage(id, to = "down") {
736
829
  const pct = m.max > 0 ? Math.round(m.y / m.max * 100) : 0;
737
830
  const next = await snapshot(page, s.name.replace(/ ↓ \d+%$/, "") + (pct ? ` ↓ ${pct}%` : ""));
738
831
  return next.error ? next : { ...next, scrolled: to, kept: here };
739
- })).catch((e) => ({ error: String((e && e.message) || e) }));
832
+ }, true, who)).catch((e) => ({ error: String((e && e.message) || e) }));
740
833
  }
741
834
 
742
835
  // Act on a mapped region: find it (by its selector, else at the same spot) and give
@@ -745,13 +838,13 @@ function scrollPage(id, to = "down") {
745
838
  // was typed is still there); on any other, its page is opened from its address
746
839
  // again. It acts on the real site, signed in as you, so it needs the caller's
747
840
  // confirmation unless the page's site is trusted.
748
- function actOnRegion(id, regionId, verb, confirmed, check, act) {
841
+ function actOnRegion(id, regionId, verb, confirmed, check, act, who = "") {
749
842
  const s = loadScreens().find((x) => x.id === id); if (!s) return Promise.resolve({ error: "not found" });
750
843
  if (!s.page || !s.page.url) return Promise.resolve({ error: `${verb} works on a mapped page. Use Click here for a screenshot of your screen.` });
751
844
  const r = (s.regions || []).find((x) => x.id === regionId); if (!r) return Promise.resolve({ error: "That region is gone. Reload the screen." });
752
845
  const bad = check(r); if (bad) return Promise.resolve({ error: bad });
753
846
  const host = hostOf(s.page.url);
754
- if (!confirmed && !isTrusted(s.page.url)) return Promise.resolve({ error: `${verb === "Type" ? "Typing into" : verb === "Upload" ? "Attaching a file with" : "Pressing"} "${r.label}" acts on the real site, signed in as you, and ${host} isn't one of your trusted sites.`, confirm: true, host });
847
+ if (!confirmed && !isTrusted(s.page.url)) return Promise.resolve({ error: `${verb === "Type" ? "Typing into" : verb === "Upload" ? "Attaching a file with" : verb === "Choose" ? "Choosing in" : "Pressing"} "${r.label}" acts on the real site, signed in as you, and ${host} isn't one of your trusted sites.`, confirm: true, host });
755
848
  return oneAtATime(() => withPage(async (page, b) => {
756
849
  const here = await showScreen(page, b, s);
757
850
  // A new tab would leave this one where it was, so a link opens here instead.
@@ -772,11 +865,13 @@ function actOnRegion(id, regionId, verb, confirmed, check, act) {
772
865
  }
773
866
  const navigating = page.until("Page.frameStartedLoading", 1500);
774
867
  const extra = await act(page, at, r);
868
+ // refused on the page, nothing changed: the page stays as it was, so what was typed before is kept
869
+ if (extra && extra.refused) return { error: extra.refused, stay: true };
775
870
  if (await navigating) await page.until("Page.loadEventFired", 30000);
776
871
  await page.idle();
777
872
  const next = await snapshot(page, ""); // named after the page it landed on
778
873
  return next.error ? next : { ...next, ...extra, found, kept: here };
779
- })).catch((e) => ({ error: String((e && e.message) || e) }));
874
+ }, true, who)).catch((e) => ({ error: String((e && e.message) || e) }));
780
875
  }
781
876
  const clickAt = async (page, at) => { for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) await page.send("Input.dispatchMouseEvent", { type, x: at.x, y: at.y, button: "left", clickCount: type === "mouseMoved" ? 0 : 1 }); };
782
877
 
@@ -789,10 +884,10 @@ const clickAt = async (page, at) => { for (const type of ["mouseMoved", "mousePr
789
884
  const MAIL_HOSTS = /^(mail\.google\.com|outlook\.(live|office|office365)\.com)$/;
790
885
  const POSTS = /\b(post|reply|comment|submit|publish|share|repost|tweet)\b/i;
791
886
  const isSend = (r, host = "") => /^(button|menu item)$/.test(r.kind || "button") && (/\bsend\b/i.test(r.label || "") || (!MAIL_HOSTS.test(host) && POSTS.test(r.label || "")));
792
- function pressRegion(id, regionId, { confirmed = false, noSend = false } = {}) {
887
+ function pressRegion(id, regionId, { confirmed = false, noSend = false, who = "" } = {}) {
793
888
  const s = noSend ? loadScreens().find((x) => x.id === id) : null, host = s && s.page ? hostOf(s.page.url) : "";
794
889
  return actOnRegion(id, regionId, "Press", confirmed, (r) => (noSend && isSend(r, host) ? `"${r.label}" sends or posts. This run only drafts: it never presses Send, Post, Comment or Reply. The draft stays for the user to send or post.` : ""),
795
- async (page, at, r) => { await clickAt(page, at); return { pressed: r.label }; });
890
+ async (page, at, r) => { await clickAt(page, at); return { pressed: r.label }; }, who);
796
891
  }
797
892
 
798
893
  // Type: click into a field, replace what's in it with text, and press Enter if
@@ -801,25 +896,106 @@ function pressRegion(id, regionId, { confirmed = false, noSend = false } = {}) {
801
896
  // open (the app's): what's typed is still there. Otherwise it's gone by then.
802
897
  // noSend (a draft reply's run) refuses Enter, which sends in a chat, and in a
803
898
  // WhatsApp chat types line breaks as spaces, in case a new line sends there too.
899
+ // replace: only that part of what's in it is typed over (selected, then typed, as a
900
+ // person would), so the rest stays as it is: retyping all of a scheduled LinkedIn
901
+ // post to change one line dropped its picture (2026-10-12). It has to be there just
902
+ // once; none, or more than once, changes nothing and says so.
804
903
  const MAX_TEXT = 2000;
805
904
  const CHAT_HOSTS = new Set(["web.whatsapp.com"]);
806
- function typeRegion(id, regionId, text, { enter = false, confirmed = false, noSend = false } = {}) {
905
+ const SELECT_PART = (old) => `(() => {
906
+ const e = document.activeElement, w = ${JSON.stringify(String(old || ""))};
907
+ if (!e) return { problem: 'none' };
908
+ const count = (t) => { let n = 0, i = -1; while ((i = t.indexOf(w, i + 1)) !== -1) n++; return n; };
909
+ if (typeof e.setSelectionRange === 'function' && typeof e.value === 'string') {
910
+ const n = count(e.value); if (n !== 1) return { problem: n ? 'more' : 'none', count: n };
911
+ const i = e.value.indexOf(w); e.setSelectionRange(i, i + w.length); return { ok: true };
912
+ }
913
+ if (!e.isContentEditable) return { problem: 'none' };
914
+ // a rich editor (LinkedIn's): its text nodes, as one string, and the match mapped back onto them
915
+ const nodes = [], walk = document.createTreeWalker(e, NodeFilter.SHOW_TEXT); let all = '';
916
+ for (let x = walk.nextNode(); x; x = walk.nextNode()) { nodes.push([x, all.length]); all += x.data; }
917
+ const n = count(all); if (n !== 1) return { problem: n ? 'more' : 'none', count: n };
918
+ const from = all.indexOf(w), to = from + w.length, at = (k, end) => { for (const [x, o] of nodes) if (k < o + x.data.length || (end && k === o + x.data.length)) return [x, k - o]; return null; };
919
+ const a = at(from, false), b = at(to, true); if (!a || !b) return { problem: 'none' };
920
+ const r = document.createRange(); r.setStart(a[0], a[1]); r.setEnd(b[0], b[1]);
921
+ const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); return { ok: true };
922
+ })()`;
923
+ function typeRegion(id, regionId, text, { enter = false, confirmed = false, noSend = false, replace = null, who = "" } = {}) {
924
+ const part = replace == null ? null : String(replace);
807
925
  text = String(text == null ? "" : text);
808
926
  if (noSend) { const s = loadScreens().find((x) => x.id === id); if (s && s.page && CHAT_HOSTS.has(hostOf(s.page.url))) text = text.replace(/\s*[\r\n]+\s*/g, " ").trim(); }
809
927
  return actOnRegion(id, regionId, "Type", confirmed, (r) => {
810
928
  if (noSend && enter) return "Enter sends in a chat, and this run only drafts: type without --enter, and the reply stays unsent for the user to send.";
929
+ if (r.kind === "menu") return `"${r.label}" is a menu, not a field: pick its option with Choose (symbiot screens choose <screen> <menu> "option").`;
811
930
  if (r.kind !== "field") return `"${r.label}" isn't a field (it's a ${r.kind || "region"}). Type works on a field; use Press for the rest.`;
812
- if (!text && !enter) return "Give the text to type.";
931
+ if (part === "") return "Give the words to replace: some of what's in the field now.";
932
+ if (part == null && !text && !enter) return "Give the text to type.";
813
933
  if (text.length > MAX_TEXT) return `That's more than ${MAX_TEXT} characters.`;
814
934
  return "";
815
935
  }, async (page, at, r) => {
816
936
  await clickAt(page, at);
817
- await page.send("Runtime.evaluate", { expression: `(() => { const e = document.activeElement; if (!e) return;
937
+ if (part != null) {
938
+ const sel = await evaluate(page, SELECT_PART(part));
939
+ if (!sel || !sel.ok) return { refused: sel && sel.problem === "more" ? `"${part}" is in "${r.label}" ${sel.count} times: give more of it, so it's there just once. Nothing was changed.` : `"${r.label}" doesn't have "${part}" in it (it has to be within one line or paragraph, as it is now). Nothing was changed.` };
940
+ if (text) await page.send("Input.insertText", { text });
941
+ else for (const type of ["keyDown", "keyUp"]) await page.send("Input.dispatchKeyEvent", { type, key: "Delete", code: "Delete", windowsVirtualKeyCode: 46, nativeVirtualKeyCode: 46 });
942
+ } else {
943
+ await page.send("Runtime.evaluate", { expression: `(() => { const e = document.activeElement; if (!e) return;
818
944
  if (typeof e.select === 'function') e.select(); else if (e.isContentEditable) getSelection().selectAllChildren(e); })()` });
819
- if (text) await page.send("Input.insertText", { text });
945
+ if (text) await page.send("Input.insertText", { text });
946
+ }
820
947
  if (enter) for (const type of ["keyDown", "keyUp"]) await page.send("Input.dispatchKeyEvent", { type, key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, ...(type === "keyDown" ? { text: "\r" } : {}) });
821
- return { typed: r.label, entered: !!enter };
822
- });
948
+ return { typed: r.label, entered: !!enter, ...(part != null ? { replaced: part } : {}) };
949
+ }, who);
950
+ }
951
+
952
+ // Choose: pick an option in a menu, as choosing it would. A native <select> (a press only
953
+ // opens it, and typing isn't allowed in one: domains.co.za's DNS record type, 2026-10-09)
954
+ // gets the option set and the page told (input and change), so what depends on it updates
955
+ // (MX turns its Priority box on). A menu of the site's own making (role=combobox) is
956
+ // opened, and its option (role=option) clicked. The option: its label or value, exactly,
957
+ // else the one label that has it in it; none, or more than one, says which there are.
958
+ const CHOOSE_SELECT = (sel, at, want) => `(() => {
959
+ let e = null; try { e = ${JSON.stringify(sel || "")} && document.querySelector(${JSON.stringify(sel || "")}); } catch (x) {}
960
+ e = e || document.elementFromPoint(${Number(at.x) || 0}, ${Number(at.y) || 0});
961
+ const lab = e && e.closest && e.closest('label');
962
+ const s = !e ? null : e.tagName === 'SELECT' ? e : (e.querySelector && e.querySelector('select')) || (lab && lab.control && lab.control.tagName === 'SELECT' ? lab.control : null);
963
+ if (!s) return { native: false };
964
+ const norm = (t) => String(t || '').replace(/\\s+/g, ' ').trim().toLowerCase(), w = norm(${JSON.stringify(String(want || ""))});
965
+ const opts = [...s.options].map((o) => ({ o, label: norm(o.label || o.text), value: norm(o.value) })), all = opts.map((x) => x.o.label || x.o.text);
966
+ let hit = opts.filter((x) => x.label === w || x.value === w); if (!hit.length) hit = opts.filter((x) => w && x.label.includes(w));
967
+ if (hit.length !== 1) return { native: true, problem: hit.length ? 'more' : 'none', options: all };
968
+ const o = hit[0].o; if (s.disabled || o.disabled) return { native: true, problem: 'off', options: all };
969
+ Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set.call(s, o.value);
970
+ s.dispatchEvent(new Event('input', { bubbles: true })); s.dispatchEvent(new Event('change', { bubbles: true }));
971
+ return { native: true, chose: o.label || o.text, value: s.value };
972
+ })()`;
973
+ const CHOOSE_OPTION = (want) => `(() => {
974
+ const norm = (t) => String(t || '').replace(/\\s+/g, ' ').trim().toLowerCase(), w = norm(${JSON.stringify(String(want || ""))});
975
+ const seen = [...document.querySelectorAll('[role=option]')].filter((e) => { const b = e.getBoundingClientRect(); return b.width > 0 && b.height > 0; });
976
+ const all = seen.map((e) => (e.getAttribute('aria-label') || e.textContent || '').replace(/\\s+/g, ' ').trim());
977
+ let hit = seen.filter((e, i) => norm(all[i]) === w); if (!hit.length) hit = seen.filter((e, i) => w && norm(all[i]).includes(w));
978
+ if (hit.length !== 1) return { problem: hit.length ? 'more' : 'none', options: all };
979
+ hit[0].scrollIntoView({ block: 'center' }); const b = hit[0].getBoundingClientRect();
980
+ return { at: { x: b.left + b.width / 2, y: b.top + b.height / 2 }, chose: all[seen.indexOf(hit[0])] };
981
+ })()`;
982
+ const chooseSaid = (r, want, x) => x.problem === "off" ? `"${r.label}" is switched off on the page, or that option is: nothing was chosen.`
983
+ : `${x.problem === "more" ? `More than one option in "${r.label}" matches "${want}"` : `"${r.label}" has no option "${want}"`}. Its options: ${(x.options || []).slice(0, 40).map((o) => `"${o}"`).join(", ") || "none showing"}.`;
984
+ function chooseRegion(id, regionId, option, { confirmed = false, who = "" } = {}) {
985
+ const want = String(option == null ? "" : option).trim();
986
+ return actOnRegion(id, regionId, "Choose", confirmed, (r) => {
987
+ if (!want) return "Give the option to choose: its label, as the menu shows it.";
988
+ if (r.kind !== "menu") return `"${r.label}" isn't a menu (it's a ${r.kind || "region"}). Choose works on a menu; use Press or Type for the rest.`;
989
+ return "";
990
+ }, async (page, at, r) => {
991
+ const sel = await evaluate(page, CHOOSE_SELECT(r.selector, at, want));
992
+ if (sel && sel.native) { if (sel.problem) throw new Error(chooseSaid(r, want, sel)); return { chose: sel.chose, value: sel.value }; }
993
+ await clickAt(page, at); await sleep(400); // the site's own menu: open it, then its option
994
+ const o = await evaluate(page, CHOOSE_OPTION(want));
995
+ if (!o || o.problem) { await page.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }).catch(() => {}); throw new Error(chooseSaid(r, want, o || {})); }
996
+ await clickAt(page, o.at);
997
+ return { chose: o.chose };
998
+ }, who);
823
999
  }
824
1000
 
825
1001
  // Upload: put a file (or a few) in a page's file box, as picking it in the file
@@ -878,10 +1054,10 @@ function uploadFiles(files) {
878
1054
  const bad = list.find((f) => { try { return !statSync(f).isFile(); } catch { return true; } });
879
1055
  return bad ? { error: `No file at ${bad}.` } : { files: list };
880
1056
  }
881
- function uploadRegion(id, regionId, files, { confirmed = false } = {}) {
1057
+ function uploadRegion(id, regionId, files, { confirmed = false, who = "" } = {}) {
882
1058
  const f = uploadFiles(files);
883
1059
  return actOnRegion(id, regionId, "Upload", confirmed, () => f.error || "",
884
- async (page, at, r) => ({ uploaded: r.label, files: f.files, ...(await attachFiles(page, at, r, f.files)) }));
1060
+ async (page, at, r) => ({ uploaded: r.label, files: f.files, ...(await attachFiles(page, at, r, f.files)) }), who);
885
1061
  }
886
1062
 
887
1063
  // Open the site in Symbiot's browser profile as a normal window, to sign in (or
@@ -907,7 +1083,9 @@ const browserHub = () => HUB.replace(/t=[0-9a-f]+/, "t=…"); // where it opens
907
1083
  function profilePids({ headless } = {}) {
908
1084
  if (process.platform === "win32") return [];
909
1085
  try {
910
- const out = spawnSync("ps", ["-eo", "pid=,args="], { encoding: "utf8", timeout: 5000 }).stdout || "";
1086
+ // -ww: the whole line, however wide. With COLUMNS set (an app started from a terminal) ps cut
1087
+ // each at 80, the profile's flag went, and Done found no window to close (2026-10-12).
1088
+ const out = spawnSync("ps", ["-ww", "-eo", "pid=,args="], { encoding: "utf8", timeout: 5000 }).stdout || "";
911
1089
  return out.split("\n").map((l) => l.trim().match(/^(\d+)\s+(.*)$/)).filter(Boolean)
912
1090
  .filter(([, , a]) => a.includes("--user-data-dir=" + PROFILE) && !/--type=/.test(a) && (headless === undefined || /--headless/.test(a) === headless))
913
1091
  .map(([, pid]) => Number(pid)).filter((p) => p && p !== process.pid);
@@ -931,14 +1109,37 @@ async function openSymbiotBrowser(url = "") {
931
1109
  if (url) setTimeout(() => { try { spawn(chrome, [...base, "--new-window", url], { detached: true, stdio: "ignore" }).unref(); } catch {} }, page.length ? 1500 : 0);
932
1110
  if (!page.length && !url) spawn(chrome, [...base, "--new-window", "about:blank"], { detached: true, stdio: "ignore" }).unref();
933
1111
  } catch (e) { return { error: String((e && e.message) || e) }; }
1112
+ watchWindow();
934
1113
  return { ok: true, ...(url ? { url } : {}), browser: true };
935
1114
  }
936
1115
  // Done: the Symbiot Browser closes (its sign-ins are saved), so the hidden browser can read with them.
937
- async function closeSymbiotBrowser() {
938
- const open = profilePids({ headless: false });
1116
+ // Its windows are the visible Chromes on the profile, and the one holding the profile's lock if
1117
+ // it's visible (found even where ps can't list it). One still open after a few seconds (a page
1118
+ // holding it up) is closed outright: Done always closes it.
1119
+ const windows = () => { const h = lockHolder(); return [...new Set([...profilePids({ headless: false }), ...(h && !h.headless && !OWN.has(h.pid) ? [h.pid] : [])])]; };
1120
+ async function closeSymbiotBrowser({ wait = 8000 } = {}) {
1121
+ const open = windows();
939
1122
  kill(open);
940
- for (let i = 0; i < 20 && profilePids({ headless: false }).length; i++) await sleep(250);
941
- return { closed: open.length };
1123
+ for (const end = Date.now() + wait; Date.now() < end && windows().length;) await sleep(250);
1124
+ const left = windows(); for (const p of left) try { process.kill(p, "SIGKILL"); } catch {}
1125
+ for (let i = 0; i < 8 && windows().length; i++) await sleep(250);
1126
+ return { closed: open.length, ...(left.length ? { forced: left.length } : {}) };
1127
+ }
1128
+ // Closed by its X (the last of its windows), or by Done: once no window has the profile any
1129
+ // more, the app is told (onBrowserClosed), so it checks your sites just as Done does. Watched
1130
+ // from when it opens; a window that never came up (in `start`) tells nothing.
1131
+ let CLOSED = null, WATCH = null;
1132
+ function onBrowserClosed(f) { CLOSED = typeof f === "function" ? f : null; }
1133
+ function watchWindow({ every = 2000, start = 30000 } = {}) {
1134
+ clearInterval(WATCH); if (!CLOSED) return;
1135
+ let seen = false; const t0 = Date.now();
1136
+ WATCH = setInterval(() => {
1137
+ if (windowOpen()) { seen = true; return; }
1138
+ if (!seen && Date.now() - t0 < start) return;
1139
+ clearInterval(WATCH); WATCH = null;
1140
+ if (seen && CLOSED) try { CLOSED(); } catch {}
1141
+ }, every);
1142
+ if (WATCH.unref) WATCH.unref();
942
1143
  }
943
1144
 
944
- export { siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, readLinkedIn, LI_READ, pagePicture, pageClip, CLIP, isSend, pressRegion, typeRegion, uploadRegion, uploadFiles, attachFiles, FILE_BOX, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, keepSessions, SESSION_KEEP, SESSIONS, loadSessions, trustedSites, isTrusted, trustSite, untrustSite, PROFILE , openSymbiotBrowser, closeSymbiotBrowser, setBrowserHub , browserHub };
1145
+ export { tabKey, siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, readLinkedIn, LI_READ, pagePicture, pageClip, CLIP, isSend, pressRegion, typeRegion, chooseRegion, uploadRegion, uploadFiles, attachFiles, FILE_BOX, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, keepSessions, SESSION_KEEP, SESSIONS, loadSessions, trustedSites, isTrusted, trustSite, untrustSite, PROFILE , openSymbiotBrowser, closeSymbiotBrowser, onBrowserClosed, scratchPage, open as openPage, evaluate, SCROLLER, watchWindow, windowOpen, lockHolder, setBrowserHub , browserHub };