symbiot 0.60.0 → 0.61.0

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
@@ -2,6 +2,12 @@
2
2
 
3
3
  What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
4
4
 
5
+ ## 0.61.0
6
+
7
+ - **The Symbiot Browser.** Where you sign in to your sites for Symbiot, now a window of its own, named and marked as Symbiot's, instead of a plain Chrome window that looked like any other. It opens on a page of your sites (Gmail, Outlook, calendars, GitHub, Slack, WhatsApp, Jira, LinkedIn…), each saying whether you're signed in, with a **Sign in** (or **Connect**) button, an address bar for any other site, and **Done**, which closes it and has Symbiot check each site. Open it from **Settings → Links → Open the Symbiot Browser**, or `symbiot browser [site]`; connecting a site anywhere in Symbiot opens it too.
8
+ - **No more invisible sign-in windows.** If Symbiot's hidden browser was running, a sign-in window could open inside it, where you'd never see it. It's closed first now.
9
+ - **No more stray empty windows.** While the Symbiot Browser (or any sign-in window) is open, Symbiot's background reads wait for Done instead of starting a second Chrome, which popped an empty window into yours.
10
+
5
11
  ## 0.60.0
6
12
 
7
13
  - **A release no longer trips on file times.** Linux stamps files with a clock a few milliseconds behind the precise one, so a run that asked a question straight away could look as if it asked before it started, and wasn't noted as waiting on you. Symbiot now allows for that (it also made a test fail on GitHub's machines, which held up 0.59.0).
package/README.md CHANGED
@@ -1419,6 +1419,15 @@ expects. Symbiot rescales for `xdotool` and for `cliclick` on a Retina screen, b
1419
1419
  it can't read the scale on Wayland, and ydotool's moves follow your pointer
1420
1420
  acceleration, so a click there can land off target.
1421
1421
 
1422
+ ## The Symbiot Browser
1423
+
1424
+ Symbiot reads your sites (Gmail, LinkedIn, WhatsApp, Jira…) with your own sign-in, in
1425
+ its own browser. **Settings → Links → Open the Symbiot Browser** (or `symbiot browser`)
1426
+ opens it: a window of its own listing your sites, each with whether you're signed in and
1427
+ a **Sign in** button, plus an address bar for any other site. Sign in the usual way
1428
+ (the site's own login and 2FA; Symbiot never sees a password), then click **Done**: it
1429
+ closes and Symbiot checks each site.
1430
+
1422
1431
  ## Any language
1423
1432
 
1424
1433
  Symbiot uses the language your computer is set to, or any language you name in
package/browser.html ADDED
@@ -0,0 +1,94 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Symbiot Browser</title>
7
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
8
+ <meta name="theme-color" content="#0B0C0E">
9
+ <style>
10
+ /* The Symbiot Browser's own page: where you sign in to your sites, once, for Symbiot.
11
+ It runs in Symbiot's browser profile, so a sign-in here is the one Symbiot uses. */
12
+ @font-face{font-family:"Geist";src:url("/fonts/Geist-Variable.woff2") format("woff2");font-weight:100 900;font-display:swap}
13
+ :root{--bg:#0B0C0E;--panel:#131518;--line:#2A2E34;--bone:#F3F0EA;--text:#C9CDD3;--faint:#8A919B;--amber:#F2A541;--ok:#4FBE8C;--bad:#FF8A75;color-scheme:dark;
14
+ --sans:"Geist",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
15
+ @media (prefers-color-scheme: light){:root{--bg:#F4F2EE;--panel:#FFFFFF;--line:#DEDAD2;--bone:#11151B;--text:#3A414C;--faint:#6B7380;--amber:#B86A0C;--ok:#0B7A55;--bad:#B4361F;color-scheme:light}}
16
+ *{box-sizing:border-box}
17
+ body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 var(--sans)}
18
+ .wrap{max-width:880px;margin:0 auto;padding:28px 24px 40px;display:grid;gap:22px}
19
+ header{display:flex;align-items:center;gap:14px}
20
+ header img{width:44px;height:44px;border-radius:12px}
21
+ h1{margin:0;font:650 24px/1.2 var(--sans);letter-spacing:-.02em;color:var(--bone)}
22
+ .lede{margin:2px 0 0;color:var(--faint);font-size:14px;max-width:62ch}
23
+ form.go{display:flex;gap:10px}
24
+ form.go input{flex:1;min-width:0;padding:12px 16px;border-radius:999px;border:1px solid var(--line);background:var(--panel);color:var(--bone);font:15px var(--sans)}
25
+ button{font:600 14px var(--sans);border-radius:999px;padding:10px 18px;border:1px solid var(--line);background:transparent;color:var(--bone);cursor:pointer}
26
+ button.main{background:var(--bone);color:var(--bg);border-color:var(--bone)}
27
+ button:focus-visible,input:focus-visible{outline:2px solid var(--amber);outline-offset:2px}
28
+ h2{margin:6px 0 0;font:600 12px var(--sans);letter-spacing:.14em;text-transform:uppercase;color:var(--faint)}
29
+ .sites{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}
30
+ .site{display:flex;align-items:center;gap:12px;padding:14px 16px;border:1px solid var(--line);border-radius:16px;background:var(--panel)}
31
+ .site .n{flex:1;min-width:0}
32
+ .site b{display:block;color:var(--bone);font-weight:600}
33
+ .site small{display:block;color:var(--faint);font-size:12.5px}
34
+ .site small.ok{color:var(--ok)}.site small.bad{color:var(--bad)}.site small.wait{color:var(--amber)}
35
+ .site button{padding:8px 14px;font-size:13px;flex:none}
36
+ .done{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:18px 20px;border-radius:18px;border:1px solid var(--line);background:var(--panel)}
37
+ .done p{margin:0;flex:1;min-width:220px;font-size:14px}
38
+ .msg{min-height:20px;font-size:14px;color:var(--amber)}
39
+ </style>
40
+ </head>
41
+ <body>
42
+ <main class="wrap">
43
+ <header>
44
+ <img src="/favicon.svg" alt="">
45
+ <div><h1>Symbiot Browser</h1>
46
+ <p class="lede">Symbiot’s own browser. Sign in to your sites here, once, the usual way (their own login, 2FA and all). Symbiot then reads them for you with that sign-in. It never sees your passwords.</p></div>
47
+ </header>
48
+
49
+ <form class="go" id="go"><input id="addr" placeholder="Go to any site: a name like gmail, or an address" autocomplete="off" aria-label="Site to open"><button class="main" type="submit">Open</button></form>
50
+ <div class="msg" id="msg" role="status"></div>
51
+
52
+ <section id="list"></section>
53
+
54
+ <div class="done">
55
+ <p><b>Signed in?</b> Click Done: the Symbiot Browser closes and Symbiot checks each site, so the ticks come up in Symbiot within a minute.</p>
56
+ <button class="main" id="done" type="button">Done</button>
57
+ </div>
58
+ </main>
59
+ <script>
60
+ var T = new URLSearchParams(location.search).get("t") || "";
61
+ function api(path, body) { return fetch(path, { method: body ? "POST" : "GET", headers: { "x-symbiot-token": T, "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined }).then(function (r) { return r.json(); }); }
62
+ function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); }
63
+ function say(t) { document.getElementById("msg").textContent = t || ""; }
64
+ // a window of this same browser (so the sign-in is Symbiot's), the size of a laptop screen
65
+ function openSite(url) { var w = window.open(url, "_blank", "popup=no,width=1200,height=860"); if (!w) say("Your browser blocked the window: allow pop-ups for this page, then try again."); return w; }
66
+ function fromInput(v) { v = String(v || "").trim(); if (!v) return ""; if (/^https?:\/\//i.test(v)) return v; if (/^[a-z0-9-]+$/i.test(v)) return "https://" + v.toLowerCase() + ".com"; return "https://" + v; }
67
+ var STATE = { off: ["Not connected", ""], signin: ["Not signed in yet", "wait"], signedout: ["Signed out", "bad"], error: ["Needs attention", "bad"], ok: ["Signed in", "ok"] };
68
+ function render(d) {
69
+ var items = (d && d.items) || [], groups = (d && d.groups) || [], h = "";
70
+ groups.forEach(function (g) {
71
+ var its = items.filter(function (x) { return x.group === g; }); if (!its.length) return;
72
+ h += "<h2>" + esc(g) + "</h2><div class='sites'>" + its.map(function (x) {
73
+ var s = STATE[x.state] || STATE.off, label = x.state === "off" ? "Connect" : x.state === "ok" ? "Open" : "Sign in";
74
+ return "<div class='site'><div class='n'><b>" + esc(x.name) + "</b><small class='" + s[1] + "'>" + (x.state === "ok" ? "✓ " : "") + esc(s[0]) + "</small></div><button type='button' data-id='" + esc(x.id) + "' data-url='" + esc(x.url) + "' data-state='" + esc(x.state) + "'>" + label + "</button></div>";
75
+ }).join("") + "</div>";
76
+ });
77
+ document.getElementById("list").innerHTML = h;
78
+ }
79
+ function load() { api("/api/links").then(render).catch(function () { say("Symbiot isn't answering. Is it still running?"); }); }
80
+ document.getElementById("list").addEventListener("click", function (e) {
81
+ var b = e.target.closest && e.target.closest("button[data-id]"); if (!b) return;
82
+ var url = b.getAttribute("data-url"), id = b.getAttribute("data-id");
83
+ var w = openSite(url); // first, while the click still counts (pop-up rules)
84
+ if (b.getAttribute("data-state") === "off") api("/api/links/link", { id: id, here: true }).then(function (r) {
85
+ if (r && r.error) { if (w) w.close(); say(r.error); } else { say("Sign in to it in the window that opened, then come back here."); load(); }
86
+ }).catch(function () {});
87
+ else say("Sign in to it in the window that opened, then come back here.");
88
+ });
89
+ document.getElementById("go").addEventListener("submit", function (e) { e.preventDefault(); var u = fromInput(document.getElementById("addr").value); if (u) openSite(u); });
90
+ document.getElementById("done").addEventListener("click", function () { say("Closing, and checking your sites…"); api("/api/browser/done", {}).catch(function () {}); });
91
+ load(); setInterval(load, 5000);
92
+ </script>
93
+ </body>
94
+ </html>
package/headless.mjs CHANGED
@@ -19,9 +19,9 @@
19
19
  // The browser keeps its own profile in ~/.config/symbiot/browser: sign in to a
20
20
  // site once there (signIn opens it as a normal window) and later maps see it
21
21
  // signed in. It's separate from your everyday browser profile.
22
- import { spawn } from "node:child_process";
22
+ import { spawn, spawnSync } from "node:child_process";
23
23
  import { join, resolve as resolvePath } from "node:path";
24
- import { mkdirSync, existsSync, statSync } from "node:fs";
24
+ import { mkdirSync, existsSync, statSync, readlinkSync } from "node:fs";
25
25
  import { CONFIG_DIR, chromeBinary, loadConfig, saveConfig } from "./core.mjs";
26
26
  import { loadScreens, addPageScreen, center, stitchPng, stitchListPng } from "./screens.mjs";
27
27
 
@@ -98,6 +98,18 @@ const browserOpen = () => !!(live && !live.c.closed());
98
98
  // Don't leave it running when Symbiot exits (a restart, Ctrl+C).
99
99
  process.once("exit", () => { if (live && live.proc.exitCode === null) try { live.proc.kill("SIGKILL"); } catch {} });
100
100
 
101
+ // Is a visible Chrome (the Symbiot Browser) using Symbiot's profile? Its SingletonLock names the process
102
+ // ("host-1234", Linux and Mac); a lock whose process has gone is a leftover.
103
+ const OWN = new Set(); // every hidden Chrome this Symbiot started
104
+ function windowOpen() {
105
+ try {
106
+ const m = /-(\d+)$/.exec(readlinkSync(join(PROFILE, "SingletonLock")));
107
+ if (!m) return false;
108
+ if (OWN.has(Number(m[1]))) return false; // our own hidden browser (running, or still closing) isn't a window
109
+ process.kill(Number(m[1]), 0);
110
+ return true;
111
+ } catch { return false; }
112
+ }
101
113
  // Start the hidden browser and open a tab: { proc, c, page: { send, until, idle } }.
102
114
  async function launch() {
103
115
  const chrome = chromeBinary();
@@ -105,7 +117,11 @@ async function launch() {
105
117
  ? "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."
106
118
  : "Mapping a page needs Chrome, Chromium, Edge or Brave, and none was found.");
107
119
  mkdirSync(PROFILE, { recursive: true, mode: 0o700 });
120
+ // the Symbiot Browser (or a sign-in window) has the profile: a second Chrome would hand
121
+ // over to it and pop an empty window into it, so wait for Done instead
122
+ if (windowOpen()) throw new Error("The Symbiot Browser is open: Symbiot reads your sites once you click Done in it.");
108
123
  const proc = spawn(chrome, browserArgs(true), { stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"] });
124
+ if (proc.pid) OWN.add(proc.pid);
109
125
  let err = ""; proc.stderr.on("data", (d) => { err = (err + d).slice(-2000); });
110
126
  const failed = new Promise((resolve, reject) => { proc.on("error", reject); }); failed.catch(() => {});
111
127
  const c = connect(proc), b = { proc, c, shown: "", timer: null };
@@ -801,13 +817,53 @@ function uploadRegion(id, regionId, files, { confirmed = false } = {}) {
801
817
  // accept cookies) once. Close it before mapping: the profile is shared. The hidden
802
818
  // browser is closed first, or the window would open in it, out of sight.
803
819
  async function signIn(input) {
804
- const url = siteUrl(input), chrome = chromeBinary();
820
+ const url = siteUrl(input);
805
821
  if (!url) return { error: "Give the site to sign in to: a name (gmail), a host or a web address." };
806
- if (!chrome) return { error: "Signing in needs Chrome, Chromium, Edge or Brave, and none was found." };
822
+ return openSymbiotBrowser(url);
823
+ }
824
+
825
+ // ---- the Symbiot Browser --------------------------------------------------------
826
+ // Where you sign in to your sites for Symbiot: a window of its own, named and marked
827
+ // as Symbiot's, on Symbiot's browser profile (so its sign-ins are the ones the hidden
828
+ // browser reads with). It opens on its own page (browser.html, served by the app:
829
+ // your sites, signed in or not, a Sign in for each, an address bar, Done), plus the
830
+ // site asked for in a window beside it. Chrome lets one copy use a profile at a time,
831
+ // and a window asked of a hidden copy opens hidden: so any hidden copy on this profile
832
+ // is closed first, Symbiot's or a stray one (Linux, Mac).
833
+ let HUB = ""; // the app's address for that page: http://127.0.0.1:<port>/browser?t=<token>
834
+ function setBrowserHub(url) { HUB = String(url || ""); }
835
+ function profilePids({ headless } = {}) {
836
+ if (process.platform === "win32") return [];
837
+ try {
838
+ const out = spawnSync("ps", ["-eo", "pid=,args="], { encoding: "utf8", timeout: 5000 }).stdout || "";
839
+ return out.split("\n").map((l) => l.trim().match(/^(\d+)\s+(.*)$/)).filter(Boolean)
840
+ .filter(([, , a]) => a.includes("--user-data-dir=" + PROFILE) && !/--type=/.test(a) && (headless === undefined || /--headless/.test(a) === headless))
841
+ .map(([, pid]) => Number(pid)).filter((p) => p && p !== process.pid);
842
+ } catch { return []; }
843
+ }
844
+ const kill = (pids) => { for (const p of pids) try { process.kill(p, "SIGTERM"); } catch {} };
845
+ async function openSymbiotBrowser(url = "") {
846
+ const chrome = chromeBinary();
847
+ if (!chrome) return { error: "The Symbiot Browser needs Chrome, Chromium, Edge or Brave on this computer, and none was found." };
807
848
  await oneAtATime(closeBrowser);
808
- try { mkdirSync(PROFILE, { recursive: true, mode: 0o700 }); spawn(chrome, browserArgs(false, url), { detached: true, stdio: "ignore" }).unref(); }
809
- catch (e) { return { error: String((e && e.message) || e) }; }
810
- return { ok: true, url };
849
+ const hidden = profilePids({ headless: true }); if (hidden.length) { kill(hidden); await sleep(800); }
850
+ try {
851
+ mkdirSync(PROFILE, { recursive: true, mode: 0o700 });
852
+ const base = ["--user-data-dir=" + PROFILE, "--no-first-run", "--no-default-browser-check",
853
+ ...(process.platform === "linux" ? ["--password-store=basic", "--class=SymbiotBrowser"] : process.platform === "darwin" ? ["--use-mock-keychain"] : [])];
854
+ const page = HUB ? ["--app=" + HUB, "--window-size=1040,820"] : [];
855
+ if (page.length) spawn(chrome, [...base, ...page], { detached: true, stdio: "ignore" }).unref();
856
+ if (url) setTimeout(() => { try { spawn(chrome, [...base, "--new-window", url], { detached: true, stdio: "ignore" }).unref(); } catch {} }, page.length ? 1500 : 0);
857
+ if (!page.length && !url) spawn(chrome, [...base, "--new-window", "about:blank"], { detached: true, stdio: "ignore" }).unref();
858
+ } catch (e) { return { error: String((e && e.message) || e) }; }
859
+ return { ok: true, ...(url ? { url } : {}), browser: true };
860
+ }
861
+ // Done: the Symbiot Browser closes (its sign-ins are saved), so the hidden browser can read with them.
862
+ async function closeSymbiotBrowser() {
863
+ const open = profilePids({ headless: false });
864
+ kill(open);
865
+ for (let i = 0; i < 20 && profilePids({ headless: false }).length; i++) await sleep(250);
866
+ return { closed: open.length };
811
867
  }
812
868
 
813
- 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, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
869
+ 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, trustedSites, isTrusted, trustSite, untrustSite, PROFILE , openSymbiotBrowser, closeSymbiotBrowser, setBrowserHub };
package/index.mjs CHANGED
@@ -351,6 +351,15 @@ async function cmdAway() {
351
351
  if (!r) { console.log(c.y("Symbiot's app isn't running.") + c.d(" Start it with symbiot app, then Away works.")); process.exitCode = 1; return; }
352
352
  if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; }
353
353
  }
354
+ // symbiot browser [site]: the Symbiot Browser, where you sign in to your sites for
355
+ // Symbiot (headless.mjs openSymbiotBrowser), opened by the running app.
356
+ async function cmdBrowser() {
357
+ const site = argv.slice(1).find((a) => !a.startsWith("-")) || "";
358
+ const r = await viaApp("/api/browser/open", site ? { url: site } : {});
359
+ if (!r) { console.log(c.y("Symbiot's app isn't running.") + c.d(" Open Symbiot first, then the Symbiot Browser opens from Settings → Links, or with symbiot browser.")); process.exitCode = 1; return; }
360
+ if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; return; }
361
+ console.log(`${c.g("✓")} Opened the Symbiot Browser${site ? " at " + (r.url || site) : ""}. Sign in to your sites there, then click Done.`);
362
+ }
354
363
  // symbiot open: what the app-menu icon runs. Running: its window comes up. Not
355
364
  // running: it starts in the background (no terminal; what it prints goes to
356
365
  // ~/.config/symbiot/app.log) and opens its own window.
@@ -446,7 +455,8 @@ async function cmdScreens() {
446
455
  symbiot screens whole <id> map all of that page in one tall screenshot
447
456
  (where a list scrolls inside the page, like
448
457
  Gmail's mail, that list opened out)
449
- symbiot screens signin <site> sign in once, in Symbiot's browser window
458
+ symbiot browser [site] the Symbiot Browser: sign in to your sites for Symbiot
459
+ symbiot screens signin <site> sign in once, in the Symbiot Browser
450
460
  --yes is needed (press, type, upload) unless the page's site is under Trusted sites in the app's Settings.
451
461
  While the app runs, these use its hidden browser, which stays open a few minutes:
452
462
  press on the screen the last command printed carries on from that page as it is
@@ -926,6 +936,7 @@ async function main() {
926
936
  // an old copy npm replaced), and Claude Code won't run from a deleted folder
927
937
  if (cmd === "app" || cmd === "ui") { try { process.chdir(homedir()); } catch {} return cmdApp(); }
928
938
  if (cmd === "away") return cmdAway();
939
+ if (cmd === "browser") return cmdBrowser();
929
940
  if (cmd === "open") return cmdOpen();
930
941
  if (cmd === "uninstall") return cmdUninstall();
931
942
  if (cmd === "models" || cmd === "hardware") return cmdModels();
package/links.mjs CHANGED
@@ -89,7 +89,7 @@ function stateOf(e, l, watches) {
89
89
  // finished signing in in the window is still "sign in"
90
90
  if (w.error && /^Signed out/.test(w.error)) return w.checked ? { state: "signedout", note: "Signed out: click it to sign in again." } : { state: "signin" };
91
91
  if (w.checked) return { state: "ok", checked: w.checked, ...(w.error ? { note: w.error } : {}) };
92
- return { state: "signin", ...(w.error && !/in use|busy|already running/i.test(w.error) ? { note: w.error } : {}) };
92
+ return { state: "signin", ...(w.error && !/in use|busy|already running|Symbiot Browser is open/i.test(w.error) ? { note: w.error } : {}) };
93
93
  }
94
94
  function linksState() {
95
95
  const c = catalog(), l = linked(), watches = watchState().watches;
@@ -98,7 +98,7 @@ function linksState() {
98
98
  return { items, groups, linked: items.filter((x) => x.state !== "off").length, ...(c.file ? { file: c.file } : {}), ...(c.error ? { error: c.error } : {}) };
99
99
  }
100
100
 
101
- // Link one: trust its hosts, watch its page, open it to sign in. Clicking a link
101
+ // Link one: trust its hosts, watch its page, open it to sign in (in the Symbiot Browser). Clicking a link
102
102
  // that's already there just opens it again to sign in (signed out, or a second
103
103
  // account). Gives { ok, url, item } or { error }.
104
104
  // what counts as an inbox or chat for Free's one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.60.0",
3
+ "version": "0.61.0",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,7 +54,8 @@
54
54
  "claudesetup.mjs",
55
55
  "voice.mjs",
56
56
  "licence.mjs",
57
- "lang.mjs"
57
+ "lang.mjs",
58
+ "browser.html"
58
59
  ],
59
60
  "engines": {
60
61
  "node": ">=18"
@@ -87,7 +88,7 @@
87
88
  "@anthropic-ai/sdk": "^0.128.0"
88
89
  },
89
90
  "scripts": {
90
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/drafts.mjs && node test/linkedin.mjs && node test/secrets.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/licence.mjs && node test/lang.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/tray.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
91
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/drafts.mjs && node test/linkedin.mjs && node test/secrets.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/licence.mjs && node test/lang.mjs && node test/browser.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/tray.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
91
92
  "lint": "eslint .",
92
93
  "postinstall": "node postinstall.mjs"
93
94
  },
package/server.mjs CHANGED
@@ -25,7 +25,7 @@ import { computeDrift } from "./drift.mjs";
25
25
  import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
26
26
  import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
27
27
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
28
- import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
28
+ import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite, openSymbiotBrowser, closeSymbiotBrowser, setBrowserHub, siteUrl } from "./headless.mjs";
29
29
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart, installLauncher, iconSvg, setLauncherLook } from "./desktop.mjs";
30
30
  import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
31
31
  import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
@@ -224,6 +224,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
224
224
  if (!process.env.SYMBIOT_FORCE_NEW && !RELAUNCH) {
225
225
  const p = await askRunningApp("ping", { port: PORT, token: TOKEN });
226
226
  if (p && p.version) {
227
+ setBrowserHub(`http://127.0.0.1:${PORT}/browser?t=${TOKEN}`);
227
228
  const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url);
228
229
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
229
230
  console.log(how ? c.d(` Opened the existing window (${/^[aeiou]/.test(how) ? "an" : "a"} ${how}).`) : c.d(" Open that URL in your browser."));
@@ -251,6 +252,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
251
252
  const t0 = Date.now(); res.on("finish", () => { const ms = Date.now() - t0; if (ms > 1500) console.log(`slow: ${String(req.url || "").split("?")[0]} took ${(ms / 1000).toFixed(1)}s`); });
252
253
  const u = new URL(req.url, "http://127.0.0.1");
253
254
  if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
255
+ // the Symbiot Browser's own page (headless.mjs openSymbiotBrowser): like /, it gets its token in ?t=
256
+ if (req.method === "GET" && u.pathname === "/browser") { try { res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); res.end(readFileSync(new URL("./browser.html", import.meta.url))); } catch { res.writeHead(404); res.end(); } return; }
254
257
  // the app's typeface ships in the package (fonts/), so it's there offline
255
258
  // the window's own icon (its taskbar entry), the same orb as the app menu's
256
259
  if (req.method === "GET" && (u.pathname === "/favicon.svg" || u.pathname === "/favicon.ico")) {
@@ -378,7 +381,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
378
381
  if (u.pathname === "/api/mind/forget" && req.method === "POST") { const b = await readBody(req); return json(res, forget(String(b.id || ""))); }
379
382
  // Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
380
383
  if (u.pathname === "/api/links") { let reach = null; try { reach = linkReach(); } catch {} return json(res, { ...linksState(), ...(reach ? { reach } : {}) }); } // reach: which sites agent runs can use too (agents.mjs)
381
- if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
384
+ if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""), b.here ? { open: async (url) => ({ ok: true, url }) } : undefined)); } // here: from the Symbiot Browser, which opens the site itself
385
+ if (u.pathname === "/api/browser/open" && req.method === "POST") { const b = await readBody(req); if (process.env.SYMBIOT_NO_OPEN === "1") return json(res, { ok: false, note: "not opened (SYMBIOT_NO_OPEN)" }); return json(res, await openSymbiotBrowser(b.url ? siteUrl(String(b.url)) : "")); }
386
+ if (u.pathname === "/api/browser/done" && req.method === "POST") { if (process.env.SYMBIOT_NO_OPEN === "1") return json(res, { closed: 0 }); const r = await closeSymbiotBrowser(); setTimeout(() => { for (const it of linksState().items) if (it.state !== "off" && it.state !== "ok") checkLink(it.id).catch(() => {}); }, 1500); return json(res, { ...r, checking: true }); }
382
387
  if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
383
388
  if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
384
389
  // Posts (post.mjs): the week's drafts, waiting on you. Approve is your yes: Marketing's
package/ui.mjs CHANGED
@@ -1226,6 +1226,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
1226
1226
  </div>
1227
1227
  </div>
1228
1228
  <div class="sset">
1229
+ <div class="row" id="sbrow" style="margin-bottom:12px"><span style="flex:1"><b>The Symbiot Browser</b><br><span class="muted">Symbiot’s own browser window, where you sign in to your sites once so Symbiot can read them for you.</span></span><button class="act" id="sbopen">Open the Symbiot Browser</button></div>
1229
1230
  <div id="links"></div>
1230
1231
  <div id="linksmsg"></div>
1231
1232
  <div class="note muted" id="linksnote">For a whole team, one <b>links.json</b> in Symbiot's config folder adds your company's own sites and hides the ones you don't use, so everyone gets the same buttons.</div>
@@ -2008,15 +2009,16 @@ var rc=reachOf(d,it.id),rt=rc?(rc.ready?" Your agent's runs can use it too, thro
2008
2009
  return "<div class='lnk cx "+escQ(it.state)+"' data-id='"+escQ(it.id)+"'><button type='button' class='lbtn' title='"+escQ(String(it.note||(on?'open '+it.url+' to sign in again':'sign in at '+it.url+' and link it')).replace(/\.?\s*$/,rt?'.':'')+rt)+"'><span class='cxb' aria-hidden='true'></span><span class='cxn'>"+esc(it.name)+"</span>"+(rc?"<i class='ic-plug cxa"+(rc.ready?" ready":"")+"' role='img' aria-label='"+escQ(rc.ready?"agent runs can use it too":"agent runs can use it once connected in claude.ai")+"'></i>":"")+"<span class='cxs'>"+(on?esc(LINKWORD[it.state]||''):'Link')+"</span></button>"+
2009
2010
  (on?"<button type='button' class='lmore' title='check it now' aria-label='Check "+escQ(it.name)+" now'>&#8635;</button><button type='button' class='lrm' title='unlink: stop watching it and stop trusting it'>Unlink</button>":"")+"</div>";}).join('')+"</div>";}).join('')+"</div>";return h;}
2010
2011
  function linksOut(html){var o=document.getElementById('linksmsg');if(o)o.innerHTML=html;var b=document.getElementById('boardmsg');if(b&&LINKS&&!LINKS.linked)b.innerHTML=html;}
2012
+ function sbOpen(url){api('/api/browser/open',url?{url:url}:{}).then(function(r){if(r&&r.error)linksOut("<div class='note err'>"+esc(r.error)+"</div>");}).catch(function(){});}
2011
2013
  function renderLinks(){if(!LINKS)return;['links','boardlinks'].forEach(function(id){var el=document.getElementById(id);if(!el)return;
2012
2014
  el.innerHTML=linksHtml(LINKS,id==='boardlinks');
2013
2015
  el.querySelectorAll('.lnk').forEach(function(sp){var lid=sp.getAttribute('data-id'),nm=sp.querySelector('.cxn'),name=nm?nm.textContent:sp.querySelector('.lbtn').textContent;
2014
2016
  sp.querySelector('.lbtn').addEventListener('click',function(){linksOut("<div class='note muted'>Opening "+esc(name)+"&hellip;</div>");
2015
2017
  api('/api/links/link',{id:lid}).then(function(r){if(!r||r.error){linksOut("<div class='note err'>"+esc(name)+": "+esc((r&&r.error)||'failed')+"</div>");loadLinks();return;}
2016
- linksOut("<div class='note ok'>Opened "+esc(name)+" in Symbiot's browser. Sign in there as you normally do, then close that window: Symbiot checks it within a minute.</div>");loadLinks();
2018
+ linksOut("<div class='note ok'>Opened the Symbiot Browser with "+esc(name)+". Sign in there as you normally do, then click Done in the Symbiot Browser: Symbiot checks it within a minute.</div>");loadLinks();
2017
2019
  [20000,60000,120000].forEach(function(ms){setTimeout(loadLinks,ms);});});});
2018
2020
  var m=sp.querySelector('.lmore');if(m)m.addEventListener('click',function(){m.disabled=true;api('/api/links/check',{id:lid}).then(function(r){m.disabled=false;
2019
- if(r&&r.busy)linksOut("<div class='note muted'>Symbiot's browser is busy (a sign-in window is open?). Close it, then check again.</div>");else if(r&&r.error)linksOut("<div class='note err'>"+esc(r.error)+"</div>");loadLinks();loadBoard();});});
2021
+ if(r&&r.busy)linksOut("<div class='note muted'>The Symbiot Browser is open. Click Done in it when you’ve signed in, then check again.</div>");else if(r&&r.error)linksOut("<div class='note err'>"+esc(r.error)+"</div>");loadLinks();loadBoard();});});
2020
2022
  var x=sp.querySelector('.lrm');if(x)x.addEventListener('click',function(){api('/api/links/unlink',{id:lid}).then(function(){linksOut('');loadLinks();loadTrusted();loadBoard();});});});});}
2021
2023
  // What Symbiot remembers (mind.mjs): a row per thing, with what it knows; × forgets one.
2022
2024
  function loadMind(){api('/api/mind').then(function(d){var el=document.getElementById('mindlist');if(!el||!d)return;var ns=d.nodes||[];
@@ -2183,7 +2185,7 @@ var DRAFTING_CHAT="&#10003; Your agent is drafting a reply in that chat. It open
2183
2185
  var OPEN_WA_TIP="open WhatsApp in Symbiot&#39;s browser, where the chat shows your agent&#39;s reply in its message box, for you to read and send";
2184
2186
  function openChatUI(n,b,say){if(!n)return;b.disabled=true;
2185
2187
  api('/api/watch/open-chat',{id:n.id}).then(function(x){b.disabled=false;if(!x||x.error){say('err',esc((x&&x.error)||'failed'));return;}
2186
- say('ok',"&#10003; Opened WhatsApp in Symbiot's browser, in a window of its own. The chat shows your agent's reply in its message box: read it, change it if you like, and send it there. Close the window when you're done, so Watch can read WhatsApp again.");}).catch(function(e){b.disabled=false;say('err',esc(String((e&&e.message)||e)));});}
2188
+ say('ok',"&#10003; Opened WhatsApp in the Symbiot Browser. The chat shows your agent's reply in its message box: read it, change it if you like, and send it there. Close the window when you're done, so Watch can read WhatsApp again.");}).catch(function(e){b.disabled=false;say('err',esc(String((e&&e.message)||e)));});}
2187
2189
  var DRAFTING="&#10003; Your agent is drafting a reply to it. It opens the email in your inbox through Screens, writes the reply and leaves it in Drafts for you to read and send: it never presses Send. Follow it, and answer anything it asks, in the Agents tab.";
2188
2190
  // a LinkedIn comment or mention (watch.mjs socialBrief): typed into the comment box, never posted
2189
2191
  var DRAFT_SOCIAL_TIP="your coding agent opens it on LinkedIn in Symbiot&#39;s hidden browser and types a reply into the comment box, unposted. It never presses Post, Comment, Reply or Send";
@@ -3543,5 +3545,5 @@ var G=lqGL();if(G){try{lqStep();lqDraw(G);}catch(e){}}if(typeof window.requestAn
3543
3545
  function frame(){if(!document.hidden){LQ.frame++;if(LQ.mode!=='pool'||LQ.frame%3===0)lqStep();var every=(GLSOFT||GLSTILL)?glEvery(LQ.mode==='aware'):LQ.mode==='pool'?6:LQ.mode==='rest'?2:1;if(G&&LQ.frame%every===0)lqDraw(G);}raf(frame);} // moves every frame; drawn less at rest, behind a panel, without a GPU
3544
3546
  raf(frame);}
3545
3547
 
3546
- initGraphEvents();syncP();refresh();firstTab();lqInit();onbLoad();var rr=$('rerunsetup');if(rr)rr.addEventListener('click',function(){api('/api/onboarding/restart',{}).then(function(){lqSink();onbLoad();});});awayInit();lqDeep();loadWhatsNew();loadAgentCfg();loadScanRoots();loadKnowledge();loadPhone();loadLinks();loadMind();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(function(){whenFree(document.querySelector('main'),'watch',loadWatchUI);},60000);loadPhoneLink();
3548
+ initGraphEvents();syncP();refresh();firstTab();lqInit();onbLoad();var sbo=$('sbopen');if(sbo)sbo.addEventListener('click',function(){sbOpen('');});var rr=$('rerunsetup');if(rr)rr.addEventListener('click',function(){api('/api/onboarding/restart',{}).then(function(){lqSink();onbLoad();});});awayInit();lqDeep();loadWhatsNew();loadAgentCfg();loadScanRoots();loadKnowledge();loadPhone();loadLinks();loadMind();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(function(){whenFree(document.querySelector('main'),'watch',loadWatchUI);},60000);loadPhoneLink();
3547
3549
  </script></body></html>`;