symbiot 0.61.1 → 0.62.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,31 @@
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.62.1
6
+
7
+ - **A phone that had Symbiot before goes to "Your computer" first.** Installing the new app over an older one kept the old app's setup, already started at "Meet Symbiot", so the phone went on to "Your AI" and asked to install Claude Code, and never offered to pair with your computer. An unfinished setup on a phone that isn't paired now opens on **Your computer** (scan the QR your computer shows, or type its address and code), once; "No computer" carries on as before. Checked on the Android emulator: 0.59.0, then 0.62.0 over it (the problem), then this over that (fixed).
8
+
9
+ ## 0.62.0 — 2026-10-09
10
+
11
+ - **Your phone shows your computer's work, and you can act on it there.** Pair Symbiot's Android app (or Symbiot in Termux) with your computer and the phone gets a droplet on Home, **On <your computer>**: your tasks, what needs you, your agents' questions, the work waiting for your Approve, and what Watch found (as notifications too). Tick and add tasks, answer an agent and approve from it. "Watch on your phone" is now **Your phone**, in Settings and in Setup.
12
+ - **Sealed between the two.** At pairing the phone and the computer agree a key, and everything after is encrypted with it and stamped so a copy can't be sent again. Your mail's subjects no longer cross your Wi-Fi in the clear. Phones paired before this are asked to pair again.
13
+ - **Pair by scanning.** Switch it on and the computer shows a QR: point your phone's camera at it and the app pairs. The QR carries your computer's key, so a scan can't pair with a stand-in. Typing the address and code still works, with the computers found on your network listed, and `symbiot phone code` prints the QR in the terminal.
14
+ - **In Setup.** A new install's Setup has an optional **Your phone** step, and the Android app's Setup starts with **Your computer** (or "No computer: use Symbiot on this phone"). Settings' first steps list it too.
15
+ - **Found again by itself.** While the link is on, your computer says it's there on your network (mDNS, under a neutral name), so your phone finds it after the router hands it a new address, without pairing again.
16
+ - **Back without a wait.** After a failed try the phone asks again in 15 seconds, 30, then a minute, and at once when its network changes or you open the app. A network error never unpairs it.
17
+ - **Your work, with the computer off.** The phone keeps the last copy and shows it, with one calm line saying the computer is out of reach and when it last heard from it. What you change meanwhile shows at once, marked "waiting to send", and goes in order once the computer is back, each change once. If your computer changed the same thing later, the computer's stays and the phone says so.
18
+ - **Approve from your phone, safely.** An approve goes through only if the work is still exactly what the phone showed; it runs the same Approve as the button, and Home and the Workdesk say which phone approved. Unpairing a phone stops it at once, including what it had queued. A phone can do these few things only; nothing else on your computer is reachable from it.
19
+ - **Away from home, through symbiot.co.za.** With a phone paired, your computer keeps a line open to a relay at relay.symbiot.co.za, which passes the sealed messages on and can't read them or keep them. At home nothing goes through it, and **Away from home too** in Settings switches it off. The relay is in the repo (`relay/`); it goes live once it's deployed.
20
+ - **Keys kept safer.** Your computer keeps only a hash of each phone's token (a copied config can't pose as your phone) and its own key in `secrets.json`, out of agents' reach. The Android app keeps its key sealed by Android's Keystore.
21
+ - **A sign-in stays signed in.** Sites that keep your login only until the browser closes (domains.co.za's, with its SMS PIN) lost it the moment you were done signing in. Symbiot now keeps those sign-ins for 12 hours, and lets Chrome finish saving them before it closes.
22
+ - **A sign-in kept in a tab comes along too.** Some sites keep you signed in in the tab's own storage (sessionStorage) rather than a cookie. Symbiot now carries that over from the Symbiot Browser, site by site, for 12 hours, and no other site's page can see it.
23
+ - **Setup on the phone starts at "Your computer".** A new install of the Android app opened on "Meet Symbiot", so a phone paired by scanning the QR showed nothing, not even an error. It now starts at "Your computer", and a pairing link that arrives during setup shows there, paired or why not.
24
+ - **Sign in to any site, from where you are.** Settings → Connections has **Add a site**: type the page you see once signed in (www.domains.co.za/client/dashboard), and the Symbiot Browser opens on it straight away. The site appears under **Your sites** (here and in the Symbiot Browser, whose address bar adds a site the same way), turns green once you've signed in and clicked Done, and agents can use it. Each trusted site has a **Sign in** button too.
25
+ - **A card or a reply that asks you to sign in has the button for it.** "Sign in to domains.co.za" opens the Symbiot Browser on it, instead of a pointer to "Symbiot's browser" with no way to find it. Agents and Talk to Symbiot now name the site and where to sign in.
26
+ - Agents can run `symbiot screens` from where Symbiot is installed again: the guard stopped any command that only mentioned its folder. Changing Symbiot's own files is still stopped.
27
+ - symbiot.co.za's footer says © 2026 Symbiot.
28
+ - The 4-week posting test counts posts that went out through Marketing too (marked posted, or their scheduled time on LinkedIn passed), and its replies are comments, replies, mentions and messages, not reactions or a bare badge count.
29
+
5
30
  ## 0.61.1
6
31
 
7
32
  - **The Symbiot Browser opens on its page, not blank.** 0.61.0 only knew the page's address in the copy of Symbiot that hands over to one already running, so in the running app "Open the Symbiot Browser" opened an empty window. It's set where the app starts serving now, and a test checks it.
package/README.md CHANGED
@@ -297,6 +297,72 @@ to a GitHub release tagged `apk-<version>`, signed with that same key once it's
297
297
  in the repo's `SYMBIOT_KEYSTORE_B64` and `SYMBIOT_KEYSTORE_PASS` secrets (see
298
298
  `.github/workflows/publish.yml`).
299
299
 
300
+ ### Your phone
301
+
302
+ Your computer's work, on your phone: your tasks, what needs you, your agents'
303
+ questions, the work waiting for your Approve, and what Watch found (as a
304
+ notification too). Experimental.
305
+
306
+ - **Pair it.** On the computer, Setup's **Your phone** step (or Settings → **Your
307
+ phone**) switches the link on and shows a QR. Point your phone's camera at it and
308
+ tap its link: it opens symbiot.co.za/pair, which hands it to Symbiot's app (the
309
+ part of the link with the code never leaves your phone), and the app pairs. The
310
+ Android app's own Setup starts with **Your computer** for this, or **No computer:
311
+ use Symbiot on this phone**. No camera, or Termux? Type the computer's address and
312
+ the 6-digit code it shows (**Look for it on this network** lists the computers it
313
+ finds), or `symbiot phone pair <address> <code>` (or the QR's link) in Termux;
314
+ `symbiot phone code` on the computer prints the QR in the terminal. A code lasts
315
+ 10 minutes and 5 tries.
316
+ - **Sealed.** At pairing the phone and the computer agree a key (X25519, then HKDF),
317
+ and every request and answer after that is sealed with it (AES-256-GCM) and stamped,
318
+ so a copy sent again is refused. The QR carries the computer key's fingerprint, so a
319
+ scan can't pair with a stand-in. The computer keeps only a hash of each phone's
320
+ token, and its own private key in `secrets.json`, which agent runs can't read. The
321
+ Android app keeps its token and key sealed by a key in Android's Keystore; in Termux
322
+ they're in `secrets.json` (readable only by you, 0600).
323
+ - **What a phone can do.** Only this: what's new, a copy of your work, and four
324
+ changes (add a task, mark one done or not, answer an agent, approve). The computer
325
+ listens on port 7392 of your network for that alone; the rest of Symbiot stays on
326
+ `127.0.0.1`, and nothing else is reachable from the phone. An approve goes through
327
+ only if the work is still exactly what the phone showed, runs the same Approve as
328
+ the button, and Home and the Workdesk say which phone approved. Unpairing a phone
329
+ stops it at once, including what it had queued. A firewall on the computer may need
330
+ to allow the port.
331
+ - **With the computer off.** The phone keeps the last copy (`computer.json`) and shows
332
+ it, with one line saying the computer is out of reach and when it last heard from
333
+ it. What you change meanwhile shows at once, marked "waiting to send", and goes in
334
+ order once the computer is back, each change once. When the computer changed the
335
+ same thing later, the computer's stays, and the phone says so.
336
+ - **Finding it again.** While the link is on, the computer says it's here on your
337
+ network (mDNS, as a printer does, under a neutral name, not your computer's), so
338
+ the phone finds it after a new address; it never unpairs because of a network
339
+ error. It asks every 2 minutes, sooner after a failed try (15 s, 30 s, a minute),
340
+ and at once when the phone's network changes or you open the app.
341
+ - **Away from home.** With a phone paired, the computer keeps a line open to a relay
342
+ at `relay.symbiot.co.za` (it connects out: no port opened on your router). The phone
343
+ uses it only when it can't reach the computer on your network, so at home nothing
344
+ goes through it. The relay passes the sealed messages on and can't read them; it
345
+ stores nothing; it knows a pair only by a hash. It does see when a pair talks, and
346
+ how much. Untick **Away from home too** in Settings → Your phone to keep it to your
347
+ Wi-Fi. The relay is in `relay/` (a Cloudflare Worker, or `node relay/server.mjs` on
348
+ a server of your own, with `SYMBIOT_RELAY=<its address>` on the computer).
349
+
350
+ In Termux:
351
+
352
+ ```bash
353
+ symbiot phone pair 192.168.1.21:7392 123456 # the computer's address and the code it shows
354
+ symbiot phone pair 'https://symbiot.co.za/pair#a=…' # or the link its QR holds
355
+ symbiot phone # the computer it's paired with
356
+ symbiot phone check # ask it now
357
+ symbiot phone forget # stop asking it
358
+ ```
359
+
360
+ On the computer, `symbiot phone` lists where it listens, the phones paired and the
361
+ relay, and `symbiot phone code` opens a new code and prints its QR (while `symbiot
362
+ app` runs there). The phone asks the computer while `symbiot app` runs in Termux, so
363
+ start it after pairing. In Termux, notifications need the Termux:API app and
364
+ `pkg install termux-api`.
365
+
300
366
  ## Hand tasks to your coding agent: `symbiot push`
301
367
 
302
368
  ```bash
@@ -1098,8 +1164,9 @@ price told two ways) also shows on Home, under Watching, until you open it.
1098
1164
  [experimental](#screens-blueprints-for-screen-automation) Screens takes a
1099
1165
  screenshot only when you click Capture, and opens only the web pages you map
1100
1166
  or Watch (in a browser profile of its own). What it maps and what Watch finds
1101
- stay on your computer, unless you pair your phone (Watch on your phone) or
1102
- switch on the brief, which sends what's new to your AI.
1167
+ stay on your computer, unless you pair your phone (Your phone: sealed between
1168
+ the two, and away from home through a relay that can't read it) or switch on
1169
+ the brief, which sends what's new to your AI.
1103
1170
  - **Runs only what you set:** the agent command is yours, and deploy commands are
1104
1171
  read only from your own `~/.config/symbiot/`, never from a repo.
1105
1172
 
@@ -1125,9 +1192,11 @@ can't fully check, so they may change. Everything above works without them.
1125
1192
  Type and Trusted sites work on web pages only.
1126
1193
  - **Watch**: reads your Gmail inbox, but hasn't yet been seen to notify a new
1127
1194
  email on a real one. GitHub through `gh` has been read from a real account,
1128
- but not yet left running. The brief, Standup's "Waiting on you" line and
1129
- **Watch on your phone** are new; the phone side hasn't been tried on a real
1130
- phone yet.
1195
+ but not yet left running. The brief and Standup's "Waiting on you" line are new.
1196
+ - **Your phone**: the sealed link, the copy, the queue and the relay are tested
1197
+ between two Symbiots on one computer (`test/phone.mjs`; the relay's Worker was
1198
+ also run through it in Miniflare, Cloudflare's local runtime), but not yet on a
1199
+ real phone, and the relay at relay.symbiot.co.za isn't up yet.
1131
1200
 
1132
1201
  ### Your sent email, without an API: `symbiot mail`
1133
1202
 
@@ -1357,35 +1426,12 @@ from what Watch found: `Waiting on you: 3 emails, 2 GitHub notifications`. Symbi
1357
1426
  counts them itself, so the numbers are right; the AI only sees them to know
1358
1427
  what's next.
1359
1428
 
1360
- **On your phone.** What Watch finds on your computer can show up as a
1361
- notification on your phone, through Symbiot there (the [Android app](#the-android-app-apk),
1362
- or Symbiot in [Termux](#on-your-phone-android-in-termux)). On the computer, tick
1363
- **Watch on your phone** in Settings: it shows the computer's address and a 6-digit
1364
- code, good for 10 minutes. On the phone, in Settings → **Watch on your phone**,
1365
- type both and click **Pair**. From then on, while Symbiot runs on both, the phone
1366
- asks the computer every 2 minutes and notifies what's new there, with its brief.
1367
- They have to be on the same network (or both on a VPN such as Tailscale, whose
1368
- address is listed too). For this, the computer listens on port 7392 of your
1369
- network, and serves only two things there: pairing with that code (5 wrong tries
1370
- and the code is gone), and what's new for a phone that paired (with a token of its
1371
- own; unpair it in Settings). Nothing can be changed from there, and the rest of
1372
- Symbiot stays on `127.0.0.1`. What's new crosses your network unencrypted, so
1373
- switch it on at home, not on a café's Wi-Fi. A firewall on the computer may need
1374
- to allow the port. In Termux, notifications need the Termux:API app and
1375
- `pkg install termux-api`.
1376
-
1377
- In Termux you can pair from the command line too, without the app window:
1378
-
1379
- ```bash
1380
- symbiot phone pair 192.168.1.21:7392 123456 # the computer's address and the code it shows
1381
- symbiot phone # the computer it's paired with
1382
- symbiot phone check # ask it what's new now
1383
- symbiot phone forget # stop asking it
1384
- ```
1385
-
1386
- On the computer, `symbiot phone` lists where it listens and the phones paired, and
1387
- `symbiot phone code` opens a new code (while `symbiot app` runs there). The phone
1388
- asks the computer while `symbiot app` runs in Termux, so start it after pairing.
1429
+ **On your phone.** Your phone can show your computer's work, through Symbiot
1430
+ there (the [Android app](#the-android-app-apk), or Symbiot in
1431
+ [Termux](#on-your-phone-android-in-termux)): your tasks, what needs you, your
1432
+ agents' questions, the work waiting for your Approve, and what Watch found, which
1433
+ also comes as a notification. From the phone you can add and tick tasks, answer
1434
+ your agents and approve. See [Your phone](#your-phone) for how it works.
1389
1435
 
1390
1436
  **More than one display?** Symbiot reads how your displays are laid out
1391
1437
  (`cosmic-randr`, `wlr-randr`, `kscreen-doctor` or `xrandr` on Linux, PowerShell on
package/browser.html CHANGED
@@ -86,7 +86,8 @@ document.getElementById("list").addEventListener("click", function (e) {
86
86
  }).catch(function () {});
87
87
  else say("Sign in to it in the window that opened, then come back here.");
88
88
  });
89
- document.getElementById("go").addEventListener("submit", function (e) { e.preventDefault(); var u = fromInput(document.getElementById("addr").value); if (u) openSite(u); });
89
+ // any site: opened, and kept under Your sites with whether you're signed in (links.mjs addSite)
90
+ document.getElementById("go").addEventListener("submit", function (e) { e.preventDefault(); var u = fromInput(document.getElementById("addr").value); if (!u) return; openSite(u); api("/api/links/site", { site: u, here: true }).then(function (r) { if (r && !r.error) { say("Sign in to it in the window that opened, then come back here. It's under " + ((r.item && r.item.group) || "Your sites") + " now."); load(); } }).catch(function () {}); });
90
91
  document.getElementById("done").addEventListener("click", function () { say("Closing, and checking your sites…"); api("/api/browser/done", {}).catch(function () {}); });
91
92
  load(); setInterval(load, 5000);
92
93
  </script>
package/core.mjs CHANGED
@@ -28,7 +28,9 @@ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
28
28
  // SANDBOX_SECRETS) refuse secrets.json. loadConfig joins the two and saveConfig
29
29
  // splits them, so nothing else changes; a config.json from before still reads, and
30
30
  // its keys move out on the next save (the app saves once as it starts: moveSecrets).
31
- const SECRETS_PATH = join(CONFIG_DIR, "secrets.json"), SECRET_KEYS = ["apiKey", "appToken"];
31
+ // The phone link's keys too (phone.mjs): this computer's private key, and on a phone
32
+ // its token and the key it shares with the computer.
33
+ const SECRETS_PATH = join(CONFIG_DIR, "secrets.json"), SECRET_KEYS = ["apiKey", "appToken", "phoneSecret", "computerSecret"];
32
34
  const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
33
35
  function splitSecrets(cfg) {
34
36
  const pub = { ...cfg }, sec = {};
package/guard.mjs CHANGED
@@ -31,6 +31,35 @@ function symbiotConfig(p, home) { const d = join(home, ".config", "symbiot"); re
31
31
  // runs through (SYMBIOT_OWNER, set by Symbiot, not by the agent).
32
32
  const SELF = resolve(fileURLToPath(new URL(".", import.meta.url)));
33
33
  const selfFile = (p) => !process.env.SYMBIOT_OWNER && under(p, SELF);
34
+ // A shell command that names Symbiot's files changes them unless it only reads them, runs
35
+ // Symbiot's own CLI (`node <its index.mjs> screens map …`, as TASKS.md tells agents to),
36
+ // sets a variable to the path or prints it, and writes (>, >>, tee) nowhere inside them.
37
+ // Anything else naming them (sed -i, cp, mv, rm, git, npm, cd into them…) is stopped. A
38
+ // command that only named the path was stopped too, and ops couldn't use Screens (2026-10-09).
39
+ const SELF_READ = /^(cat|less|more|head|tail|grep|egrep|fgrep|rg|ls|wc|file|stat|diff|cmp|sha\d*sum|md5sum|readlink|realpath|du|tree|jq|echo|printf|test|\[)$/;
40
+ const SELF_WRAP = /^(timeout|nice|nohup|env|time|command|exec|xargs)$/;
41
+ const unquote = (x) => String(x).replace(/^['"]|['"]$/g, "");
42
+ function changesSelf(part, { home, cwd }) {
43
+ const mentions = (x) => { const v = unquote(x); return v.includes(SELF) && (v === SELF || v.includes(SELF + "/") || under(expand(v.replace(/^[A-Za-z_][A-Za-z0-9_]*=/, ""), home, cwd), SELF)); };
44
+ for (const seg of part.split(/\|(?!\|)/)) {
45
+ const t = seg.trim(); if (!t || !t.includes(SELF)) continue;
46
+ // where it writes: a redirect's file, or tee's
47
+ for (const m of t.matchAll(/\d*>>?\s*(?!&)(\S+)/g)) if (mentions(m[1])) return true;
48
+ let w = t.split(/\s+/), i = 0;
49
+ while (i < w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i])) i++; // VAR=… on its own sets a variable, nothing more
50
+ if (i < w.length && w[i] === "export") { i++; while (i < w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i])) i++; }
51
+ while (i < w.length && SELF_WRAP.test(w[i])) { i++; while (i < w.length && (w[i].startsWith("-") || /^\d+[smhd]?$/.test(w[i]))) i++; }
52
+ if (i >= w.length) continue;
53
+ const cmd = w[i].split("/").pop(), args = w.slice(i + 1);
54
+ if (cmd === "tee" && args.some((x) => !x.startsWith("-") && mentions(x))) return true;
55
+ if (SELF_READ.test(cmd)) continue;
56
+ if (cmd === "find" && !args.some((x) => /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls)$/.test(x))) continue;
57
+ // node running a script of Symbiot's (not code given inline, which could write anything)
58
+ if (/^(node|nodejs)$/.test(cmd) && !args.some((x) => /^(-e|--eval|-p|--print)$/.test(x) || /^--(eval|print)=/.test(x))) { const script = args.find((x) => !x.startsWith("-")); if (script && mentions(script) && /\.(m?js|cjs)['"]?$/.test(script)) continue; }
59
+ return true;
60
+ }
61
+ return false;
62
+ }
34
63
 
35
64
  // the branch a bare `git push` pushes: the one checked out in cwd
36
65
  function currentBranch(cwd) { try { return execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } }
@@ -42,7 +71,7 @@ function judgeBash(cmd, { cwd, home, branch }) {
42
71
  const s = part.trim(); if (!s) continue;
43
72
  const w = s.split(/\s+/);
44
73
  if (/^(sudo|su|doas)$/.test(w[0])) return { why: "sudo: only you run things as root" };
45
- if (!process.env.SYMBIOT_OWNER && (s.includes(SELF + "/") || s.includes(SELF + " ") || s.endsWith(SELF))) return { why: "Symbiot doesn't edit its own code" };
74
+ if (!process.env.SYMBIOT_OWNER && s.includes(SELF) && changesSelf(s, { home, cwd })) return { why: "Symbiot doesn't edit its own code" };
46
75
  if (/^(shutdown|reboot|poweroff|halt)$/.test(w[0])) return { why: "shutting the computer down is yours to do" };
47
76
  if (/^(mkfs(\.\w+)?|fdisk|parted|wipefs)$/.test(w[0]) || (/^dd$/.test(w[0]) && /\bof=\/dev\//.test(s))) return { why: "that writes a disk directly" };
48
77
  if (/^git$/.test(w[0]) || /^git\s/.test(s)) {
package/handover.mjs CHANGED
@@ -27,7 +27,7 @@ function handoverRules(lanes = [], here = "") {
27
27
  }
28
28
  // The rule for options in QUESTIONS.md: the user gets only what no agent can do,
29
29
  // and the option says which kind it is.
30
- const ONLY_YOU = "- Only what no agent can do is the user's: their body (a phone in their hand, a cable, which network they're on), their identity or secrets (signing in, a 2FA code, a token from a provider's console) or a decision that's theirs (closing an account, spending money, sending something in their name). Start such an option with `👤 You (only you: <which>):`, e.g. `👤 You (only you: your Meta token): paste it into .env`. Anything an agent could do, yours or another lane's, is never a 👤 step: do it, or hand it over. Local git work (staging, committing, pulling, pushing, in this repo or another lane's) is never a 👤 step either: do it, or hand it to that repo's lane, and ask only for an OK in QUESTIONS.md before a push, with the files it pushes. Prefer what's already linked: Symbiot's own browser (`~/.config/symbiot/browser`) is signed in to the sites the user linked under Connections, so use that session over any route that needs a new sign-in, an app, a key or their consent (a platform's API, a developer app, OAuth). Check what's linked before you write a 👤 sign-in or link step, ask them to sign in only when a real attempt found the session expired (and say so), and never offer doing an agent's job by hand. An option where picking it is enough starts with `🤖 Agent:`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`) gets no tag. A permission your run doesn't have (a command, a folder) is never a chore for the user. Write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`), ask for it in QUESTIONS.md with the option `👤 You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`, and stop. If it stays inside the user's work (the folders they gave Symbiot, not ~/.ssh or ~/.config), asks for nothing wide, and doesn't publish or reach another machine, Symbiot turns it on by itself and runs you again: no question reaches the user. Their knowledge folders are already yours to read. Never ask them to copy files or run claude in a terminal for it. Decide what can be undone yourself, as a trusted colleague would: the approach, names, which of two fixes, how to lay files out; pick, do it, and say what you chose and why in your last message. Ask only about what can't be undone, costs money, goes out in the user's name, or needs who they are, and then give 2–3 options with your pick first, marked (recommended). Their answer comes back into this same conversation, so you'll remember everything when it does. Symbiot's membrane (its guard) stops the few things only the user does (pushing to main, publishing, deleting outside your folder, sudo, their keys); if it stops you, ask, don't work around it. Whatever waits on the user's OK (sending, posting, deleting, paying, closing) or on a step only they can take goes in QUESTIONS.md as a question, with what they should check first in its context line, never only in your last message: they aren't asked about what your last message says.";
30
+ const ONLY_YOU = "- Only what no agent can do is the user's: their body (a phone in their hand, a cable, which network they're on), their identity or secrets (signing in, a 2FA code, a token from a provider's console) or a decision that's theirs (closing an account, spending money, sending something in their name). Start such an option with `👤 You (only you: <which>):`, e.g. `👤 You (only you: your Meta token): paste it into .env`. Anything an agent could do, yours or another lane's, is never a 👤 step: do it, or hand it over. Local git work (staging, committing, pulling, pushing, in this repo or another lane's) is never a 👤 step either: do it, or hand it to that repo's lane, and ask only for an OK in QUESTIONS.md before a push, with the files it pushes. Prefer what's already linked: Symbiot's own browser (`~/.config/symbiot/browser`) is signed in to the sites the user linked under Connections, so use that session over any route that needs a new sign-in, an app, a key or their consent (a platform's API, a developer app, OAuth). Check what's linked before you write a 👤 sign-in or link step, ask them to sign in only when a real attempt found the session expired (and say so), and never offer doing an agent's job by hand. When they do have to sign in to a site, name the site's address and the place in the app: `👤 You (only you: your sign-in): sign in to <site, e.g. www.domains.co.za/client/dashboard> in the Symbiot Browser (Settings → Connections → Add a site)`, never just \"in Symbiot's browser\": Symbiot puts a Sign in button on that card, which opens the window there. An option where picking it is enough starts with `🤖 Agent:`. Tag actions only: an option that just reports what the user saw or decides (`it notified me`, `not tried yet`) gets no tag. A permission your run doesn't have (a command, a folder) is never a chore for the user. Write the rules, as narrow as the task needs, to `.symbiot/allowlist.proposed.json` (`{\"permissions\": {\"allow\": [...], \"deny\": [...], \"additionalDirectories\": [...]}}`), ask for it in QUESTIONS.md with the option `👤 You (only you: a permission): allow the list in .symbiot/allowlist.proposed.json`, and stop. If it stays inside the user's work (the folders they gave Symbiot, not ~/.ssh or ~/.config), asks for nothing wide, and doesn't publish or reach another machine, Symbiot turns it on by itself and runs you again: no question reaches the user. Their knowledge folders are already yours to read. Never ask them to copy files or run claude in a terminal for it. Decide what can be undone yourself, as a trusted colleague would: the approach, names, which of two fixes, how to lay files out; pick, do it, and say what you chose and why in your last message. Ask only about what can't be undone, costs money, goes out in the user's name, or needs who they are, and then give 2–3 options with your pick first, marked (recommended). Their answer comes back into this same conversation, so you'll remember everything when it does. Symbiot's membrane (its guard) stops the few things only the user does (pushing to main, publishing, deleting outside your folder, sudo, their keys); if it stops you, ask, don't work around it. Whatever waits on the user's OK (sending, posting, deleting, paying, closing) or on a step only they can take goes in QUESTIONS.md as a question, with what they should check first in its context line, never only in your last message: they aren't asked about what your last message says.";
31
31
 
32
32
  // What a run hands back to Symbiot itself (handback.mjs reads it): facts for
33
33
  // memory, and emails that wait on a reply, which Symbiot then watches for; and
package/headless.mjs CHANGED
@@ -21,7 +21,7 @@
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 } from "node:fs";
24
+ import { mkdirSync, existsSync, statSync, readlinkSync, readFileSync, writeFileSync } 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
 
@@ -45,10 +45,11 @@ function siteUrl(input) {
45
45
  }
46
46
 
47
47
  // The browser's flags. The same profile and password store headless and not, so
48
- // a sign-in made in the window is readable by the hidden browser.
48
+ // a sign-in made in the window is readable by the hidden browser. The hidden one
49
+ // restores the last session: that's what keeps a site's session-only sign-in (keepSessions).
49
50
  function browserArgs(headless, url = "about:blank") {
50
51
  return [
51
- ...(headless ? ["--headless=new", "--hide-scrollbars", "--mute-audio", "--remote-debugging-pipe", `--window-size=${VIEW.w},${VIEW.h}`] : ["--new-window"]),
52
+ ...(headless ? ["--headless=new", "--hide-scrollbars", "--mute-audio", "--remote-debugging-pipe", `--window-size=${VIEW.w},${VIEW.h}`, "--restore-last-session"] : ["--new-window"]),
52
53
  "--user-data-dir=" + PROFILE, "--no-first-run", "--no-default-browser-check",
53
54
  ...(process.platform === "linux" ? ["--password-store=basic"] : process.platform === "darwin" ? ["--use-mock-keychain"] : []),
54
55
  url,
@@ -84,6 +85,67 @@ function connect(proc) {
84
85
  return { send, until, on: (f) => { listeners.add(f); return () => listeners.delete(f); }, closed: () => closed };
85
86
  }
86
87
 
88
+ // A sign-in that lives in a session-only cookie (domains.co.za's PHPSESSID: no "remember
89
+ // me") is still on disk when the Sign in window closes, but Chrome drops session cookies
90
+ // when it next starts, unless it restores the last session. So the hidden browser starts
91
+ // that way (browserArgs), and at once makes each session cookie a cookie that lasts
92
+ // SESSION_KEEP; and again before it closes, for one a site set meanwhile. Twice the user
93
+ // signed in to domains.co.za, SMS PIN and all, and lost it as the window closed (2026-10-09).
94
+ const SESSION_KEEP = 12 * 3600; // seconds
95
+ async function keepSessions(c, now = Date.now()) {
96
+ let cookies = []; try { ({ cookies = [] } = await c.send("Storage.getCookies")); } catch { return 0; }
97
+ let n = 0;
98
+ for (const k of cookies.filter((x) => x.session)) {
99
+ const host = String(k.domain || "").replace(/^\./, ""); if (!host) continue;
100
+ const port = k.sourcePort > 0 && k.sourcePort !== 80 && k.sourcePort !== 443 ? ":" + k.sourcePort : "";
101
+ const at = k.domain.startsWith(".") ? { domain: k.domain } : { url: `${k.secure ? "https" : "http"}://${host}${port}${k.path || "/"}` }; // host-only stays host-only
102
+ const p = { name: k.name, value: k.value, path: k.path || "/", secure: !!k.secure, httpOnly: !!k.httpOnly, expires: Math.floor(now / 1000) + SESSION_KEEP, ...at,
103
+ ...(k.sameSite ? { sameSite: k.sameSite } : {}), ...(k.priority ? { priority: k.priority } : {}), ...(k.partitionKey ? { partitionKey: k.partitionKey } : {}) };
104
+ try { await c.send("Storage.setCookies", { cookies: [p] }); n++; } catch {}
105
+ }
106
+ return n;
107
+ }
108
+ // A sign-in some sites keep in sessionStorage (a tab's own, not a cookie) comes back with
109
+ // the tab it was in when the last session is restored, and the hidden browser works in a
110
+ // tab of its own. So each restored tab's sessionStorage is kept, by site, for SESSION_KEEP,
111
+ // in the profile (SESSIONS), and filled into the hidden browser's tab when it opens a page
112
+ // of that site with none of its own (sessionFill: in a world of its own, so no page sees
113
+ // another site's). Its own tab's is kept again before it closes.
114
+ const SESSIONS = join(PROFILE, "symbiot-sessions.json"), SS_MAX = 200000; // bytes, all of it
115
+ const SS_READ = `location.origin.startsWith("http") ? JSON.stringify({ origin: location.origin, items: Object.fromEntries(Object.entries(sessionStorage)) }) : ""`;
116
+ function loadSessions(now = Date.now(), file = SESSIONS) {
117
+ let d = {}; try { d = JSON.parse(readFileSync(file, "utf8")) || {}; } catch {}
118
+ return Object.fromEntries(Object.entries(d).filter(([o, v]) => /^https?:\/\//.test(o) && v && v.items && typeof v.items === "object" && now - Number(v.at) < SESSION_KEEP * 1000));
119
+ }
120
+ function saveSessions(add, now = Date.now(), file = SESSIONS) {
121
+ const d = { ...loadSessions(now, file) };
122
+ for (const x of add) if (x && x.origin && x.items) { if (Object.keys(x.items).length) d[x.origin] = { at: now, items: x.items }; else delete d[x.origin]; }
123
+ let out = JSON.stringify(d); if (out.length > SS_MAX) out = "{}"; // too big to be a sign-in: keep nothing rather than half
124
+ try { writeFileSync(file, out, { mode: 0o600 }); } catch {}
125
+ }
126
+ const sessionFill = (d) => `(() => { try { const o = ${JSON.stringify(Object.fromEntries(Object.entries(d).map(([k, v]) => [k, v.items])))}[location.origin]; if (!o || sessionStorage.length) return; for (const k of Object.keys(o)) sessionStorage.setItem(k, String(o[k])); } catch {} })()`;
127
+ // wait: a restored tab may not have its page yet (about:blank a moment longer); our own tab has it
128
+ async function readSession(send, wait = false) {
129
+ for (let i = 0; i < (wait ? 10 : 1); i++) {
130
+ try { const r = await send("Runtime.evaluate", { expression: SS_READ, returnByValue: true }); const v = r && r.result && r.result.value; if (v) return JSON.parse(v); } catch {}
131
+ if (wait) await sleep(200);
132
+ }
133
+ return null;
134
+ }
135
+ // The tabs the last session left (restored with it): their sessionStorage kept, then
136
+ // closed, so nothing loads unseen.
137
+ async function closeOthers(c, keep) {
138
+ try {
139
+ const { targetInfos = [] } = await c.send("Target.getTargets"), got = [];
140
+ for (const t of targetInfos) {
141
+ if (t.type !== "page" || t.targetId === keep) continue;
142
+ if (/^https?:/.test(t.url || "")) try { const { sessionId } = await c.send("Target.attachToTarget", { targetId: t.targetId, flatten: true }); got.push(await readSession((m, p) => c.send(m, p, sessionId), true)); } catch {}
143
+ try { await c.send("Target.closeTarget", { targetId: t.targetId }); } catch {}
144
+ }
145
+ if (got.some(Boolean)) saveSessions(got.filter(Boolean));
146
+ } catch {}
147
+ }
148
+
87
149
  // One browser at a time: they share a profile, and Chrome locks it.
88
150
  let queue = Promise.resolve();
89
151
  const oneAtATime = (fn) => { const run = queue.then(fn, fn); queue = run.catch(() => {}); return run; };
@@ -135,6 +197,7 @@ async function launch() {
135
197
  if (existsSync(join(PROFILE, "SingletonLock")) || /existing browser session|ProcessSingleton/i.test(err)) throw new Error("Symbiot's browser is already open, in a window (Sign in) or in another Symbiot: close that, then try again.");
136
198
  throw new Error("The browser didn't start: " + ((err.trim().split("\n").pop()) || e.message));
137
199
  }
200
+ await closeOthers(c, targetId); await keepSessions(c);
138
201
  const { sessionId } = await c.send("Target.attachToTarget", { targetId, flatten: true });
139
202
  const send = (method, params) => c.send(method, params, sessionId);
140
203
  const until = (method, ms) => c.until((m) => m.sessionId === sessionId && m.method === method, ms);
@@ -152,6 +215,7 @@ async function launch() {
152
215
  while (Date.now() < end && calm < 600) { await sleep(100); calm = inflight.size <= 1 ? calm + 100 : 0; }
153
216
  };
154
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 {}
155
219
  await send("Emulation.setDeviceMetricsOverride", { width: VIEW.w, height: VIEW.h, deviceScaleFactor: 1, mobile: false });
156
220
  // Sites serve "HeadlessChrome" something else (or a block page): look like the browser it is.
157
221
  const { userAgent } = await c.send("Browser.getVersion");
@@ -168,7 +232,10 @@ async function launch() {
168
232
  async function shut(b) {
169
233
  if (!b) return;
170
234
  clearTimeout(b.timer); if (live === b) live = null;
171
- if (!b.c.closed()) { try { await Promise.race([b.c.send("Browser.close"), sleep(3000)]); } catch {} }
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 {} }
236
+ // Chrome writes its cookies to disk as it shuts down: killed before that, a sign-in made
237
+ // or kept since it started is lost. Give it a few seconds to finish.
238
+ if (b.proc.exitCode === null) await Promise.race([new Promise((r) => b.proc.once("exit", r)), sleep(5000)]);
172
239
  if (b.proc.exitCode === null) try { b.proc.kill("SIGKILL"); } catch {}
173
240
  }
174
241
  // Close the hidden browser now (the Sign in window needs its profile).
@@ -487,13 +554,17 @@ function wholePage(id) {
487
554
  // browser is open for you or an agent (type, then press), it leaves it alone
488
555
  // ({ busy }) rather than take its page somewhere else; and it doesn't keep the
489
556
  // browser open after.
490
- function readPage(input) {
557
+ // password: a page asking for a password counts as a sign-in page too (a site's own login
558
+ // 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; })`;
560
+ function readPage(input, { password = false } = {}) {
491
561
  const url = siteUrl(input);
492
562
  if (!url) return Promise.resolve({ error: "Give a site to read: a name (gmail), a host or a web address." });
493
563
  return oneAtATime(() => browserOpen() ? { busy: true } : withPage(async (page) => {
494
564
  await open(page, url);
495
565
  const info = await collect(page);
496
- return { ...info, login: signInPage(info.url) };
566
+ let pw = false; if (password) try { pw = !!((await page.send("Runtime.evaluate", { expression: ASKS_PASSWORD, returnByValue: true })).result || {}).value; } catch {}
567
+ return { ...info, login: signInPage(info.url) || pw };
497
568
  }, false)).catch((e) => ({ error: String((e && e.message) || e) }));
498
569
  }
499
570
  // The words on a page, not its buttons: the text of each element `selector`
@@ -846,7 +917,10 @@ const kill = (pids) => { for (const p of pids) try { process.kill(p, "SIGTERM");
846
917
  async function openSymbiotBrowser(url = "") {
847
918
  const chrome = chromeBinary();
848
919
  if (!chrome) return { error: "The Symbiot Browser needs Chrome, Chromium, Edge or Brave on this computer, and none was found." };
849
- await oneAtATime(closeBrowser);
920
+ // the hidden browser starts and closes once first (not when a window's open already: it'd
921
+ // hand over to it): a session sign-in from the last window is kept (keepSessions) before
922
+ // this one, which doesn't restore the last session, drops it
923
+ await oneAtATime(async () => { await closeBrowser(); if (process.platform !== "win32" && existsSync(PROFILE) && !profilePids({ headless: false }).length) try { await shut(await launch()); } catch {} });
850
924
  const hidden = profilePids({ headless: true }); if (hidden.length) { kill(hidden); await sleep(800); }
851
925
  try {
852
926
  mkdirSync(PROFILE, { recursive: true, mode: 0o700 });
@@ -867,4 +941,4 @@ async function closeSymbiotBrowser() {
867
941
  return { closed: open.length };
868
942
  }
869
943
 
870
- 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 , browserHub };
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 };
package/home.mjs CHANGED
@@ -17,7 +17,7 @@ import { watchBoard, draftReply, draftCards, draftAnswer } from "./watch.mjs";
17
17
  import { pendingReview, pushTasks, addTask, taskType } from "./tasks.mjs";
18
18
  import { estimate, estimateWords } from "./estimate.mjs";
19
19
  import { setHandoffCmd, agentsList, runHandoff, runningHandoff, handoffCmd, loadRuns, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent, detectHandoffs, linkReach } from "./agents.mjs";
20
- import { linksState } from "./links.mjs";
20
+ import { linksState, signInAsked } from "./links.mjs";
21
21
  import { knowledgeState } from "./knowledge.mjs";
22
22
  import { CONFIG_DIR, loadTasks, saveTasks, loadConfig, saveConfig, sameTask } from "./core.mjs";
23
23
  import { lanesState, stuckHandovers, allowHandover, skipHandover } from "./lanes.mjs";
@@ -28,6 +28,7 @@ import { reportsNews, listReports, readReport, decide } from "./reports.mjs";
28
28
  import { awaitingState, inboxSight } from "./handback.mjs";
29
29
  import { newClashes } from "./checks.mjs";
30
30
  import { postsState } from "./post.mjs";
31
+ import { PHONE, ANDROID_APP, computerView, phoneApprovals, linkState } from "./phone.mjs";
31
32
  import { MARKETING, MARKETING_DIR, MARKETING_WORDS, displayName, productNames, productOf, untagged, tagged, draftFiles, setDraftStatus, postedCmd } from "./marketing.mjs";
32
33
 
33
34
  const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
@@ -71,7 +72,7 @@ function troubles({ stuck = [], list = [], map = {}, named = displayName, now =
71
72
  if (n && !dropped.has("needs:" + a.id)) {
72
73
  const ok = n.kind === "approve";
73
74
  out.push({ kind: "ask", fix: "needs", id: "needs:" + a.id, path: a.path, repo, name: nm, title: ok ? `${nm} waits for your OK` : `${nm} needs you`, sub: plain(n.label || n.what, 90),
74
- q: n.what + (n.check ? ` Check first: ${n.check}` : ""), options: ok ? ["Go ahead (recommended)", "Skip"] : ["Done it (recommended)", "Skip"], shape: "agents" });
75
+ q: n.what + (n.check ? ` Check first: ${n.check}` : ""), options: ok ? ["Go ahead (recommended)", "Skip"] : ["Done it (recommended)", "Skip"], ...signInCard([n.label, n.what, n.check]), shape: "agents" });
75
76
  continue;
76
77
  }
77
78
  if (a.status !== "failed" || dropped.has(a.id) || (a.endedAt && now - a.endedAt > FAILED_FOR)) continue;
@@ -354,7 +355,7 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
354
355
  if (!q) continue;
355
356
  const lane = laneOfPath(a.path, map), repo = lane || (lane === "" ? "" : a.name);
356
357
  you.push({ kind: "ask", id: "ask:" + a.path, path: a.path, repo, name: repo ? named(lane ? map[lane] : a.path, repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60),
357
- title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), q: String(q.q || ""), options: (q.options || []).slice(0, 2), ...(qs.length > 1 ? { more: qs.length - 1 } : {}), shape: "agents" });
358
+ title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), q: String(q.q || ""), options: (q.options || []).slice(0, 2), ...(qs.length > 1 ? { more: qs.length - 1 } : {}), ...signInCard([q.q, ...(q.options || []).slice(0, 2)]), shape: "agents" });
358
359
  }
359
360
  // What stopped and can't go on without you, as a question too: a handover that
360
361
  // couldn't start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"),
@@ -386,7 +387,7 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
386
387
  }
387
388
  // nothing waits on you: a few things that can be done next (none while anything does)
388
389
  const next = you.length ? [] : nextUp({ now, map, list, named, deps: { board: () => bd, reports: () => rep, ...(deps.next || {}) } });
389
- const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, runs: runs.slice(0, 4), next, marketing: marketing({ list, map, named, pend }), at: now };
390
+ const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, runs: runs.slice(0, 4), next, marketing: marketing({ list, map, named, pend }), ...(deps.phone || phoneBits)(now), at: now };
390
391
  if (!deps.board) cached = out;
391
392
  return out;
392
393
  }
@@ -466,9 +467,29 @@ function firstSteps({ deps = {} } = {}) {
466
467
  { id: "site", title: "Link one site", done: !!site, sub: site ? `${site.name} linked. ${runs}` : "your mail, chat or code, so Symbiot sees what arrives" },
467
468
  { id: "company", title: "A company folder", optional: true, done: folders > 0, sub: folders ? "your chats can quote it, and Symbiot checks where its files disagree" : "documents your chats can quote, and Symbiot checks" },
468
469
  ];
470
+ // your phone: on a computer, pairing the Symbiot app on it (Setup offers it too)
471
+ if (!PHONE) { const ph = tryOr(deps.phone || linkState, { phones: [] }), n = (ph.phones || []).length;
472
+ steps.push({ id: "phone", title: "Your phone", optional: true, done: n > 0, sub: n ? `${ph.phones.map((p) => p.name).join(", ")} paired: your tasks and what needs you, there too` : "your tasks, what needs you and agents' questions on your phone, and Approve from there" }); }
469
473
  return { steps, done: steps.every((s) => s.done) };
470
474
  }
471
475
 
476
+ // Your phone (phone.mjs) on Home. On a phone paired with a computer: that computer's
477
+ // droplet, from the copy it keeps (what needs you there, its open tasks, whether it's
478
+ // out of reach and since when, what's waiting to send). On the computer: what was
479
+ // approved from a phone today, so you know it wasn't you at this desk.
480
+ function phoneBits(now = Date.now()) {
481
+ try {
482
+ if (!PHONE) {
483
+ const a = phoneApprovals(now), l = linkState(), phones = l.on ? (l.phones || []).map((p) => ({ name: p.name, ...(p.seen ? { seen: p.seen } : {}), ...(p.via ? { via: p.via } : {}), ...(p.old ? { old: true } : {}) })) : [];
484
+ return { ...(phones.length ? { phones } : {}), ...(a.length ? { phoneApproved: a.map((x) => ({ repo: x.repo, phone: x.phone, at: x.at, status: x.status, ...(x.pr ? { pr: x.pr } : {}), ...(x.error ? { error: x.error } : {}) })) } : {}) };
485
+ }
486
+ const v = computerView(); if (!v.paired) return {};
487
+ const c = v.copy || {}, asks = (c.asks || []).filter((x) => !x.waiting).length, approves = (c.approves || []).filter((x) => !x.waiting).length;
488
+ return { computer: { name: v.name, ...(v.old ? { old: true } : {}), ...(v.away ? { away: true } : {}), heard: v.heard || 0, queued: v.queued || 0, asks, approves, needs: (c.needs || []).length,
489
+ tasks: (c.tasks || []).filter((t) => !t.done).length, notes: (c.notes || []).length, copy: !!v.copy } };
490
+ } catch { return {}; }
491
+ }
492
+
472
493
  // One line per thing home shows, for the talk's context.
473
494
  function homeContext(h) {
474
495
  return [
@@ -478,6 +499,8 @@ function homeContext(h) {
478
499
  "Recent handovers:", ...(h.lanes.length ? h.lanes.map((l) => `- ${l.from} → ${l.to}: ${l.text} (${l.status}${l.times ? `, handed over ${l.times} times` : ""})`) : ["- none"]),
479
500
  ...(h.marketing ? [`Marketing (a lane of its own, for marketing across all their products; "repo": "${MARKETING}"): ${h.marketing.needs ? `${h.marketing.needs} thing${h.marketing.needs > 1 ? "s" : ""} there need${h.marketing.needs > 1 ? "" : "s"} them` : "nothing needs them"}${h.marketing.working ? ", its agent is at work" : ""}.`] : []),
480
501
  ...((h.next || []).length ? ["Could be done next (Home suggests these, one tap each):", ...h.next.map((s) => `- ${s.title}: ${s.gain} (${s.time})`)] : []),
502
+ ...(h.computer ? [`On their computer (${h.computer.name}, this phone keeps a copy${h.computer.away ? `; out of reach, last heard ${new Date(h.computer.heard).toLocaleString()}` : ""}): ${h.computer.asks} agent question${h.computer.asks === 1 ? "" : "s"}, ${h.computer.approves} waiting for Approve, ${h.computer.tasks} open task${h.computer.tasks === 1 ? "" : "s"}${h.computer.queued ? `, ${h.computer.queued} change${h.computer.queued > 1 ? "s" : ""} waiting to send` : ""}. They act on it in its droplet ("On ${h.computer.name}").`] : []),
503
+ ...((h.phoneApproved || []).length ? ["Approved from their phone today:", ...h.phoneApproved.map((a) => `- ${a.repo} from ${a.phone}: ${a.status}${a.pr ? ` (${a.pr})` : ""}`)] : []),
481
504
  ].join("\n");
482
505
  }
483
506
 
@@ -611,14 +634,14 @@ function workScene({ deps = {}, full = false } = {}) {
611
634
  const line = plain(String(t).split(/\n\s*\n/)[0], 220);
612
635
  if (line) { p.summary = line; p.state = a.status === "running" ? "at work" : qs.length ? "asks you" : "done"; p.path = a.path; }
613
636
  }
614
- for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.q, options: (q.options || []).slice(0, 3), path: a.path }); }
637
+ for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.q, options: (q.options || []).slice(0, 3), path: a.path, ...signInCard([q.q, ...(q.options || []).slice(0, 3)]) }); }
615
638
  for (const s of ideas) { p.ideas = p.ideas || []; if (p.ideas.length < 4) p.ideas.push({ text: plain(s.text, 200), full: String(s.text), repo: s.repo || l, path: a.path }); }
616
639
  }
617
640
  const stuck = deps.stuck || (() => { try { return stuckHandovers(); } catch { return []; } });
618
641
  const drafts = deps.drafts || (() => { try { return draftCards(); } catch { return []; } });
619
642
  for (const t of troubles({ stuck: stuck(), list: AG, map, named, drafts: drafts() })) {
620
643
  const p = at(t.repo || RUNS_LANE); p.asks++; p.qs = p.qs || [];
621
- if (p.qs.length < 3) p.qs.unshift({ q: t.q, options: t.options, path: t.path, fix: t.fix, id: t.id, sub: t.sub, ...(t.draft ? { draft: t.draft } : {}) });
644
+ if (p.qs.length < 3) p.qs.unshift({ q: t.q, options: t.options, path: t.path, fix: t.fix, id: t.id, sub: t.sub, ...(t.draft ? { draft: t.draft } : {}), ...(t.signInTo ? { signInTo: t.signInTo } : {}) });
622
645
  }
623
646
  for (const p of Object.values(by)) { p.lit = !!(p.asks || p.ready); if (isParked(p.repo)) p.parked = true; }
624
647
  const projects = Object.values(by).sort((a, b) => (b.lit ? 1 : 0) - (a.lit ? 1 : 0) || (a.parked ? 1 : 0) - (b.parked ? 1 : 0) || (b.running ? 1 : 0) - (a.running ? 1 : 0) || b.ready - a.ready || b.waiting - a.waiting || b.last - a.last || (a.repo < b.repo ? -1 : 1));
@@ -636,6 +659,9 @@ function workScene({ deps = {}, full = false } = {}) {
636
659
  // ended without ticking it, is flagged (`stuck`) rather than started again and again.
637
660
  const AUTO_GRACE = 30000, STUCK_AFTER = 5 * 60000, RETRY_AFTER = 5 * 60000, AUTO_FAIL = {}; // lane → { at, note }: a start that didn't take
638
661
  const yours = (text) => /^\s*👤/.test(String(text || "")); // "👤 You (only you: …)": the user's step, never an agent's
662
+ // A card that asks you to sign in to a site ("sign in to domains.co.za in Symbiot's browser")
663
+ // carries the site, and shows a button that opens Symbiot's browser window on it (links.mjs).
664
+ function signInCard(texts) { let s = null; try { s = signInAsked(texts.filter(Boolean).join("\n")); } catch {} return s ? { signInTo: s } : {}; }
639
665
  const asAgent = (text) => String(text || "").replace(/^\s*🤖\s*/u, "").replace(/^Agent:\s*/i, "").trim();
640
666
  const minutes = (ms) => { const m = Math.round(ms / 60000); return m >= 120 ? `${Math.round(m / 60)} hours` : `${m} min`; };
641
667
  // What a folder's agent was last handed (agents.mjs handed.json) and when its last run started.
@@ -724,9 +750,29 @@ const goOps = () => startOps(loadTasks().filter((t) => t.repo === RUNS_LANE && !
724
750
  // where your work is, your agent, every app you use (connected, or "I don't use it"),
725
751
  // your documents, then Home. It's in config.onboarding: { pending, step, skipped: [link
726
752
  // ids you don't use] }; a Symbiot that ran before this existed isn't sent through it.
727
- const ONB_STEPS = ["welcome", "ai", "work", "agent", "apps", "docs", "done"];
753
+ // Your phone has a step of its own (optional): on a computer, pair the app on your phone
754
+ // (after your documents); in the Android app, "Your computer" comes first, and "no
755
+ // computer" carries on with the rest, as Symbiot on the phone alone.
756
+ const ONB_STEPS = ["welcome", "ai", "work", "agent", "apps", "docs", "phone", "done"];
757
+ const ONB_STEPS_PHONE = ["computer", "welcome", "ai", "work", "agent", "apps", "docs", "done"];
758
+ const onbSteps = (app = ANDROID_APP, phone = PHONE) => (app ? ONB_STEPS_PHONE : phone ? ONB_STEPS.filter((s) => s !== "phone") : ONB_STEPS); // (Symbiot in Termux is on the phone already)
759
+ // A new install (`symbiot app` the first time): setup, from its first step, "Your computer"
760
+ // in the Android app (it started at "welcome" there, so a phone paired by its QR showed nothing).
761
+ function startOnboarding() { const cf = loadConfig(); if (!cf.onboarding) { cf.onboarding = { pending: true, step: onbSteps()[0], skipped: [], ...(onbSteps()[0] === "computer" ? { computer: true } : {}) }; saveConfig(cf); } return cf.onboarding; }
762
+ // The phone app over an older one (a phone that had Symbiot before "Your computer"
763
+ // existed): its unfinished setup carried on at "Meet Symbiot", then "Your AI" asked to
764
+ // install Claude Code on the phone. An unfinished setup on a phone that isn't paired
765
+ // goes to "Your computer" first, once (computer: true): "No computer" carries on as before.
766
+ function phoneSetupFirst({ app = ANDROID_APP, paired = () => !!computerView().paired } = {}) {
767
+ if (!app) return null;
768
+ const cf = loadConfig(), o = cf.onboarding;
769
+ if (!o || !o.pending || o.computer) return o || null;
770
+ let isPaired = false; try { isPaired = paired(); } catch {}
771
+ if (!isPaired) o.step = "computer";
772
+ o.computer = true; saveConfig(cf); return o;
773
+ }
728
774
  function onboarding({ fresh = false } = {}) {
729
- const cfg = loadConfig(), o = cfg.onboarding || {};
775
+ const cfg = loadConfig(), o = cfg.onboarding || {}, steps = onbSteps();
730
776
  const tryOr = (f, d) => { try { return f(); } catch { return d; } };
731
777
  let st = null; try { st = claudeState(fresh); } catch {}
732
778
  const r = tryOr(() => resolveProvider(), null);
@@ -736,7 +782,9 @@ function onboarding({ fresh = false } = {}) {
736
782
  ...(reach && reach.sites && reach.sites[x.id] ? { agents: !!reach.sites[x.id].ready, connector: reach.sites[x.id].name } : {}) }));
737
783
  const det = tryOr(() => detectHandoffs(), { agents: [] }), cmd = tryOr(() => handoffCmd(), "");
738
784
  return {
739
- pending: !!o.pending, step: ONB_STEPS.includes(o.step) ? o.step : "welcome", steps: ONB_STEPS,
785
+ pending: !!o.pending, step: steps.includes(o.step) ? o.step : steps[0], steps,
786
+ phone: tryOr(() => { const p = PHONE ? computerView() : linkState(); return PHONE ? { role: "phone", paired: !!p.paired, name: p.name || "", ...(p.error ? { error: p.error } : {}) }
787
+ : { role: "computer", on: p.on, listening: p.listening, ...(p.error ? { error: p.error } : {}), code: p.code || "", until: p.until || 0, link: p.link || "", qr: p.qr || "", addresses: p.addresses || [], port: p.port, phones: (p.phones || []).map((x) => x.name) }; }, null),
740
788
  ai: { connected: !!r, provider: r ? r.provider : "", line: r ? `${PROVIDERS[r.provider].label}${r.model ? " · " + r.model : ""}` : "", claude: tryOr(() => claudeSetup(), st ? { installed: st.installed, signedIn: st.signedIn } : null) },
741
789
  work: { searching: reps.searching, at: reps.at || 0, done: reps.done || 0, total: reps.total || 0, count: reps.list.length, repos: reps.list.slice(0, 60).map((x) => ({ name: x.name, path: x.path })), roots: tryOr(() => scanRoots(), []).map((r) => (r === homedir() ? "~" : r.startsWith(homedir() + "/") ? "~" + r.slice(homedir().length) : r)) },
742
790
  agent: { cmd, pick: tryOr(() => pickAgent(), null), agents: (det.agents || []).map((a) => ({ label: a.label, tmpl: a.tmpl })) },
@@ -747,8 +795,8 @@ function onboarding({ fresh = false } = {}) {
747
795
  // step: where you are; skip / unskip: an app you do or don't use; skipRest: every app not connected.
748
796
  function setOnboarding({ step, skip, unskip, skipRest, done, restart } = {}) {
749
797
  const cfg = loadConfig(), o = { ...(cfg.onboarding || {}) }, sk = new Set(o.skipped || []);
750
- if (restart) { o.pending = true; o.step = "welcome"; }
751
- if (step && ONB_STEPS.includes(step)) o.step = step;
798
+ if (restart) { o.pending = true; o.step = onbSteps()[0]; }
799
+ if (step && onbSteps().includes(step)) o.step = step;
752
800
  if (skip) sk.add(String(skip)); if (unskip) sk.delete(String(unskip));
753
801
  if (skipRest) { try { for (const x of linksState().items) if (x.state === "off") sk.add(x.id); } catch {} }
754
802
  o.skipped = [...sk];
@@ -757,4 +805,4 @@ function setOnboarding({ step, skip, unskip, skipRest, done, restart } = {}) {
757
805
  return onboarding();
758
806
  }
759
807
 
760
- export { reportIdeasAdd, reportAsk, reportDraftAnswer, workTick, waitWhy, AUTO_GRACE, STUCK_AFTER, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, goLane, homeState, homeContext, homeAsk, homeAnswer, homeNext, nextUp, laneNamed, NEXT_FILE, workScene, workGo, displayName, firstSteps, onboarding, setOnboarding, ONB_STEPS };
808
+ export { reportIdeasAdd, reportAsk, reportDraftAnswer, workTick, waitWhy, AUTO_GRACE, STUCK_AFTER, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, goLane, homeState, homeContext, homeAsk, homeAnswer, homeNext, nextUp, laneNamed, NEXT_FILE, workScene, workGo, displayName, firstSteps, onboarding, setOnboarding, startOnboarding, ONB_STEPS, ONB_STEPS_PHONE, onbSteps, phoneBits , phoneSetupFirst };