symbiot 0.54.4 → 0.55.1

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,17 @@
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.55.1
6
+
7
+ - **No more stray Symbiot windows.** 0.55.0's smoke test called Away like every other endpoint, so each full test run (yours, or an agent's in the symbiot repo) opened Away windows on your screen. Away now opens only when asked to by name, and never while windows are turned off (as in tests).
8
+ - **One Symbiot window, not a pile.** Opening Symbiot again (its icon, `symbiot app`) used to add a window each time. Now the newest window wins and older ones close themselves; a tab that can't close itself says Symbiot is open in a newer window, with **Use this one**. An update's restart that can't get its port bows out instead of lingering unseen.
9
+ - **Away is Super+`** (Super and the key above Tab). Super+S stacks windows on COSMIC, so it was the wrong key; `symbiot away --shortcut` sets up Super+` and clears the old one.
10
+
11
+ ## 0.55.0
12
+
13
+ - **Away: Super+S.** Symbiot full screen while you're away from the desk: the orb at rest, the time, the agents at work and what each is doing, how many things only you can do, and how much waits on what you watch. Counts only, never anyone's words. Any key or click brings you back. On X11 with more than one screen the orb bounces across all of them; under Wayland (COSMIC, GNOME, KDE) it's one window on your screen, because no app may place windows there. `symbiot away --shortcut` sets up Super+S (on COSMIC it's done for you). Super+Esc still locks.
14
+ - **Approve no longer strands a commit.** It picked a branch name only by what's local, so a name already on GitHub (an earlier PR of that name, merged) had its push refused, and the approved work sat in a commit with no PR. Now it picks a name free on GitHub too, and if a push is still refused, it moves the commit to a fresh name and pushes that.
15
+
5
16
  ## 0.54.4 — 2026-10-07
6
17
 
7
18
  - Removed the "Add a task" row from Tasks, since you add tasks through the bottom chat bar, and moved Send to repos beside Close.
package/README.md CHANGED
@@ -643,6 +643,23 @@ of this: it adapts to you.
643
643
  Everything it learns stays on this computer: `adapt.json` and `mind.json` in
644
644
  Symbiot's config folder, readable by you only.
645
645
 
646
+ ### Away: Super+`
647
+
648
+ Stepping away? **Super+`** (Super and the key above Tab), or `symbiot away`, puts Symbiot full screen: the orb at
649
+ rest, the time, and what's going on. That means the agents at work and what each is
650
+ doing, how many things only you can do, and how much waits on what you watch. It shows
651
+ counts only, never anyone's words, so it's fine on a screen across the room. Any key
652
+ or click (or Super+` again) brings you back.
653
+
654
+ On one screen the orb rests in the middle. On X11 desktops with more screens it
655
+ bounces across all of them, one window per screen, with the orb crossing from one to
656
+ the next. Under Wayland (COSMIC, GNOME, KDE) no app may place a window on a screen, so
657
+ it's one window on the screen you're on.
658
+
659
+ `symbiot away --shortcut` sets up Super+`. On COSMIC that's done for you; elsewhere
660
+ it tells you what to bind.
661
+ Away isn't a lock: **Super+Esc** still locks the computer.
662
+
646
663
  ## Link your work
647
664
 
648
665
  **Connections** (on the Dashboard, and in Settings) has one entry per standard work site: Gmail,
package/away.mjs ADDED
@@ -0,0 +1,113 @@
1
+ // symbiot — Away: Symbiot full screen on every screen while you're away from the
2
+ // desk (Super+`, or `symbiot away`): the orb at rest, the time, and what's going on
3
+ // (agents at work and what they're doing, how much waits on you), counts only, no
4
+ // one's words. One screen: the orb in the middle. More: it bounces across all of
5
+ // them, one kiosk window per screen, each working out where the orb is from the
6
+ // same clock over the screens' shared area and drawing it while it's on that
7
+ // screen, so it crosses from one to the next with nothing passed between them.
8
+ // That needs windows placed on screens, which only X allows: under Wayland (COSMIC,
9
+ // GNOME, KDE) no app can put a window on a screen, and COSMIC ignored every way tried
10
+ // (X moves, Chrome's position flags, fullscreen on a given screen, a keypress for its
11
+ // own move-to-screen shortcut). So under Wayland it's one window, full screen where
12
+ // you are, the orb in the middle. Each window's profile lets Symbiot's address see the
13
+ // screens and go full screen without a click, so nothing asks.
14
+ // Any key or click, or Super+` again, closes every window. Not a lock: Super+Esc
15
+ // locks the computer.
16
+ import { spawn, spawnSync } from "node:child_process";
17
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import { CONFIG_DIR, chromeBinary } from "./core.mjs";
21
+
22
+ // The screens, from `xrandr --listmonitors` (X, and XWayland under COSMIC, GNOME and
23
+ // KDE): [{ x, y, w, h }], left to right. [] when it can't tell (then one window).
24
+ function parseMonitors(text) {
25
+ const out = [];
26
+ for (const m of String(text || "").matchAll(/^\s*\d+:\s+\S+\s+(\d+)\/\d+x(\d+)\/\d+\+(-?\d+)\+(-?\d+)/gm)) out.push({ w: +m[1], h: +m[2], x: +m[3], y: +m[4] });
27
+ return out.sort((a, b) => a.x - b.x || a.y - b.y);
28
+ }
29
+ function screens() {
30
+ for (const d of [process.env.DISPLAY, ":0", ":1"].filter(Boolean)) {
31
+ const r = spawnSync("xrandr", ["--listmonitors"], { encoding: "utf8", timeout: 3000, env: { ...process.env, DISPLAY: d } });
32
+ const s = r.status === 0 ? parseMonitors(r.stdout) : [];
33
+ if (s.length) return { list: s, display: d };
34
+ }
35
+ return { list: [], display: "" };
36
+ }
37
+ // Each window's place in the shared area: { x, y, w, h } plus the area's size, as
38
+ // the page's query (?away=1&ax=…&ay=…&gw=…&gh=…&n=…&t0=…).
39
+ function awayQueries(list, t0 = Date.now()) {
40
+ if (!list.length) return [`away=1&n=1&t0=${t0}`];
41
+ const x0 = Math.min(...list.map((s) => s.x)), y0 = Math.min(...list.map((s) => s.y));
42
+ const gw = Math.max(...list.map((s) => s.x + s.w)) - x0, gh = Math.max(...list.map((s) => s.y + s.h)) - y0;
43
+ return list.map((s) => `away=1&n=${list.length}&ax=${s.x - x0}&ay=${s.y - y0}&aw=${s.w}&ah=${s.h}&gw=${gw}&gh=${gh}&t0=${t0}`);
44
+ }
45
+
46
+ let KIDS = [];
47
+ const awayOpen = () => KIDS.some((k) => k.exitCode === null && !k.killed);
48
+ function closeAway() {
49
+ for (const k of KIDS) { try { process.kill(-k.pid, "SIGTERM"); } catch { try { k.kill("SIGTERM"); } catch {} } }
50
+ const n = KIDS.length; KIDS = []; return n;
51
+ }
52
+ // The two permissions an Away window needs, for the app's address only, in the
53
+ // window's own profile: the screens (window_placement) and fullscreen without a
54
+ // click (automatic_fullscreen). Chrome keeps them when it writes the file back.
55
+ function grantProfile(dir, origin) {
56
+ const f = join(dir, "Default", "Preferences"); let p = {}; try { p = JSON.parse(readFileSync(f, "utf8")) || {}; } catch {}
57
+ const ex = (((p.profile ||= {}).content_settings ||= {}).exceptions ||= {});
58
+ for (const k of ["window_placement", "automatic_fullscreen"]) (ex[k] ||= {})[`${origin},*`] = { setting: 1 };
59
+ try { mkdirSync(join(dir, "Default"), { recursive: true }); writeFileSync(f, JSON.stringify(p)); } catch {}
60
+ }
61
+ // Opens a window per screen on the app's url (http://127.0.0.1:port/?t=token), the
62
+ // i-th told it's for screen i (si). Each has a profile of its own under Symbiot's
63
+ // config, so its flags take (a window for a profile already open would join that
64
+ // browser and ignore them).
65
+ const placeable = (env = process.env) => (env.XDG_SESSION_TYPE || "").toLowerCase() === "x11" || (!env.WAYLAND_DISPLAY && !!env.DISPLAY);
66
+ function openAway(url, { chrome = chromeBinary(), scr = placeable() ? screens() : { list: [], display: "" }, now = Date.now() } = {}) {
67
+ if (!chrome) return { error: "Away needs Chrome or Chromium on this computer." };
68
+ if (process.env.SYMBIOT_NO_OPEN === "1") return { open: false, note: "Windows are off here (SYMBIOT_NO_OPEN): tests, and runs that mustn't open any." };
69
+ closeAway();
70
+ const qs = awayQueries(scr.list, now);
71
+ const origin = (String(url).match(/^https?:\/\/[^/?#]+/) || [""])[0];
72
+ qs.forEach((q, i) => {
73
+ const s = scr.list[i], dir = join(CONFIG_DIR, "away", String(i)); grantProfile(dir, origin);
74
+ const args = ["--user-data-dir=" + dir, "--no-first-run", "--no-default-browser-check", "--disable-session-crashed-bubble", "--hide-crash-restore-bubble", "--noerrdialogs", "--kiosk"];
75
+ if (s) args.push(`--window-position=${s.x},${s.y}`, `--window-size=${s.w},${s.h}`); // X places it on its screen
76
+ args.push(url + (url.includes("?") ? "&" : "?") + q + `&si=${i}`);
77
+ try { const k = spawn(chrome, args, { detached: true, stdio: "ignore", env: { ...process.env, ...(s && scr.display ? { DISPLAY: scr.display } : {}) } }); k.on("exit", () => {}); k.unref(); KIDS.push(k); } catch {}
78
+ });
79
+ return { open: KIDS.length > 0, screens: Math.max(1, scr.list.length) };
80
+ }
81
+ // open: true opens, false closes, "toggle" goes the other way; anything else only
82
+ // says whether it's open (a call that names nothing never opens windows: the smoke
83
+ // test calls every endpoint, and it opened Away on the owner's screen every run).
84
+ function toggleAway(url, open, deps) {
85
+ if (open !== true && open !== false && open !== "toggle") return { open: awayOpen() };
86
+ const want = open === "toggle" ? !awayOpen() : open;
87
+ if (!want) { closeAway(); return { open: false }; }
88
+ return openAway(url, deps);
89
+ }
90
+
91
+ // ---- the shortcut -------------------------------------------------------------
92
+ // Super+` (the key above Tab) runs `symbiot away`: free on COSMIC, where Super+S
93
+ // stacks windows. COSMIC: a custom shortcut in its config (it reloads it as it
94
+ // changes). Elsewhere it says how to add one.
95
+ const COSMIC_CUSTOM = (home = homedir()) => join(home, ".config", "cosmic", "com.system76.CosmicSettings.Shortcuts", "v1", "custom");
96
+ const KEY = "grave";
97
+ function shortcutLine(cmd) { return ` (modifiers: [Super], key: "${KEY}"): Spawn(${JSON.stringify(cmd)}),`; }
98
+ // The new custom file: whatever was there, minus any Super+` and any earlier one of
99
+ // ours (on another key), plus ours.
100
+ function withShortcut(text, cmd) {
101
+ const body = String(text || "").trim().replace(/^\{/, "").replace(/\}\s*$/, "");
102
+ const kept = body.split("\n").filter((l) => l.trim() && !/\(modifiers:\s*\[\s*Super\s*,?\s*\],\s*key:\s*"grave"\s*\)/.test(l) && !/Spawn\("[^"]*symbiot[^"]* away"\)/.test(l));
103
+ return "{\n" + [...kept.map((l) => l.replace(/\s*$/, "")), shortcutLine(cmd)].join("\n") + "\n}\n";
104
+ }
105
+ function installShortcut({ cmd, home = homedir(), desktop = process.env.XDG_CURRENT_DESKTOP || "" } = {}) {
106
+ if (!/cosmic/i.test(desktop)) return { manual: true, note: `Add a keyboard shortcut in your desktop's settings: Super+\` runs ${cmd}` };
107
+ const f = COSMIC_CUSTOM(home);
108
+ let had = ""; try { had = existsSync(f) ? readFileSync(f, "utf8") : ""; } catch {}
109
+ try { mkdirSync(join(f, ".."), { recursive: true }); writeFileSync(f, withShortcut(had, cmd)); } catch (e) { return { error: "Couldn't write " + f + ": " + ((e && e.message) || e) }; }
110
+ return { ok: true, file: f, replaced: /key:\s*"grave"/.test(had) };
111
+ }
112
+
113
+ export { placeable, grantProfile, parseMonitors, screens, awayQueries, openAway, closeAway, awayOpen, toggleAway, withShortcut, installShortcut, COSMIC_CUSTOM };
package/index.mjs CHANGED
@@ -328,6 +328,20 @@ async function viaApp(path, body) {
328
328
  return r.ok ? await r.json() : { error: `Symbiot's app answered ${r.status}.` };
329
329
  } catch (e) { return { error: "Lost Symbiot's app mid-way: " + ((e && e.message) || e) }; }
330
330
  }
331
+ // symbiot away: Symbiot full screen on every screen (again: closes it). --shortcut:
332
+ // Super+` runs it (on COSMIC it's set up; elsewhere it says how).
333
+ async function cmdAway() {
334
+ if (has("shortcut")) {
335
+ const { installShortcut } = await import("./away.mjs");
336
+ const r = installShortcut({ cmd: `${process.execPath} ${process.argv[1]} away` });
337
+ if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; return; }
338
+ console.log(r.manual ? r.note : `${c.g("✓")} Super+\` (the key above Tab) now opens Away. ${c.d(r.file)}`);
339
+ return;
340
+ }
341
+ const r = await viaApp("/api/away", { open: "toggle" });
342
+ 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; }
343
+ if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; }
344
+ }
331
345
  async function cmdScreens() {
332
346
  const [sub = "list", a1, a2, a3] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--name");
333
347
  const out = (x) => { console.log(JSON.stringify(x, null, 2)); if (x && x.error) process.exitCode = 1; };
@@ -753,6 +767,8 @@ ${c.b("Usage")}
753
767
  symbiot standup yesterday + today, for standup
754
768
  symbiot todo what's still on your plate
755
769
  symbiot app open the visual app in your browser
770
+ symbiot away [--shortcut] Symbiot full screen on every screen while you're away;
771
+ again closes it. --shortcut: Super+\` does it
756
772
  symbiot app --fresh [--keep] try it as someone new: a brand-new Symbiot in
757
773
  a throwaway home, none of your data or accounts
758
774
  symbiot drift [--fetch] what's out of sync / at risk across repos
@@ -808,6 +824,7 @@ async function main() {
808
824
  if (cmd === "logout") return cmdLogout();
809
825
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
810
826
  if (cmd === "app" || cmd === "ui") return cmdApp();
827
+ if (cmd === "away") return cmdAway();
811
828
  if (cmd === "models" || cmd === "hardware") return cmdModels();
812
829
  if (cmd === "setup-local" || cmd === "setup-ollama") return cmdSetupLocal();
813
830
  if (cmd === "drift") return cmdDrift();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.54.4",
4
- "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
3
+ "version": "0.55.1",
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": {
7
7
  "symbiot": "index.mjs"
@@ -42,7 +42,8 @@
42
42
  "LICENSE",
43
43
  "fonts",
44
44
  "mapknn.mjs",
45
- "guard.mjs"
45
+ "guard.mjs",
46
+ "away.mjs"
46
47
  ],
47
48
  "engines": {
48
49
  "node": ">=18"
@@ -75,7 +76,7 @@
75
76
  "@anthropic-ai/sdk": "^0.128.0"
76
77
  },
77
78
  "scripts": {
78
- "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/work.mjs && node test/mapknn.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
79
+ "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/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
79
80
  "lint": "eslint ."
80
81
  },
81
82
  "devDependencies": {
package/server.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // work, on 127.0.0.1 only and behind the per-install token. (Watch on your phone
4
4
  // listens on your network separately, only for that: phone.mjs.)
5
5
  import { spawn, spawnSync } from "node:child_process";
6
+ import { toggleAway, closeAway } from "./away.mjs";
6
7
  import { homedir } from "node:os";
7
8
  import { join } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
@@ -213,6 +214,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
213
214
  const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
214
215
  const screenOut =(s) => (s && s.id ? { ...s, blueprint: blueprint(s), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}) } : s && s.screens ? { ...s, screens: s.screens.map(screenOut) } : s);
215
216
  let UPDATING = null; // an update in flight: { target, attempt, retrying? }
217
+ let NEWEST_WIN = ""; // the Symbiot window opened last: older ones close themselves (one window, not a pile)
218
+ process.on("exit", () => closeAway()); // Away's windows go with the app (a quit, an update's restart)
216
219
  const server = createServer(async (req, res) => {
217
220
  const u = new URL(req.url, "http://127.0.0.1");
218
221
  if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
@@ -371,6 +374,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
371
374
  if (u.pathname === "/api/work/go" && req.method === "POST") return json(res, workGo());
372
375
  if (u.pathname === "/api/firststeps") return json(res, firstSteps()); // Settings' first steps: what's set up, in order
373
376
  if (u.pathname === "/api/home/answer" && req.method === "POST") { const b = await readBody(req); return json(res, homeAnswer(b.id, { pick: b.pick, text: b.text })); }
377
+ if (u.pathname === "/api/away" && req.method === "POST") { const b = await readBody(req); return json(res, toggleAway(`http://127.0.0.1:${server.address().port}/?t=${TOKEN}`, b.open)); }
374
378
  if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question, { images: saveShots(b.images) })); }
375
379
  if (u.pathname === "/api/adapt") return json(res, adaptState({ from: String(u.searchParams.get("from") || ""), commit: u.searchParams.get("commit") === "1", ...(u.searchParams.has("touch") ? { touch: u.searchParams.get("touch") === "1" } : {}) }));
376
380
  if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
@@ -415,7 +419,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
415
419
  if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, cmd === "week" ? await runWeekly(writeup, { notify: false }) : await writeup(cmd)); }
416
420
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
417
421
  // a sandbox (symbiot app --fresh) never offers an update: it would replace your real install
418
- if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && semverGt(LATEST_VERSION, VERSION), ...(UPDATING && UPDATING.retrying ? { retrying: UPDATING } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
422
+ if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1") NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && semverGt(LATEST_VERSION, VERSION), ...(UPDATING && UPDATING.retrying ? { retrying: UPDATING } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
419
423
  // What's new: after an update, since the version you last saw (until you click Got it);
420
424
  // ?latest=1, what the update on offer brings, from its package on npm
421
425
  if (u.pathname === "/api/whatsnew") {
@@ -470,7 +474,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
470
474
  server.on("error", (e) => {
471
475
  // Stable port busy (an older instance still exiting during an update, or a
472
476
  // second app): retry briefly, then fall back to a random port.
473
- if (e && e.code === "EADDRINUSE" && tries < 8) { tries++; setTimeout(() => { try { server.listen(PORT, "127.0.0.1"); } catch {} }, 500); }
477
+ if (e && e.code === "EADDRINUSE" && tries < (RELAUNCH ? 40 : 8)) { tries++; setTimeout(() => { try { server.listen(PORT, "127.0.0.1"); } catch {} }, 500); }
478
+ // after an update, the window only ever looks for the stable port: a copy on another
479
+ // port would serve nobody and linger (one did, for hours), so it bows out
480
+ else if (RELAUNCH) process.exit(0);
474
481
  else { try { server.listen(0, "127.0.0.1"); } catch {} }
475
482
  });
476
483
  server.listen(PORT, "127.0.0.1");
package/tasks.mjs CHANGED
@@ -433,7 +433,10 @@ function shipChanges(path, texts, opts = {}) {
433
433
  const base = gitDefaultBranch(path), day = new Date().toISOString().slice(0, 10); let branch = ch.branch, fresh = "";
434
434
  const newBranch = () => {
435
435
  const stem = "symbiot/" + branchSlug(!texts.length ? `changes-${day}` : texts.length === 1 ? texts[0] : `${texts.length}-tasks-${day}`);
436
- let b = stem; for (let i = 2; git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + b]).ok; i++) b = `${stem}-${i}`;
436
+ // a name free here and on origin: an earlier day's "7 tasks" may be merged and gone
437
+ // locally but still there, and pushing onto it is refused
438
+ const taken = (b) => git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + b]).ok || git(path, ["rev-parse", "--verify", "-q", "refs/remotes/origin/" + b]).ok;
439
+ let b = stem; for (let i = 2; taken(b); i++) b = `${stem}-${i}`;
437
440
  return b;
438
441
  };
439
442
  if (branch === base) {
@@ -462,7 +465,13 @@ function shipChanges(path, texts, opts = {}) {
462
465
  const noted = (note) => ({ ...out, note: (fresh ? fresh + " " : "") + note });
463
466
  if (opts.push === false) return out;
464
467
  if (!git(path, ["remote", "get-url", "origin"]).ok) return noted("No origin remote — committed locally.");
465
- const ps = git(path, ["push", "-u", "origin", branch], 120000);
468
+ let ps = git(path, ["push", "-u", "origin", branch], 120000);
469
+ // refused because origin's branch of that name went another way (an earlier PR of
470
+ // that name, merged): the commit moves to a fresh name and goes up on that
471
+ if (!ps.ok && /rejected|non-fast-forward|fetch first/i.test(ps.err)) {
472
+ const next = newBranch();
473
+ if (git(path, ["branch", "-m", branch, next]).ok) { branch = next; out.branch = next; ps = git(path, ["push", "-u", "origin", branch], 120000); }
474
+ }
466
475
  if (!ps.ok) return noted("Committed, but the push failed: " + (ps.err.split("\n").filter(Boolean).pop() || "unknown error"));
467
476
  out.pushed = true;
468
477
  if (opts.pr === false) return out;
package/ui.mjs CHANGED
@@ -817,6 +817,22 @@ body.lq-liquid.lq-light ::-webkit-scrollbar-thumb{background:rgba(21,26,33,.16)}
817
817
  body.lq-light .lqatt img{border-color:rgba(21,26,33,.15)}body.lq-light .lqatt button{background:#151A21;color:#FFFFFF}
818
818
  #lqform.drop{outline:2px dashed rgba(242,165,65,.7);outline-offset:4px}
819
819
  .lqmimgs{display:flex;gap:6px;margin-bottom:6px}.lqmimgs img{width:120px;max-height:90px;object-fit:cover;border-radius:8px}
820
+
821
+ /* Away (Super and the key above Tab): the orb, the time and what's going on, on every screen; nothing else */
822
+ #lqaway{display:none}
823
+ body.lq-away{cursor:none;overflow:hidden}
824
+ body.lq-away #lq{position:fixed!important;inset:0!important;width:100vw!important;height:100vh!important;display:block!important}
825
+ body.lq-away>*:not(#liquid):not(#lqaway):not(script),body.lq-away #liquid>*:not(#lq){display:none!important}
826
+ body.lq-away #liquid{display:block!important;position:fixed!important;inset:0!important}
827
+ body.lq-away #lqaway{display:block;position:fixed;left:0;top:0;width:440px;text-align:center;pointer-events:none;z-index:5;color:#F3F0EA;text-shadow:0 1px 14px rgba(0,0,0,.65);will-change:transform;transition:opacity .6s}
828
+ #lqaway .awt{font:300 68px/1 var(--sans);letter-spacing:-.03em;font-variant-numeric:tabular-nums}
829
+ #lqaway .awd{margin-top:10px;font:500 12px var(--sans);letter-spacing:.16em;text-transform:uppercase;opacity:.55}
830
+ #lqaway .aws{margin-top:20px;font:400 15px/1.6 var(--sans);opacity:.9}
831
+ #lqaway .aws b{color:var(--amber);font-weight:600}
832
+ #lqaway .awr{display:block;font-size:13.5px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
833
+ #lqaway .awh{margin-top:16px;font-size:11.5px;opacity:.32}
834
+ body.lq-light #lqaway{color:#151A21;text-shadow:0 1px 12px rgba(255,255,255,.8)}
835
+ body.lq-light #lqaway .aws b{color:#9A5200}
820
836
  /* the Dashboard as a stream: time runs left to right, a current per feed, now on the right */
821
837
  .bstream{position:relative;margin-top:10px;border-radius:18px;overflow:hidden;isolation:isolate;background:#08090B;box-shadow:0 0 0 1px rgba(255,255,255,.08)}
822
838
  body.lq-light .bstream{background:#EBEEF0;box-shadow:0 0 0 1px rgba(21,26,33,.08)}
@@ -2264,8 +2280,17 @@ document.getElementById('autostart').addEventListener('change',saveAutostart);
2264
2280
  (function(){var h='';for(var i=0;i<24;i++)h+="<option value='"+i+"'>"+(i<10?'0':'')+i+":00</option>";document.getElementById('weeklyhour').innerHTML=h;})();
2265
2281
  var SRV_STARTED=null,srvDown=false,updBusy=false;
2266
2282
  function ubar(){return document.getElementById('updatebar');}
2283
+ // One Symbiot window, not a pile: each window has an id, says so the first time it
2284
+ // asks, and the server remembers the newest. An older one closes itself; where the
2285
+ // browser won't let it (a tab you opened), it says so and can take over. Away's
2286
+ // windows don't count.
2287
+ var WIN_ID=/[?&]away=1(&|$)/.test(location.search||'')?'':Math.random().toString(36).slice(2,12),WIN_NEW=true; // read from the address: AWAY is set further down
2288
+ function winTakeOver(){WIN_NEW=true;var o=$('winold');if(o)o.remove();heartbeat(false);}
2289
+ function winOld(){try{window.close();}catch(e){}setTimeout(function(){if($('winold')||!document.body)return;var o=document.createElement('div');o.id='winold';o.className='updatebar reconnect show';o.innerHTML="Symbiot is open in a newer window. <button class='ghost' id='wintake'>Use this one</button>";document.body.appendChild(o);var t=$('wintake');if(t)t.onclick=winTakeOver;},400);}
2267
2290
  function heartbeat(fresh){
2268
- api('/api/ping'+(fresh?'?fresh=1':'')).then(function(p){
2291
+ var q=[];if(fresh)q.push('fresh=1');if(WIN_ID){q.push('w='+WIN_ID);if(WIN_NEW)q.push('new=1');}
2292
+ api('/api/ping'+(q.length?'?'+q.join('&'):'')).then(function(p){
2293
+ if(WIN_ID){WIN_NEW=false;if(p&&p.window&&p.window!==WIN_ID){if(!$('winold'))winOld();return;}}
2269
2294
  var b=ubar();
2270
2295
  var ve=document.getElementById('ver');if(ve&&p.version){ve.textContent='v'+p.version+(p.sandbox?' · sandbox':'');if(p.sandbox){ve.title='A brand-new Symbiot to try first run in (symbiot app --fresh): its own empty home, none of your data, accounts or memory. It is deleted when you quit.';ve.style.color='var(--amber)';}}
2271
2296
  if(SRV_STARTED===null){SRV_STARTED=p.started;appBar(p);}
@@ -2677,7 +2702,7 @@ LQ.back=true;try{if(s.scene==='work'&&(cur.scene!=='work'||cur.by!==s.by)){LQ.wo
2677
2702
  if(s.pool){if(s.arch)lqArchive();else lqPool(s.pool);}else if(LQ.mode==='pool')lqSink();}finally{LQ.back=false;}}
2678
2703
  function lqPool(shape){if(shape==='map'&&LM.items&&LM.items.length)setTimeout(function(){lmFit();lmStart();},60);lqRemember();LQ.poolShape=shape;LQ.arch=false;tabPicked=true;setTab(shape);var b=document.body;if(b&&b.classList)b.classList.add('lq-pooled');LQ.mode='pool';var m=document.querySelector('main');if(m){m.scrollTop=0;if(m.focus)m.focus();}}
2679
2704
  function lqSink(){lqRemember();LQ.focus=null;var b=document.body;if(b&&b.classList)b.classList.remove('lq-pooled');LQ.mode='aware';lqAct();lqLoad(false);}
2680
- function lqAct(){LQ.lastAct=Date.now();if(LQ.mode==='rest'){LQ.mode='aware';lqLoad(true);}}
2705
+ function lqAct(){if(AWAY)return;LQ.lastAct=Date.now();if(LQ.mode==='rest'){LQ.mode='aware';lqLoad(true);}}
2681
2706
  function lqTalkMode(on){LQ.talking=on;var b=document.body;if(b&&b.classList)b.classList.toggle('lq-talking',on);}
2682
2707
  function lqTalkShow(wait){var el=$('lqtalk');if(!el)return;var n=LQ.talk.length;el.innerHTML=LQ.talk.map(function(m,i){var age=n-1-i,op=age<2?1:age===2?0.7:age===3?0.45:0.25;return "<div class='lqmsg"+(m.me?' me':'')+"' style='opacity:"+op+"'>"+(m.imgs&&m.imgs.length?"<span class='lqmimgs'>"+m.imgs.map(function(d){return "<img src='"+d+"' alt='your screenshot'>";}).join('')+"</span>":"")+esc(m.text)+stepsHtml(m.steps)+"</div>";}).join('')+(wait?"<div class='lqmsg thinking'>Thinking: recalling what it knows, reading what's here&hellip;</div>":'');if(el.scrollHeight)el.scrollTop=el.scrollHeight;
2683
2708
  // scrolled up to read back: the whole history shows, clear; back at the bottom it rolls on again
@@ -2699,6 +2724,7 @@ api('/api/home/ask',{question:q,images:shots}).then(function(r){LQ.talk.push({me
2699
2724
  // The physics: each droplet a critically damped spring to its place (no wobble,
2700
2725
  // no overshoot), pushed off its neighbours and the core where they'd overlap.
2701
2726
  function lqStep(){var S0=lqSize();if(S0.w!==LQ.bw||S0.h!==LQ.bh){if(LQ.scene==='work'&&LQ.workData)lqBuildWork();else if(LQ.adapt)lqBuild();}var S=S0,cx=S.w/2,cy=(LQ.scene!=='work'&&LQ.cy)||S.h*0.47,k=0.022,c=2*Math.sqrt(k),th=LQ.theme,still=th.still,rest=LQ.mode==='rest',pool=LQ.mode==='pool',talk=LQ.talking&&!pool;
2727
+ if(AWAY){LQ.mode='rest';rest=true;pool=false;talk=false;var ap=lqAwayPos(S);cx=ap.x;cy=ap.y;} // away: the orb at rest, where the bounce has it
2702
2728
  var core=LQ.core,ctx=cx,cty=talk?S.h-44:cy,ctr=(rest?140:pool?50:talk?54+30*(LQ.talkWeight||0.35):92)*S.s;
2703
2729
  if(!core.x){core.x=cx;core.y=cy;}core.x+=(ctx-core.x)*(still?1:0.06);core.y+=(cty-core.y)*(still?1:0.06);core.cr+=(ctr-core.cr)*(still?1:0.05);
2704
2730
  var D=LQ.drops,N=D.length;
@@ -2799,6 +2825,49 @@ if(slow){var sig=W+'|'+H+'|'+th.look+th.night+th.contrast;for(var q=0;q<o.length
2799
2825
  var U=G.U;gl.uniform2f(U.uRes,W,H);gl.uniform1f(U.uT,slow?0:t);gl.uniform1f(U.uDpr,d);gl.uniform4fv(U.uB,o);gl.uniform3f(U.uRip,LQ.ripple[0],LQ.ripple[1],still?-10:LQ.ripple[2]);
2800
2826
  gl.uniform1f(U.uStyle,th.look==='glass'?0:th.look==='pearl'?2:1);gl.uniform1f(U.uContrast,th.contrast?1:0);gl.uniform1f(U.uExposure,th.night?0.82:1);
2801
2827
  gl.drawArrays(gl.TRIANGLES,0,3);}
2828
+
2829
+ // ---- Away (Super and the key above Tab) ------------------------------------------------------------
2830
+ // ?away=1 from "symbiot away": this window is one of the screens. One screen: the
2831
+ // orb rests in the middle. More: it bounces across their shared area (gw x gh) at
2832
+ // the same pace in every window from the same start (t0), each drawing it while it's
2833
+ // over its own part (ax, ay, aw x ah), so it crosses from screen to screen. Under it,
2834
+ // the time and what's going on, counts only. Any key or click closes every window.
2835
+ var AWAY=null;
2836
+ (function(){try{var q=new URLSearchParams(location.search);if(q.get('away')!=='1')return;var n=function(k,d){var v=Number(q.get(k));return isFinite(v)&&q.get(k)!==null?v:d;};
2837
+ AWAY={n:n('n',1),ax:n('ax',0),ay:n('ay',0),aw:n('aw',0),ah:n('ah',0),gw:n('gw',0),gh:n('gh',0),t0:n('t0',Date.now()),since:Date.now(),shown:Date.now()};}catch(e){}})();
2838
+ var pingpong=function(d,L){if(L<=0)return 0;var m=d%(2*L);if(m<0)m+=2*L;return m<=L?m:2*L-m;};
2839
+ function lqAwayPos(S){var cx=S.w/2,cy=S.h*0.42;if(!AWAY||AWAY.n<2||!AWAY.gw||!AWAY.aw)return {x:cx,y:cy};
2840
+ var k=S.w/AWAY.aw,R=220,L=AWAY.gw-2*R,M=AWAY.gh-2*R,t=(Date.now()-AWAY.t0)/1000,v=95;
2841
+ var gx=R+pingpong(v*t*0.83+L/2,L),gy=R+pingpong(v*t*0.56+M/2,M);
2842
+ return {x:(gx-AWAY.ax)*k,y:(gy-AWAY.ay)*k};}
2843
+ function awShort(t){t=String(t||'');var cut=[' - ',' \u00b7 ',' | '];cut.forEach(function(c){var i=t.indexOf(c);if(i>0)t=t.slice(0,i);});return t.trim();}
2844
+ function awStatus(){var el=$('awstat');if(!el)return;Promise.all([api('/api/home').catch(function(){return {};}),api('/api/work').catch(function(){return {};})]).then(function(r){var h=r[0]||{},w=r[1]||{},out=[];
2845
+ var runs=(w.running||[]).slice(0,4),nr=(w.running||[]).length||h.working||0;
2846
+ out.push(nr?'<b>'+nr+' agent'+(nr===1?'':'s')+' at work</b>':'No agent at work');
2847
+ runs.forEach(function(x){out.push("<span class='awr'>"+esc(x.lane||x.name||'')+': '+esc(x.doing||'working on it')+'</span>');});
2848
+ var y=h.youCount||0;out.push(y?'<b>'+y+' thing'+(y===1?'':'s')+' only you can do</b>':'Nothing needs you');
2849
+ var fd=(h.feeds||[]).filter(function(f){return f.count;}).slice(0,4).map(function(f){return esc(awShort(f.title))+' '+f.count;});if(fd.length)out.push('Waiting: '+fd.join(' \u00b7 '));
2850
+ el.innerHTML=out.join('<br>');}).catch(function(){});}
2851
+ function awTick(){var tm=$('awtime'),dt=$('awdate');if(!tm)return;var d=new Date(),p=function(x){return (x<10?'0':'')+x;};tm.textContent=p(d.getHours())+':'+p(d.getMinutes());
2852
+ dt.textContent=d.toLocaleDateString(undefined,{weekday:'long',day:'numeric',month:'long'})+' \u00b7 away since '+p(new Date(AWAY.since).getHours())+':'+p(new Date(AWAY.since).getMinutes());}
2853
+ function awPlace(){var o=$('lqaway');if(!o||!LQ.core)return;var S=lqSize(),c=LQ.core,h=o.offsetHeight||220,x=c.x-220,below=c.y+c.cr+30,y=below+h<S.h-10?below:c.y-c.cr-30-h;
2854
+ o.style.transform='translate('+Math.round(x)+'px,'+Math.round(y)+'px)';o.style.opacity=(c.x+220<0||c.x-220>S.w)?'0':'1';}
2855
+ // Its own screen, full: Chrome lists the screens (its profile lets this address see
2856
+ // them) and puts this window full screen on the si-th, left to right; their places
2857
+ // come from there too (scaled, as the page sees them). No list: full screen where it is.
2858
+ function awayScreen(){var si=0;try{si=Number(new URLSearchParams(location.search).get('si'))||0;}catch(e){}
2859
+ var fs=function(scr){var el=document.documentElement;try{var p=scr?el.requestFullscreen({screen:scr}):el.requestFullscreen();if(p&&p.catch)p.catch(function(){});}catch(e){}};
2860
+ if(!AWAY||AWAY.n<2||!window.getScreenDetails){fs(null);return;} // one window: full screen where it is
2861
+ window.getScreenDetails().then(function(d){var L=((d&&d.screens)||[]).slice().sort(function(a,b){return a.left-b.left||a.top-b.top;}),t=L[si]||L[0];if(!t){fs(null);return;}
2862
+ var mn=function(f){return Math.min.apply(null,L.map(f));},mx=function(f){return Math.max.apply(null,L.map(f));},x0=mn(function(s){return s.left;}),y0=mn(function(s){return s.top;});
2863
+ AWAY.n=L.length;AWAY.ax=t.left-x0;AWAY.ay=t.top-y0;AWAY.aw=t.width;AWAY.ah=t.height;AWAY.gw=mx(function(s){return s.left+s.width;})-x0;AWAY.gh=mx(function(s){return s.top+s.height;})-y0;
2864
+ fs(t);}).catch(function(){fs(null);});}
2865
+ function awayInit(){if(!AWAY||!document.body)return;document.body.classList.add('lq-away');LQ.mode='rest';awayScreen();
2866
+ var o=document.createElement('div');o.id='lqaway';o.innerHTML="<div class='awt' id='awtime'></div><div class='awd' id='awdate'></div><div class='aws' id='awstat'></div><div class='awh'>Any key or click to come back</div>";document.body.appendChild(o);
2867
+ awTick();awStatus();setInterval(awTick,5000);setInterval(awStatus,15000);
2868
+ var bye=function(ev){if(Date.now()-AWAY.shown<1500)return;if(ev&&ev.type==='keydown'&&['Meta','OS','Super','Shift','Control','Alt','Hyper'].indexOf(ev.key)>=0)return;api('/api/away',{open:false}).catch(function(){});};
2869
+ ['keydown','mousedown','touchstart','wheel'].forEach(function(t){document.addEventListener(t,bye,true);});
2870
+ var raf=window.requestAnimationFrame&&function(f){return window.requestAnimationFrame(f);};if(raf){var loop=function(){awPlace();raf(loop);};raf(loop);}}
2802
2871
  function lqInit(){var bd=document.body;if(!bd||!bd.classList)return;lqTheme();bd.classList.add('lq-liquid');
2803
2872
  ['(prefers-color-scheme: light)','(prefers-contrast: more)','(forced-colors: active)','(prefers-reduced-transparency: reduce)','(prefers-reduced-motion: reduce)'].forEach(function(q){try{var mq=window.matchMedia&&window.matchMedia(q);if(mq&&mq.addEventListener)mq.addEventListener('change',lqTheme);else if(mq&&mq.addListener)mq.addListener(lqTheme);}catch(e){}});
2804
2873
  if(document.addEventListener){document.addEventListener('contextmenu',function(ev){var b=document.body,t=ev&&ev.target;if(!b||!b.classList||!b.classList.contains('lq-liquid'))return;if(t&&t.closest&&t.closest('input,textarea,select,[contenteditable]'))return;var sel=window.getSelection?String(window.getSelection()):'';if(sel)return;ev.preventDefault();lqBack();});
@@ -2822,5 +2891,5 @@ var G=lqGL();if(G){try{lqStep();lqDraw(G);}catch(e){}}if(typeof window.requestAn
2822
2891
  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
2823
2892
  raf(frame);}
2824
2893
 
2825
- initGraphEvents();syncP();refresh();loadMap();firstTab();lqInit();lqDeep();loadWhatsNew();loadAgentCfg();loadScanRoots();loadKnowledge();loadPhone();loadLinks();loadMind();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(function(){whenFree(document.querySelector('main'),'watch',loadWatchUI);},60000);loadPhoneLink();
2894
+ initGraphEvents();syncP();refresh();loadMap();firstTab();lqInit();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();
2826
2895
  </script></body></html>`;