symbiot 0.61.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/phone.mjs CHANGED
@@ -1,36 +1,120 @@
1
- // symbiot — Watch on your phone: what Symbiot on your computer finds (Watch,
2
- // watch.mjs) shows up as a notification on your phone, through the Symbiot there
3
- // (the Android app, or Symbiot in Termux).
1
+ // symbiot — Your phone: what Symbiot on your computer has (your tasks, what needs
2
+ // you, agents' questions, what Watch found) on your phone, through the Symbiot
3
+ // there (the Android app, or Symbiot in Termux), and the few things you do from
4
+ // it (add and tick tasks, answer agents, approve) sent back.
4
5
  //
5
- // The computer: switched on in Settings, it listens on your network (port 7392)
6
- // for just two things: pairing, with a 6-digit code it shows for 10 minutes,
7
- // and "what's new since …" for a phone that paired. Nothing else is served there,
8
- // and nothing can be changed from it. The app itself stays on 127.0.0.1.
9
- // The phone: paired once with the computer's address and that code, it asks every
10
- // 2 minutes while it runs and notifies what's new, with the brief if there is one.
6
+ // The computer: switched on in Settings (or Setup), it listens on your network
7
+ // (port 7392) for just this: pairing, with a 6-digit code (or its QR) shown for 10
8
+ // minutes, and sealed requests from a phone that paired. The app itself stays on
9
+ // 127.0.0.1, and nothing here reaches its API: a phone gets what's new, a copy of
10
+ // your work, and a short list of changes (PHONE_OPS), each checked again here.
11
+ // It also says it's here on the network (mdns.mjs), so a phone finds it after the
12
+ // router hands it a new address, and, with a phone paired, keeps a line open to the
13
+ // relay (relay/), so the phone reaches it away from home.
14
+ // The phone: paired once, it asks every 2 minutes while it runs (sooner after a
15
+ // failed try, and at once when its network changes), notifies what's new, keeps the
16
+ // copy (computer.json) to show with the computer off, and queues what you change
17
+ // meanwhile (phone-queue.json), sent in order once the computer is back.
11
18
  //
12
- // config.phoneLink = { on, port?, phones: [{ id, name, token, added }] } (computer)
13
- // config.computer = { url, token, name, since } (phone; since: the computer's
14
- // clock, so only what's newer comes back)
19
+ // Sealed: at pairing the two agree a key (X25519, then HKDF), and every request and
20
+ // answer after is AES-256-GCM with it, stamped so it can't be replayed. The relay,
21
+ // and anyone on your Wi-Fi, see only that. The QR carries the computer key's
22
+ // fingerprint, so a scan can't pair with a stand-in.
23
+ //
24
+ // config.phoneLink = { on, port?, relay?: false to keep it to your Wi-Fi, pub,
25
+ // phones: [{ id, name, hash (of its token), pub, added }], gone: [{ id, pub, at }] }
26
+ // config.phoneSecret = { priv } (secrets.json) — the computer
27
+ // config.computer = { v: 2, url, urls, name, id, fp, pub, relay, relayUrl, since, offset, paired }
28
+ // config.computerSecret = { token, key } (secrets.json; on the Android app, sealed by
29
+ // its Keystore instead and handed in at start: SYMBIOT_COMPUTER_SECRET) — the phone
15
30
  import { createServer } from "node:http";
16
31
  import { hostname, networkInterfaces } from "node:os";
17
- import { randomBytes, randomInt, timingSafeEqual } from "node:crypto";
18
- import { loadConfig, saveConfig, hasCmd } from "./core.mjs";
32
+ import { join } from "node:path";
33
+ import { readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync } from "node:fs";
34
+ import { randomBytes, randomInt, randomUUID, timingSafeEqual, createHash, generateKeyPairSync, createPublicKey, createPrivateKey, diffieHellman, hkdfSync, createCipheriv, createDecipheriv } from "node:crypto";
35
+ import { CONFIG_DIR, loadConfig, saveConfig, hasCmd, loadTasks } from "./core.mjs";
19
36
  import { newsAfter, newsNotice } from "./watch.mjs";
20
37
  import { desktopNotify } from "./desktop.mjs";
38
+ import { announce, browse } from "./mdns.mjs";
39
+ import { qrSvg } from "./qr.mjs";
21
40
 
22
41
  const PORT = 7392;
23
42
  const CODE_MS = 10 * 60 * 1000, CODE_TRIES = 5;
24
- const POLL_MS = 2 * 60 * 1000;
25
- const PHONE = process.platform === "android"; // the Android app, or Termux
43
+ const POLL_MS = 2 * 60 * 1000, BACKOFF = [15000, 30000, 60000]; // after a failed ask: sooner, then back to every 2 minutes
44
+ const SKEW_MS = 10 * 60 * 1000; // a request older (or newer) than this is refused: it could be a replay
45
+ const ANDROID_APP = process.env.SYMBIOT_ANDROID_APP === "1";
46
+ const PHONE = process.platform === "android" || ANDROID_APP || process.env.SYMBIOT_AS_PHONE === "1"; // the Android app, or Termux (or a test of either)
47
+ const PAIR_SITE = "https://symbiot.co.za/pair";
48
+ const relayBase = () => String(process.env.SYMBIOT_RELAY || "https://relay.symbiot.co.za").replace(/\/+$/, "");
49
+ const relayAllowed = () => process.env.SYMBIOT_NO_RELAY !== "1";
26
50
  const same = (a, b) => { const x = Buffer.from(String(a || "")), y = Buffer.from(String(b || "")); return x.length === y.length && x.length > 0 && timingSafeEqual(x, y); };
51
+ const sha = (s) => createHash("sha256").update(s).digest("hex");
52
+ const PATHS = { queue: join(CONFIG_DIR, "phone-queue.json"), copy: join(CONFIG_DIR, "computer.json"), applied: join(CONFIG_DIR, "phone-applied.json"),
53
+ seal: join(CONFIG_DIR, "android-seal.json"), nsdWant: join(CONFIG_DIR, "android-nsd.want"), nsd: join(CONFIG_DIR, "android-nsd.json") };
54
+ const readJson = (f, d) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return d; } };
55
+ const writeJson = (f, v) => { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(f, JSON.stringify(v), { mode: 0o600 }); return true; } catch { return false; } };
27
56
 
28
- // ---- the computer ----------------------------------------------------------------
57
+ // ---- sealing --------------------------------------------------------------------------
58
+ const X25519_SPKI = Buffer.from("302a300506032b656e032100", "hex"); // DER head of an X25519 public key; the 32 raw bytes follow
59
+ function newKeys() {
60
+ const { publicKey, privateKey } = generateKeyPairSync("x25519");
61
+ return { pub: publicKey.export({ type: "spki", format: "der" }).subarray(12).toString("base64url"), priv: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64url") };
62
+ }
63
+ // The key a phone and its computer share: X25519 between one's private key and the
64
+ // other's public one, through HKDF, salted with both public keys (phone's first).
65
+ function linkKey(myPriv, theirPub, phonePub, computerPub) {
66
+ const s = diffieHellman({ privateKey: createPrivateKey({ key: Buffer.from(myPriv, "base64url"), format: "der", type: "pkcs8" }),
67
+ publicKey: createPublicKey({ key: Buffer.concat([X25519_SPKI, Buffer.from(theirPub, "base64url")]), format: "der", type: "spki" }) });
68
+ return Buffer.from(hkdfSync("sha256", s, Buffer.concat([Buffer.from(phonePub, "base64url"), Buffer.from(computerPub, "base64url")]), "symbiot phone link v1", 32));
69
+ }
70
+ // What a phone checks the computer by: in the QR, in the mDNS TXT record, pinned at pairing.
71
+ const fingerprint = (pub) => createHash("sha256").update(Buffer.from(String(pub || ""), "base64url")).digest("base64url").slice(0, 22);
72
+ // aad binds a sealed message to its place: a request to its phone, an answer to its request.
73
+ function seal(key, obj, aad) {
74
+ const iv = randomBytes(12), c = createCipheriv("aes-256-gcm", key, iv); c.setAAD(Buffer.from(aad));
75
+ return { n: iv.toString("base64url"), c: Buffer.concat([c.update(JSON.stringify(obj)), c.final(), c.getAuthTag()]).toString("base64url") };
76
+ }
77
+ function unseal(key, env, aad) {
78
+ try {
79
+ const iv = Buffer.from(String(env.n), "base64url"), b = Buffer.from(String(env.c), "base64url");
80
+ if (iv.length !== 12 || b.length < 17) return null;
81
+ const d = createDecipheriv("aes-256-gcm", key, iv); d.setAAD(Buffer.from(aad)); d.setAuthTag(b.subarray(-16));
82
+ return JSON.parse(Buffer.concat([d.update(b.subarray(0, -16)), d.final()]).toString("utf8"));
83
+ } catch { return null; }
84
+ }
85
+
86
+ // ---- the computer --------------------------------------------------------------------------
29
87
  function linkCfg(cfg = loadConfig()) {
30
88
  const l = cfg.phoneLink || {};
31
- return { on: !!l.on, port: Number(l.port) || PORT, phones: Array.isArray(l.phones) ? l.phones.filter((p) => p && p.token) : [] };
89
+ return { on: !!l.on, port: Number(l.port) || PORT, relay: l.relay !== false, pub: String(l.pub || ""),
90
+ phones: (Array.isArray(l.phones) ? l.phones : []).filter((p) => p && p.id && (p.hash || p.token)), gone: (Array.isArray(l.gone) ? l.gone : []).filter((g) => g && g.id && g.pub).slice(-10) };
91
+ }
92
+ function saveLink(l) { const cfg = loadConfig(); cfg.phoneLink = { ...(cfg.phoneLink || {}), ...l }; return saveConfig(cfg); }
93
+ // Only a hash of each phone's token is kept: a copied config can't pose as a phone.
94
+ // One kept whole (paired before 0.59) is hashed the first time it's read.
95
+ function hashTokens() {
96
+ const cfg = loadConfig(), l = cfg.phoneLink; if (!l || !Array.isArray(l.phones) || !l.phones.some((p) => p && p.token)) return;
97
+ l.phones = l.phones.map((p) => (p && p.token ? (({ token, ...rest }) => ({ ...rest, hash: sha(token), ...(p.pub ? {} : { legacy: true }) }))(p) : p));
98
+ saveConfig(cfg);
99
+ }
100
+ // This computer's key pair, made the first time the link is switched on.
101
+ function linkKeys() {
102
+ const cfg = loadConfig(), l = cfg.phoneLink || {}, s = cfg.phoneSecret || {};
103
+ if (l.pub && s.priv) return { pub: l.pub, priv: s.priv };
104
+ const k = newKeys(); cfg.phoneLink = { ...l, pub: k.pub }; cfg.phoneSecret = { priv: k.priv }; saveConfig(cfg);
105
+ return k;
106
+ }
107
+ const keys = new Map(); // a phone's public key -> the key we share
108
+ function keyFor(phonePub) {
109
+ if (!keys.has(phonePub)) { const k = linkKeys(); keys.set(phonePub, linkKey(k.priv, phonePub, phonePub, k.pub)); }
110
+ return keys.get(phonePub);
111
+ }
112
+ // The relay knows this computer by a hash of a secret drawn from its key; only this
113
+ // computer can answer for it (it shows the secret, the relay hashes it). Phones get the id.
114
+ function relayOf(priv) {
115
+ const secret = Buffer.from(hkdfSync("sha256", Buffer.from(priv, "base64url"), Buffer.alloc(0), "symbiot relay v1", 32)).toString("base64url");
116
+ return { secret, id: createHash("sha256").update(secret).digest("base64url").slice(0, 32) };
32
117
  }
33
- function saveLink(l) { const cfg = loadConfig(); cfg.phoneLink = l; return saveConfig(cfg); }
34
118
  // This computer's addresses on your network, the likeliest first (not Docker's,
35
119
  // a VM's or a VPN's bridges).
36
120
  function lanAddresses(ifs = networkInterfaces()) {
@@ -44,70 +128,268 @@ function lanAddresses(ifs = networkInterfaces()) {
44
128
  }
45
129
 
46
130
  let listener = null, listenErr = "", pairing = null; // pairing: { code, until, tries }
47
- const lastSeen = new Map(); // phone id -> when it last asked
131
+ const lastSeen = new Map(); // phone id -> { at, via }
132
+ // The link a QR carries: the site's pairing page, which hands it to the app (the
133
+ // part after # never leaves the phone). Addresses, port, code and key fingerprint.
134
+ function pairLink(code, l = linkCfg()) { return `${PAIR_SITE}#a=${lanAddresses().slice(0, 3).join(",")}&p=${l.port}&c=${code}&k=${fingerprint(linkKeys().pub)}`; }
48
135
  function linkState() {
49
136
  const l = linkCfg();
50
137
  if (pairing && Date.now() > pairing.until) pairing = null;
138
+ const link = pairing && listener ? pairLink(pairing.code, l) : "";
51
139
  return { role: "computer", on: l.on, port: l.port, listening: !!listener, ...(listenErr ? { error: listenErr } : {}), addresses: lanAddresses(),
52
- phones: l.phones.map((p) => ({ id: p.id, name: p.name, added: p.added, ...(lastSeen.has(p.id) ? { seen: lastSeen.get(p.id) } : {}) })),
53
- ...(pairing ? { code: pairing.code, until: pairing.until } : {}) };
140
+ ...(l.pub ? { fp: fingerprint(l.pub) } : {}), relay: { on: l.relay, ...relayState() }, found: !!mdns,
141
+ phones: l.phones.map((p) => ({ id: p.id, name: p.name, added: p.added, ...(p.legacy || !p.pub ? { old: true } : {}), ...(lastSeen.has(p.id) ? { seen: lastSeen.get(p.id).at, via: lastSeen.get(p.id).via } : p.seen ? { seen: p.seen } : {}) })),
142
+ ...(pairing ? { code: pairing.code, until: pairing.until } : {}), ...(link ? { link, qr: qrSvg(link) } : {}) };
54
143
  }
55
144
  // A new pairing code, good for 10 minutes and 5 tries.
56
145
  function newCode() { pairing = { code: String(randomInt(0, 1e6)).padStart(6, "0"), until: Date.now() + CODE_MS, tries: 0 }; return linkState(); }
57
- function unpairPhone(id) { const l = linkCfg(); l.phones = l.phones.filter((p) => p.id !== id); lastSeen.delete(id); saveLink(l); return linkState(); }
146
+ // Unpaired: it gets nothing more, and what it had queued (an approve too) is refused.
147
+ // Its key is kept a while, so it's told so, sealed, rather than left guessing.
148
+ function unpairPhone(id) {
149
+ const l = linkCfg(), p = l.phones.find((x) => x.id === id);
150
+ l.phones = l.phones.filter((x) => x.id !== id); if (p && p.pub) l.gone = [...l.gone, { id: p.id, pub: p.pub, at: Date.now() }].slice(-10);
151
+ lastSeen.delete(id); saveLink({ phones: l.phones, gone: l.gone });
152
+ if (!l.phones.some((x) => x.pub)) stopRelay();
153
+ return linkState();
154
+ }
155
+ function setRelay(on) { saveLink({ relay: !!on }); if (on) startRelay(); else stopRelay(); return linkState(); }
58
156
 
59
157
  function reply(res, code, obj) { res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(obj)); }
60
- function body(req) {
158
+ function body(req, max = 256 * 1024) {
61
159
  return new Promise((resolve) => {
62
- let d = ""; req.on("data", (ch) => { d += ch; if (d.length > 4096) req.destroy(); });
160
+ let d = ""; req.on("data", (ch) => { d += ch; if (d.length > max) req.destroy(); });
63
161
  req.on("end", () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({}); } });
64
162
  });
65
163
  }
164
+ const NOT_PAIRED = "This phone isn't paired with this computer. Pair it again: in Symbiot on your computer, Settings → Your phone.";
165
+ const UNPAIRED = "This phone was unpaired on your computer. Pair it again to get its news and send it changes.";
166
+ const OLD_PHONE = "Symbiot on your computer now seals what it sends your phone. Update Symbiot on this phone, then pair it again.";
66
167
  async function handle(req, res) {
67
168
  const u = new URL(req.url, "http://phone");
169
+ if (u.pathname === "/phone/hello" && req.method === "GET") { const l = linkCfg(); return reply(res, 200, { v: 1, pub: l.pub || linkKeys().pub, fp: fingerprint(l.pub || linkKeys().pub) }); }
68
170
  if (u.pathname === "/phone/pair" && req.method === "POST") {
69
- const b = await body(req);
70
- if (!pairing || Date.now() > pairing.until) { pairing = null; return reply(res, 403, { error: "No pairing code is open. In Symbiot on your computer, open Settings → Watch on your phone and click New code." }); }
71
- if (!same(String(b.code || "").replace(/\D/g, ""), pairing.code)) {
171
+ const b = await body(req, 4096);
172
+ if (!b.pub || !b.c) return reply(res, 400, { error: OLD_PHONE });
173
+ if (!pairing || Date.now() > pairing.until) { pairing = null; return reply(res, 403, { error: "No pairing code is open. In Symbiot on your computer, open Settings → Your phone and click Pair a phone." }); }
174
+ const k = linkKeys(), key = linkKey(k.priv, String(b.pub), String(b.pub), k.pub), m = unseal(key, b, "pair");
175
+ if (!m || !same(String(m.code || "").replace(/\D/g, ""), pairing.code)) {
72
176
  if (++pairing.tries >= CODE_TRIES) pairing = null;
73
177
  return reply(res, 403, { error: pairing ? "That code isn't right. Check it on your computer." : "That code was wrong too many times. Click New code on your computer." });
74
178
  }
75
179
  pairing = null;
76
- const l = linkCfg(), p = { id: randomBytes(4).toString("hex"), name: String(b.name || "Phone").replace(/\s+/g, " ").trim().slice(0, 40) || "Phone", token: randomBytes(24).toString("hex"), added: Date.now() };
77
- l.phones = [...l.phones, p].slice(-5); saveLink(l);
78
- return reply(res, 200, { token: p.token, id: p.id, name: hostname(), now: Date.now() });
79
- }
80
- if (u.pathname === "/phone/news" && req.method === "GET") {
81
- const p = linkCfg().phones.find((x) => same(x.token, req.headers["x-symbiot-phone"]));
82
- if (!p) return reply(res, 403, { error: "This phone isn't paired any more. Pair it again." });
83
- lastSeen.set(p.id, Date.now());
84
- const { news, briefs } = newsAfter(u.searchParams.get("since"));
85
- return reply(res, 200, { name: hostname(), now: Date.now(), news: news.slice(0, 100), briefs: briefs.slice(0, 20) });
180
+ const l = linkCfg(), token = randomBytes(24).toString("hex"), p = { id: randomBytes(4).toString("hex"), name: String(m.name || "Phone").replace(/\s+/g, " ").trim().slice(0, 40) || "Phone", hash: sha(token), pub: String(b.pub), added: Date.now() };
181
+ saveLink({ phones: [...l.phones, p].slice(-5) });
182
+ keys.set(p.pub, key); startRelay();
183
+ return reply(res, 200, seal(key, { token, id: p.id, name: hostname(), now: Date.now(), port: l.port, addresses: lanAddresses().slice(0, 3), relay: relayOf(k.priv).id, relayUrl: relayBase() }, "pair:" + b.n));
86
184
  }
185
+ // a phone paired before the link was sealed
186
+ if (u.pathname === "/phone/news" && req.method === "GET") return reply(res, 403, { error: OLD_PHONE });
187
+ const op = (u.pathname.match(/^\/phone\/(news|state|apply)$/) || [])[1];
188
+ if (op && req.method === "POST") { const r = await answer(await body(req), op, "wifi"); return reply(res, r.status, r.body); }
87
189
  reply(res, 404, { error: "not found" });
88
190
  }
191
+
192
+ // A sealed request, from your Wi-Fi or through the relay: who sent it (its token,
193
+ // checked against the hash kept), that it's fresh, and what it asks. Gives { status,
194
+ // body }: sealed, or plain only when it can't be sealed (no such phone).
195
+ const seenNonces = new Map();
196
+ const sealed = (key, n, s, b) => ({ status: 200, body: seal(key, { s, b }, "res:" + n) });
197
+ async function answer(env, op, via) {
198
+ env = env && typeof env === "object" ? env : {};
199
+ const l = linkCfg(), p = l.phones.find((x) => x.id === env.id && x.pub && !x.legacy);
200
+ if (!p) {
201
+ const g = l.gone.find((x) => x.id === env.id);
202
+ if (g) { const k = keyFor(g.pub); if (unseal(k, env, "req:" + g.id)) return sealed(k, env.n, 403, { error: UNPAIRED, unpaired: true }); }
203
+ return { status: 403, body: { error: NOT_PAIRED } };
204
+ }
205
+ const key = keyFor(p.pub), m = unseal(key, env, "req:" + p.id);
206
+ if (!m) return { status: 403, body: { error: NOT_PAIRED } };
207
+ const ok = p.hash ? same(sha(String(m.t || "")), p.hash) : same(m.t, p.token);
208
+ if (!ok) return sealed(key, env.n, 403, { error: UNPAIRED, unpaired: true });
209
+ if (p.token) hashTokens();
210
+ const now = Date.now();
211
+ for (const [n, t] of seenNonces) if (now - t > 2 * SKEW_MS) seenNonces.delete(n);
212
+ if (Math.abs(now - Number(m.ts)) > SKEW_MS || seenNonces.has(env.n)) return sealed(key, env.n, 409, { error: "That message was old, or came twice, so it was ignored. Check the time on your phone." });
213
+ seenNonces.set(env.n, now);
214
+ if ((op && m.op !== op) || !OPS[m.op]) return sealed(key, env.n, 403, { error: "That can't be asked from a phone." });
215
+ lastSeen.set(p.id, { at: now, via: via || "wifi" });
216
+ try { const r = await OPS[m.op](m.args && typeof m.args === "object" ? m.args : {}, p); return sealed(key, env.n, r.status || 200, r.body !== undefined ? r.body : r); }
217
+ catch (e) { return sealed(key, env.n, 500, { error: "It failed on your computer: " + ((e && e.message) || e) }); }
218
+ }
219
+ const OPS = {
220
+ news: ({ since }) => { const { news, briefs } = newsAfter(since); return { name: hostname(), now: Date.now(), news: news.slice(0, 100), briefs: briefs.slice(0, 20) }; },
221
+ state: async ({ etag }) => { const s = await snapshot(); return etag && etag === s.v ? { status: 304, body: { v: s.v } } : s; },
222
+ apply: async ({ ops }, phone) => applyOps(ops, phone),
223
+ };
224
+
225
+ // ---- the copy a phone keeps ---------------------------------------------------------------
226
+ // Small and in plain words: your open tasks, what needs you, agents' questions, the
227
+ // work waiting for your Approve (with a sum of exactly what's waiting, which an
228
+ // approve from the phone has to match), what Watch found, and what was approved
229
+ // from a phone lately. v changes only when something in it does.
230
+ let snapCache = null;
231
+ const plainText = (s, n = 280) => { const t = String(s || "").replace(/\s+/g, " ").trim(); return t.length > n ? t.slice(0, n - 1) + "…" : t; };
232
+ const approveSum = (r) => sha(JSON.stringify([(r.tasks || []).map((t) => t.id).sort(), (r.files || []).map((f) => (typeof f === "string" ? f : f.path || f.file || JSON.stringify(f))).sort(), r.stat || ""])).slice(0, 16);
233
+ const askId = (path) => "ask:" + sha(String(path)).slice(0, 12);
234
+ async function snapshot({ now = Date.now(), fresh = false } = {}) {
235
+ if (!fresh && snapCache && now - snapCache.at < 5000) return snapCache.snap;
236
+ const tryOr = async (f, d) => { try { return await f(); } catch { return d; } };
237
+ const home = await tryOr(() => import("./home.mjs"), null), tasksMod = await tryOr(() => import("./tasks.mjs"), null), agents = await tryOr(() => import("./agents.mjs"), null), watch = await tryOr(() => import("./watch.mjs"), null);
238
+ const h = home ? await tryOr(() => home.homeState({ now }), { you: [] }) : { you: [] };
239
+ const tasks = loadTasks().filter((t) => !t.archived).slice(0, 200).map((t) => ({ id: t.id, text: plainText(t.text), repo: t.repo || "", done: !!t.done, ...(t.review ? { review: true } : {}) }));
240
+ const you = h.you || [];
241
+ const needs = you.filter((y) => y.kind !== "ask" && y.kind !== "approve").map((y) => ({ id: y.id, title: plainText(y.title, 90), sub: plainText(y.sub, 160) }));
242
+ const asks = [];
243
+ for (const y of (await tryOr(() => agents.agentsList(), [])).filter((a, i, all) => all.findIndex((b) => b.path === a.path) === i)) {
244
+ const qs = await tryOr(() => agents.agentQuestions(y.path, "").questions, []);
245
+ if (qs.length) asks.push({ id: askId(y.path), path: y.path, name: plainText(String(y.name || "an agent").replace(/^Agent:\s*/, ""), 60), questions: qs.slice(0, 5).map((q) => ({ q: plainText(q.q, 300), options: (q.options || []).slice(0, 2).map((o) => plainText(o, 200)) })) });
246
+ }
247
+ const approves = (await tryOr(() => tasksMod.pendingReview(), [])).filter((r) => r.path && !r.running && ((r.tasks || []).length || (r.files || []).length))
248
+ .map((r) => ({ repo: r.repo, tasks: (r.tasks || []).map((t) => ({ id: t.id, text: plainText(t.text, 200) })), files: (r.files || []).length, stat: plainText(r.stat, 120), ...(r.untasked ? { untasked: true } : {}), sum: approveSum(r) }));
249
+ const board = watch ? await tryOr(() => watch.watchBoard(24, now), { cards: [] }) : { cards: [] };
250
+ const seen = (board.cards || []).filter((c) => c.count > 0).slice(0, 8).map((c) => ({ name: plainText(c.name, 60), label: plainText(c.label, 60), count: c.count, items: (c.items || []).filter((i) => i.need !== false).slice(0, 3).map((i) => ({ text: plainText(i.text, 140), ts: i.ts })) }));
251
+ const recent = (readJson(PATHS.applied, {}).approvals || []).filter((a) => now - a.at < 24 * 3600000).slice(-10);
252
+ const body = { tasks, needs, asks, approves, watch: seen, recent, relay: linkCfg().relay };
253
+ const snap = { v: sha(JSON.stringify(body)).slice(0, 16), name: hostname(), at: now, ...body };
254
+ snapCache = { at: now, snap };
255
+ return snap;
256
+ }
257
+
258
+ // ---- changes from a phone --------------------------------------------------------------------
259
+ // Only these, each absolute ("mark it done", never "flip it"), so one sent twice or
260
+ // late lands the same. Each has an id, and an id applied once is never applied
261
+ // again (phone-applied.json). Anything else is refused and reaches nothing.
262
+ const PHONE_OPS = ["task.add", "task.setDone", "agent.answer", "pending.approve"];
263
+ let approveHook = null; // server.mjs: the same Approve as the button (release notes and all)
264
+ function setPhoneApprove(fn) { approveHook = typeof fn === "function" ? fn : null; }
265
+ async function defaultApprove(repo, untasked) { const t = await import("./tasks.mjs"); return untasked ? t.approveChanges(repo, { tick: [] }) : t.approveRepo(repo, {}); }
266
+ async function applyOne(o, phone) {
267
+ const a = o.args && typeof o.args === "object" ? o.args : {}, at = Number(o.at) || Date.now();
268
+ if (o.op === "task.add") {
269
+ const text = String(a.text || "").trim().slice(0, 4000); if (!text) return { error: "That task was empty." };
270
+ const repo = String(a.repo || ""), { laneMap } = await import("./scan.mjs");
271
+ if (repo && !(repo in laneMap())) return { error: `There's no ${repo} on your computer any more, so the task wasn't added.` };
272
+ const { addTask } = await import("./tasks.mjs"), t = addTask(text, repo);
273
+ return t.error ? { error: "It couldn't be added." } : { ok: true, task: t.id, ...(t.duplicate ? { duplicate: true } : {}) };
274
+ }
275
+ if (o.op === "task.setDone") {
276
+ const { setTaskDone } = await import("./tasks.mjs"), r = setTaskDone(String(a.id || ""), !!a.done, { at });
277
+ if (r.conflict) return { conflict: true, error: `"${plainText(r.text, 60)}" was changed on your computer after you changed it here, so your computer's stays.` };
278
+ return r.error ? { error: r.error === "not found" ? "That task isn't on your computer any more." : r.error } : { ok: true };
279
+ }
280
+ if (o.op === "agent.answer") {
281
+ const path = String(a.path || ""), snap = await snapshot({ fresh: true });
282
+ if (!snap.asks.some((x) => x.path === path)) return { error: "That agent isn't asking any more (answered on your computer, or its run moved on)." };
283
+ const { answerQuestions } = await import("./agents.mjs"), r = answerQuestions(path, Array.isArray(a.answers) ? a.answers.slice(0, 5) : [], { rerun: true });
284
+ return r && r.ok ? { ok: true, ...(r.note ? { note: r.note } : {}) } : { error: (r && r.error) || "It couldn't be answered." };
285
+ }
286
+ if (o.op === "pending.approve") {
287
+ const repo = String(a.repo || ""), { pendingReview } = await import("./tasks.mjs");
288
+ const now = pendingReview().find((r) => r.repo === repo && r.path && ((r.tasks || []).length || (r.files || []).length));
289
+ if (!now) return { error: `Nothing in ${repo} is waiting for your Approve any more.` };
290
+ if (now.running) return { error: `The agent is still working in ${repo}. Approve once it finishes.` };
291
+ if (approveSum(now) !== String(a.sum || "")) return { refused: true, error: `The work in ${repo} changed since your phone showed it, so it wasn't approved. Look at what's there now, then approve that.` };
292
+ const ap = readJson(PATHS.applied, { ids: {}, approvals: [] }), entry = { id: o.id, repo, phone: phone.name, at: Date.now(), status: "approving" };
293
+ ap.approvals = [...(ap.approvals || []), entry].slice(-30); writeJson(PATHS.applied, { ids: ap.ids || {}, approvals: ap.approvals });
294
+ // Approve takes a while (a branch, a push, a PR): it carries on here, and the copy says how it went
295
+ Promise.resolve().then(() => (approveHook || defaultApprove)(repo, !!now.untasked)).then((r) => r || {}, (e) => ({ error: String((e && e.message) || e) })).then((r) => {
296
+ const cur = readJson(PATHS.applied, { ids: {}, approvals: [] }), x = (cur.approvals || []).find((y) => y.id === o.id);
297
+ if (x) { x.status = r.error ? "failed" : "approved"; if (r.error) x.error = plainText(r.error, 200); if (r.pr) x.pr = r.pr; x.done = Date.now(); writeJson(PATHS.applied, cur); }
298
+ snapCache = null;
299
+ });
300
+ return { ok: true, started: true };
301
+ }
302
+ return { refused: true, status: 403, error: `"${String(o.op || "").slice(0, 40)}" can't be done from a phone.` };
303
+ }
304
+ async function applyOps(ops, phone) {
305
+ ops = (Array.isArray(ops) ? ops : []).slice(0, 50);
306
+ const results = [];
307
+ for (const o of ops) {
308
+ const id = String((o && o.id) || "");
309
+ if (!/^[\w-]{8,64}$/.test(id)) { results.push({ id, refused: true, status: 403, error: "That isn't a change Symbiot knows." }); continue; }
310
+ const ap = readJson(PATHS.applied, { ids: {}, approvals: [] }); ap.ids = ap.ids || {};
311
+ if (ap.ids[id]) { results.push({ id, ok: true, again: true }); continue; }
312
+ const r = PHONE_OPS.includes(o.op) ? await applyOne(o, phone) : { refused: true, status: 403, error: `"${String(o.op || "").slice(0, 40)}" can't be done from a phone.` };
313
+ const now = readJson(PATHS.applied, { ids: {}, approvals: [] }), ids = { ...(now.ids || {}), [id]: Date.now() };
314
+ const keep = Object.entries(ids).sort((x, y) => y[1] - x[1]).slice(0, 2000); // a month of changes, more than any queue holds
315
+ writeJson(PATHS.applied, { ids: Object.fromEntries(keep), approvals: now.approvals || [] });
316
+ results.push({ id, ...r });
317
+ }
318
+ snapCache = null;
319
+ return { results, state: await snapshot({ fresh: true }) };
320
+ }
321
+ // Approved from a phone, lately: Home and the Workdesk say so.
322
+ function phoneApprovals(now = Date.now()) { return (readJson(PATHS.applied, {}).approvals || []).filter((a) => now - a.at < 24 * 3600000); }
323
+
324
+ // ---- saying it's here (mdns.mjs) ----------------------------------------------------------------
325
+ let mdns = null;
326
+ function startMdns() {
327
+ if (mdns || process.env.SYMBIOT_NO_MDNS === "1") return;
328
+ const l = linkCfg(), k = linkKeys();
329
+ mdns = announce({ id: sha(Buffer.from(k.pub, "base64url")).slice(0, 4), port: l.port, fp: fingerprint(k.pub), addresses: () => lanAddresses() }, { onError: () => {} });
330
+ }
331
+ function stopMdns() { if (mdns) mdns.stop(); mdns = null; }
332
+
333
+ // ---- away from home: the relay (relay/) --------------------------------------------------------
334
+ // While the link is on and a phone is paired, the computer keeps a line open to the
335
+ // relay (it connects out: no port opened on your router), and answers what a phone
336
+ // sends through it as it would on your Wi-Fi. The relay passes sealed messages only.
337
+ let relaySock = null, relayErr = "", relayUp = false, relayTries = 0, relayTimer = null, relayPing = null;
338
+ function relayState() { return { connected: relayUp, ...(relayErr ? { error: relayErr } : {}) }; }
339
+ function relayWanted() { const l = linkCfg(); return l.on && l.relay && relayAllowed() && l.phones.some((p) => p.pub && !p.legacy); }
340
+ function startRelay() {
341
+ if (relaySock || relayTimer || !relayWanted()) return;
342
+ if (typeof globalThis.WebSocket !== "function") { relayErr = "Away from home needs Node 22 or newer on this computer."; return; }
343
+ const { id, secret } = relayOf(linkKeys().priv);
344
+ let ws; try { ws = new WebSocket(relayBase().replace(/^http/, "ws") + `/v1/${id}/computer`); } catch (e) { relayErr = String((e && e.message) || e); return retryRelay(); }
345
+ relaySock = ws;
346
+ ws.addEventListener("open", () => { try { ws.send(JSON.stringify({ hello: secret })); } catch {} });
347
+ ws.addEventListener("message", async (ev) => {
348
+ let m; try { m = JSON.parse(String(ev.data)); } catch { return; }
349
+ if (m.ready) { relayUp = true; relayErr = ""; relayTries = 0; return; }
350
+ if (m.error) { relayErr = String(m.error).slice(0, 200); return; }
351
+ if (!m.rid) return;
352
+ const r = await answer(m.body, null, "relay");
353
+ try { ws.send(JSON.stringify({ rid: m.rid, s: r.status, b: r.body })); } catch {}
354
+ });
355
+ ws.addEventListener("close", () => { if (relaySock === ws) { relaySock = null; relayUp = false; retryRelay(); } });
356
+ ws.addEventListener("error", () => { relayErr = relayErr || `Couldn't reach ${relayBase()}.`; });
357
+ clearInterval(relayPing); relayPing = setInterval(() => { try { if (relaySock && relaySock.readyState === 1) relaySock.send('{"ping":1}'); } catch {} }, 30000); relayPing.unref();
358
+ }
359
+ function retryRelay() {
360
+ if (relayTimer || !relayWanted()) return;
361
+ const wait = Math.min(5 * 60000, 2000 * 2 ** Math.min(relayTries++, 8));
362
+ relayTimer = setTimeout(() => { relayTimer = null; startRelay(); }, wait); relayTimer.unref();
363
+ }
364
+ function stopRelay() {
365
+ clearTimeout(relayTimer); relayTimer = null; clearInterval(relayPing); relayPing = null;
366
+ const ws = relaySock; relaySock = null; relayUp = false; relayTries = 0;
367
+ if (ws) try { ws.close(); } catch {}
368
+ }
369
+
89
370
  // Listen on the network while it's switched on. Gives the state.
90
371
  function startPhoneLink() {
91
372
  const l = linkCfg();
92
373
  if (!l.on || listener) return Promise.resolve(linkState());
374
+ hashTokens(); linkKeys();
93
375
  return new Promise((resolve) => {
94
376
  const srv = createServer((req, res) => { handle(req, res).catch(() => { try { reply(res, 500, { error: "failed" }); } catch {} }); });
95
377
  srv.on("error", (e) => {
96
378
  listenErr = e && e.code === "EADDRINUSE" ? `Port ${l.port} is in use by something else on this computer.` : `Couldn't listen on port ${l.port}: ${(e && e.message) || e}`;
97
379
  listener = null; resolve(linkState());
98
380
  });
99
- srv.listen(l.port, "0.0.0.0", () => { listener = srv; listenErr = ""; srv.unref(); resolve(linkState()); });
381
+ srv.listen(l.port, "0.0.0.0", () => { listener = srv; listenErr = ""; srv.unref(); startMdns(); startRelay(); resolve(linkState()); });
100
382
  });
101
383
  }
102
- function stopPhoneLink() { if (listener) { listener.close(); if (listener.closeAllConnections) listener.closeAllConnections(); } listener = null; pairing = null; }
384
+ function stopPhoneLink() { if (listener) { listener.close(); if (listener.closeAllConnections) listener.closeAllConnections(); } listener = null; pairing = null; stopMdns(); stopRelay(); }
103
385
  async function setPhoneLink(on) {
104
- const l = linkCfg(); l.on = !!on; saveLink(l);
386
+ saveLink({ on: !!on });
105
387
  if (!on) { stopPhoneLink(); listenErr = ""; return linkState(); }
106
388
  await startPhoneLink();
107
389
  return listener && !pairing ? newCode() : linkState();
108
390
  }
109
391
 
110
- // ---- the phone -------------------------------------------------------------------
392
+ // ---- the phone -------------------------------------------------------------------------------
111
393
  // What the user typed as the computer's address: "192.168.8.50", ":7392" optional.
112
394
  function computerUrl(input) {
113
395
  let s = String(input || "").trim().replace(/\/+$/, "");
@@ -115,53 +397,218 @@ function computerUrl(input) {
115
397
  if (!/^https?:\/\//i.test(s)) s = "http://" + s;
116
398
  try { const u = new URL(s); if (!u.hostname) return ""; if (!u.port) u.port = String(PORT); return `${u.protocol}//${u.host}`; } catch { return ""; }
117
399
  }
118
- const canNotify = () => process.env.SYMBIOT_ANDROID_APP === "1" || !PHONE || hasCmd("termux-notification");
119
- let pollLast = 0, pollErr = "", pollTimer = null;
400
+ // A scanned pairing link: the site's (https://symbiot.co.za/pair#a=…) or the app's
401
+ // own (symbiot://pair?a=…). { addresses, port, code, fp } or null.
402
+ function parsePairLink(s) {
403
+ s = String(s || "").trim();
404
+ const m = s.match(/^(?:https:\/\/(?:www\.)?symbiot\.co\.za\/pair\/?[#?]|symbiot:\/\/pair\/?\?)(.*)$/i); if (!m) return null;
405
+ const q = new URLSearchParams(m[1].replace(/^[#?]/, "")), addresses = String(q.get("a") || "").split(",").map((a) => a.trim()).filter((a) => /^[\w.:-]+$/.test(a)).slice(0, 5);
406
+ const port = Number(q.get("p")) || PORT, code = String(q.get("c") || "").replace(/\D/g, ""), fp = String(q.get("k") || "");
407
+ return addresses.length && /^\d{6}$/.test(code) ? { addresses, port, code, fp } : null;
408
+ }
409
+ const canNotify = () => ANDROID_APP || process.platform !== "android" || hasCmd("termux-notification");
410
+ // The phone's half of the key, its token too: sealed by the Android app's Keystore
411
+ // there (android-seal.json is how it's handed over, for a few seconds; it comes back
412
+ // in SYMBIOT_COMPUTER_SECRET at the next start), else in secrets.json (0600).
413
+ let memSecret;
414
+ function readSecret() {
415
+ if (ANDROID_APP) {
416
+ if (memSecret === undefined) { memSecret = readJson(PATHS.seal, null); try { if (!memSecret || !memSecret.token) memSecret = JSON.parse(process.env.SYMBIOT_COMPUTER_SECRET || "null"); } catch { memSecret = null; } }
417
+ return memSecret && memSecret.token && memSecret.key ? memSecret : null;
418
+ }
419
+ const s = loadConfig().computerSecret; return s && s.token && s.key ? s : null;
420
+ }
421
+ function writeSecret(s) {
422
+ if (ANDROID_APP) { memSecret = s || null; process.env.SYMBIOT_COMPUTER_SECRET = s ? JSON.stringify(s) : ""; writeJson(PATHS.seal, s || {}); return; }
423
+ const cfg = loadConfig(); if (s) cfg.computerSecret = s; else delete cfg.computerSecret; saveConfig(cfg);
424
+ }
425
+ let pollLast = 0, pollErr = "", pollTimer = null, fails = 0, lastVia = "", polling = null;
426
+ const loadQueue = () => { const q = readJson(PATHS.queue, []); return Array.isArray(q) ? q : []; };
427
+ const saveQueue = (q) => (q.length ? writeJson(PATHS.queue, q) : (() => { try { unlinkSync(PATHS.queue); } catch {} return true; })());
428
+ const loadCopy = () => readJson(PATHS.copy, null);
120
429
  function computerState() {
121
- const c = loadConfig().computer;
122
- return { role: "phone", paired: !!(c && c.token), ...(c && c.token ? { name: c.name, url: c.url } : {}), ...(pollLast ? { last: pollLast } : {}), ...(pollErr ? { error: pollErr } : {}), notify: canNotify(), termux: PHONE && process.env.SYMBIOT_ANDROID_APP !== "1" };
123
- }
124
- async function pairComputer(address, code, { fetchFn = fetch, name = hostname() } = {}) {
125
- const url = computerUrl(address);
126
- if (!url) return { ...computerState(), error: "Type your computer's address, as Symbiot shows it there (like 192.168.8.50:7392)." };
127
- if (!/^\d{6}$/.test(String(code || "").replace(/\s/g, ""))) return { ...computerState(), error: "Type the 6-digit code your computer shows." };
128
- let r;
129
- try { r = await fetchFn(url + "/phone/pair", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code: String(code).replace(/\s/g, ""), name: name === "localhost" ? "Phone" : name }), signal: AbortSignal.timeout(8000) }); }
130
- catch { return { ...computerState(), error: `Couldn't reach ${url}. Is Symbiot running there, with Watch on your phone switched on, and is this phone on the same Wi-Fi? A firewall on the computer may need to allow port ${new URL(url).port}.` }; }
131
- const j = await r.json().catch(() => ({}));
132
- if (!r.ok || !j.token) return { ...computerState(), error: j.error || `Your computer answered ${r.status}.` };
133
- const cfg = loadConfig(); cfg.computer = { url, token: j.token, name: String(j.name || "your computer").slice(0, 60), since: Number(j.now) || Date.now() }; saveConfig(cfg);
134
- pollErr = ""; startComputerPoll();
430
+ const c = loadConfig().computer, s = c && readSecret(), copy = c ? loadCopy() : null, heard = Math.max(pollLast, (copy && copy.got) || 0);
431
+ const old = !!c && !s; // paired before the link was sealed (or its key is gone)
432
+ return { role: "phone", paired: !!(c && (c.token || s)), ...(c ? { name: c.name, url: c.url } : {}), ...(old ? { old: true } : {}), ...(pollLast ? { last: pollLast } : {}), ...(heard ? { heard } : {}),
433
+ ...(lastVia ? { via: lastVia } : {}), ...(c && !old && fails >= 2 ? { away: true } : {}), ...(old ? { error: OLD_PAIRING } : pollErr ? { error: pollErr } : {}),
434
+ queued: c ? loadQueue().length : 0, notify: canNotify(), termux: PHONE && !ANDROID_APP };
435
+ }
436
+ const OLD_PAIRING = "Pair this phone with your computer again: the link between them is sealed now, and this pairing is from before.";
437
+ const OLD_COMPUTER = "Symbiot on your computer is older than this phone's. Update it there (Settings → Update), then pair again.";
438
+ const cantReach = (url) => `Couldn't reach ${url}. Is Symbiot running there, with Your phone switched on, and is this phone on the same Wi-Fi? A firewall on the computer may need to allow port ${new URL(url).port}.`;
439
+ // Pair with the address and code typed in, or a scanned link (opts.link), which
440
+ // gives both and the key to expect; each address in it is tried until one answers.
441
+ async function pairComputer(address, code, { fetchFn = fetch, name = hostname(), link = "" } = {}) {
442
+ const err = (e) => ({ ...computerState(), error: e });
443
+ const p = link ? parsePairLink(link) : null;
444
+ if (link && !p) return err("That isn't a pairing code from Symbiot. On your computer, open Settings → Your phone and scan the code it shows.");
445
+ const urls = p ? p.addresses.map((a) => computerUrl(`${a}:${p.port}`)).filter(Boolean) : [computerUrl(address)].filter(Boolean);
446
+ if (!urls.length) return err("Type your computer's address, as Symbiot shows it there (like 192.168.8.50:7392).");
447
+ code = p ? p.code : String(code || "").replace(/\s/g, "");
448
+ if (!/^\d{6}$/.test(code)) return err("Type the 6-digit code your computer shows.");
449
+ let last = "";
450
+ for (const url of urls) {
451
+ let h;
452
+ try { const r = await fetchFn(url + "/phone/hello", { signal: AbortSignal.timeout(5000) }); h = r.ok ? await r.json().catch(() => null) : null; if (!h || !h.pub) { last = OLD_COMPUTER; continue; } }
453
+ catch { last = cantReach(url); continue; }
454
+ if (p && p.fp && fingerprint(h.pub) !== p.fp) { last = `The computer at ${url} isn't the one whose code you scanned. Scan it again on your computer.`; continue; }
455
+ const mine = newKeys(), key = linkKey(mine.priv, h.pub, mine.pub, h.pub), env = { v: 1, pub: mine.pub, ...seal(key, { code, name: name === "localhost" ? "Phone" : name }, "pair") };
456
+ let r, j;
457
+ try { r = await fetchFn(url + "/phone/pair", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(env), signal: AbortSignal.timeout(8000) }); j = await r.json().catch(() => ({})); }
458
+ catch { last = cantReach(url); continue; }
459
+ if (!r.ok) return err(j.error || `Your computer answered ${r.status}.`);
460
+ const m = unseal(key, j, "pair:" + env.n);
461
+ if (!m || !m.token) return err("Your computer's answer couldn't be read. Pair again with a new code.");
462
+ const cfg = loadConfig(), was = cfg.computer, fp = fingerprint(h.pub);
463
+ const more = (Array.isArray(m.addresses) ? m.addresses : []).map((a) => computerUrl(`${a}:${Number(m.port) || PORT}`)).filter(Boolean);
464
+ cfg.computer = { v: 2, url, urls: [...new Set([url, ...more, ...urls])].slice(0, 6), name: String(m.name || "your computer").slice(0, 60), id: String(m.id), fp, pub: h.pub,
465
+ relay: String(m.relay || ""), relayUrl: String(m.relayUrl || ""), since: Number(m.now) || Date.now(), offset: (Number(m.now) || Date.now()) - Date.now(), paired: Date.now() };
466
+ saveConfig(cfg); writeSecret({ token: m.token, key: key.toString("base64url") });
467
+ if (!was || was.fp !== fp) { try { unlinkSync(PATHS.copy); } catch {} saveQueue([]); } // another computer: what was kept for the last one isn't its
468
+ pollErr = ""; fails = 0; lastVia = ""; startComputerPoll({ soon: true });
469
+ return computerState();
470
+ }
471
+ return err(last);
472
+ }
473
+ function forgetComputer() {
474
+ const cfg = loadConfig(); delete cfg.computer; delete cfg.computerSecret; saveConfig(cfg); if (ANDROID_APP) writeSecret(null);
475
+ for (const f of [PATHS.copy, PATHS.queue]) try { unlinkSync(f); } catch {}
476
+ pollErr = ""; pollLast = 0; fails = 0; lastVia = ""; clearTimeout(pollTimer); pollTimer = null;
135
477
  return computerState();
136
478
  }
137
- function forgetComputer() { const cfg = loadConfig(); delete cfg.computer; saveConfig(cfg); pollErr = ""; pollLast = 0; return computerState(); }
138
- // Ask the computer what's new and notify it here, one notification per watch
139
- // per read, as the computer did. Gives { state, shown }.
140
- async function pollComputer({ fetchFn = fetch, notify = desktopNotify } = {}) {
141
- const c = loadConfig().computer; if (!c || !c.token) return { ...computerState(), shown: 0 };
142
- let r;
143
- try { r = await fetchFn(`${c.url}/phone/news?since=${encodeURIComponent(c.since || 0)}`, { headers: { "x-symbiot-phone": c.token }, signal: AbortSignal.timeout(10000) }); }
144
- catch { pollErr = `Couldn't reach ${c.name} at ${c.url}. It's asked again in a few minutes (the computer may be off or asleep, or on another network).`; return { ...computerState(), shown: 0 }; }
145
- const j = await r.json().catch(() => ({}));
146
- if (!r.ok) { pollErr = j.error || `${c.name} answered ${r.status}.`; return { ...computerState(), shown: 0 }; }
147
- pollErr = ""; pollLast = Date.now();
148
- const news = Array.isArray(j.news) ? j.news : [], briefs = Array.isArray(j.briefs) ? j.briefs : [];
479
+ // Where the paired computer is now, by its key's fingerprint: the Android app asks
480
+ // Android's own discovery (the service runs it: android-nsd.want, then .json),
481
+ // anything else asks the network itself (mdns.mjs).
482
+ async function discoverComputer(c) {
483
+ return (await findComputers()).filter((f) => f.fp === c.fp).map((f) => f.url);
484
+ }
485
+ // Every Symbiot saying it's here on this network, for the pairing screen: [{ name, url, fp }].
486
+ async function findComputers() {
487
+ let found = [];
488
+ if (ANDROID_APP) {
489
+ const asked = Date.now(); writeJson(PATHS.nsdWant, { at: asked });
490
+ for (let i = 0; i < 16; i++) {
491
+ await new Promise((r) => setTimeout(r, 500));
492
+ try { if (statSync(PATHS.nsd).mtimeMs >= asked - 1000) { found = readJson(PATHS.nsd, []); break; } } catch {}
493
+ }
494
+ } else found = await browse({ timeout: 2000 }).catch(() => []);
495
+ return (Array.isArray(found) ? found : []).filter((f) => f && f.port).flatMap((f) => (f.addresses || [f.host]).filter(Boolean).map((a) => ({ name: String(f.name || "Symbiot").slice(0, 40), url: computerUrl(`${a}:${f.port}`), fp: String(f.fp || "") }))).filter((f) => f.url);
496
+ }
497
+ // One sealed request to the paired computer, wherever it is: the address that last
498
+ // answered, the others it gave at pairing, wherever it says it is now (mDNS), and
499
+ // only then the relay. { status, body, via } or { unreachable: true }.
500
+ async function ask(op, args, { fetchFn = fetch, discover = discoverComputer } = {}) {
501
+ const c = loadConfig().computer, s = readSecret();
502
+ if (!c || !s) return { status: 0, body: { error: OLD_PAIRING } };
503
+ const key = Buffer.from(s.key, "base64url");
504
+ const make = () => ({ v: 1, id: c.id, ...seal(key, { t: s.token, ts: Date.now() + (Number(c.offset) || 0), op, args }, "req:" + c.id) });
505
+ const wait = op === "apply" ? 30000 : 6000, tried = new Set();
506
+ const viaUrl = async (url) => { const env = make(), r = await fetchFn(`${url}/phone/${op}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(env), signal: AbortSignal.timeout(wait) }); return { env, status: r.status, json: await r.json().catch(() => ({})), via: url }; };
507
+ let res = null;
508
+ for (const url of [c.url, ...(c.urls || [])]) { if (!url || tried.has(url)) continue; tried.add(url); try { res = await viaUrl(url); break; } catch {} }
509
+ if (!res) for (const url of await discover(c).catch(() => [])) { if (tried.has(url)) continue; tried.add(url); try { res = await viaUrl(url); break; } catch {} }
510
+ if (!res && c.relay && !c.relayOff && relayAllowed()) {
511
+ try {
512
+ const env = make(), r = await fetchFn(`${String(process.env.SYMBIOT_RELAY || c.relayUrl || relayBase()).replace(/\/+$/, "")}/v1/${c.relay}/ask`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(env), signal: AbortSignal.timeout(wait + 5000) });
513
+ if (r.status === 200) { const j = await r.json(); res = { env, status: Number(j.s) || 0, json: j.b || {}, via: "relay" }; }
514
+ } catch {}
515
+ }
516
+ if (!res) return { unreachable: true };
517
+ if (res.via !== "relay" && res.via !== c.url) { const cfg = loadConfig(); if (cfg.computer && cfg.computer.id === c.id) { cfg.computer.url = res.via; cfg.computer.urls = [...new Set([res.via, ...(cfg.computer.urls || [])])].slice(0, 6); saveConfig(cfg); } }
518
+ const j = res.json || {};
519
+ if (j.n && j.c) { const m = unseal(key, j, "res:" + res.env.n); return m ? { status: Number(m.s) || 0, body: m.b || {}, sealed: true, via: res.via } : { status: 0, body: { error: "Your computer's answer couldn't be read." }, via: res.via }; }
520
+ return { status: res.status, body: j, sealed: false, via: res.via };
521
+ }
522
+ // The copy from the computer, with what you changed here that hasn't reached it yet
523
+ // laid over it ("waiting to send"), and what couldn't be applied there.
524
+ function saveCopy(snap, extra = {}) { const old = loadCopy() || {}; writeJson(PATHS.copy, { ...snap, got: Date.now(), notes: extra.notes !== undefined ? extra.notes : old.notes || [] }); }
525
+ function computerView() {
526
+ const st = computerState(), copy = loadCopy(), q = loadQueue();
527
+ if (!copy) return { ...st, copy: null };
528
+ const v = JSON.parse(JSON.stringify(copy));
529
+ for (const k of ["tasks", "needs", "asks", "approves", "watch", "recent", "notes"]) if (!Array.isArray(v[k])) v[k] = [];
530
+ for (const o of q) {
531
+ const a = o.args || {};
532
+ if (o.op === "task.add") v.tasks.unshift({ id: "q:" + o.id, text: plainText(a.text), repo: a.repo || "", done: false, waiting: true });
533
+ if (o.op === "task.setDone") { const t = v.tasks.find((x) => x.id === a.id); if (t) { t.done = !!a.done; t.waiting = true; } }
534
+ if (o.op === "agent.answer") { const x = v.asks.find((y) => y.path === a.path); if (x) { x.waiting = true; x.answered = (a.answers || []).map((y) => y.a); } }
535
+ if (o.op === "pending.approve") { const x = v.approves.find((y) => y.repo === a.repo); if (x) x.waiting = true; }
536
+ }
537
+ return { ...st, copy: v };
538
+ }
539
+ // A change made here: kept in order, shown at once, sent as soon as the computer answers.
540
+ // (refused: why it wasn't kept; error stays what the last ask of the computer said)
541
+ function queueChange(op, args = {}) {
542
+ const c = loadConfig().computer; if (!c) return { ...computerState(), refused: "Pair with your computer first." };
543
+ if (!PHONE_OPS.includes(op)) return { ...computerView(), refused: "That can't be done from here." };
544
+ const a = {};
545
+ if (op === "task.add") { a.text = String(args.text || "").trim().slice(0, 4000); a.repo = String(args.repo || ""); if (!a.text) return { ...computerView(), refused: "Type the task first." }; }
546
+ if (op === "task.setDone") { a.id = String(args.id || ""); a.done = !!args.done; }
547
+ if (op === "agent.answer") { a.path = String(args.path || ""); a.answers = (Array.isArray(args.answers) ? args.answers : []).slice(0, 5).map((x) => ({ q: String((x && x.q) || ""), a: String((x && x.a) || "").slice(0, 2000) })).filter((x) => x.q && x.a); if (!a.answers.length) return { ...computerView(), refused: "Pick or type an answer first." }; }
548
+ if (op === "pending.approve") { a.repo = String(args.repo || ""); a.sum = String(args.sum || ""); }
549
+ const q = loadQueue();
550
+ if (op === "task.setDone") for (let i = q.length - 1; i >= 0; i--) if (q[i].op === "task.setDone" && q[i].args.id === a.id) q.splice(i, 1); // the last tick wins
551
+ q.push({ id: randomUUID(), at: Date.now() + (Number(c.offset) || 0), op, args: a }); saveQueue(q);
552
+ if (process.env.SYMBIOT_NO_POLL !== "1") setTimeout(() => { pollComputer().catch(() => {}); }, 30).unref();
553
+ return computerView();
554
+ }
555
+ function dismissNote(id) { const c = loadCopy(); if (c) saveCopy(c, { notes: (c.notes || []).filter((n) => n.id !== id) }); return computerView(); }
556
+ // Ask the computer: send what was queued here, then what's new (notified, one
557
+ // notification per watch per read), then the copy (unchanged costs nothing).
558
+ async function pollComputer(opts = {}) { if (polling) return polling; polling = pollOnce(opts).finally(() => { polling = null; schedulePoll(); }); return polling; }
559
+ async function pollOnce({ fetchFn = fetch, notify = desktopNotify, discover } = {}) {
560
+ const c = loadConfig().computer; if (!c) return { ...computerState(), shown: 0 };
561
+ if (!readSecret()) return { ...computerState(), shown: 0 };
562
+ const via = { fetchFn, ...(discover ? { discover } : {}) };
563
+ const away = () => { fails++; pollErr = `Couldn't reach ${c.name}${fails > 1 ? "" : `. It's asked again in ${BACKOFF[0] / 1000} seconds`} (it may be off or asleep${c.relay ? "" : ", or on another network"}).`; return { ...computerState(), shown: 0 }; };
564
+ const refused = (r) => {
565
+ pollErr = (r.body && r.body.error) || `${c.name} answered ${r.status}.`; fails = 0;
566
+ // unpaired there: an approve it had queued never goes, even if it's paired again later
567
+ if (r.sealed && r.body && r.body.unpaired) {
568
+ const q = loadQueue(), gone = q.filter((o) => o.op === "pending.approve");
569
+ if (gone.length) { saveQueue(q.filter((o) => o.op !== "pending.approve")); const copy = loadCopy(); if (copy) saveCopy(copy, { notes: [...(copy.notes || []), ...gone.map((o) => ({ id: o.id, op: o.op, what: o.args.repo, error: `Not approved: this phone was unpaired on ${c.name}.`, at: Date.now() }))].slice(-20) }); }
570
+ }
571
+ return { ...computerState(), shown: 0 };
572
+ };
573
+ const q = loadQueue();
574
+ if (q.length) {
575
+ const r = await ask("apply", { ops: q }, via);
576
+ if (r.unreachable) return away();
577
+ if (r.status !== 200) return refused(r);
578
+ const done = new Set((r.body.results || []).map((x) => x.id)), failed = (r.body.results || []).filter((x) => !x.ok && x.error);
579
+ saveQueue(loadQueue().filter((o) => !done.has(o.id)));
580
+ const notes = [...((loadCopy() || {}).notes || []), ...failed.map((x) => { const o = q.find((y) => y.id === x.id) || {}; return { id: x.id, op: o.op, what: plainText((o.args && (o.args.text || o.args.repo)) || "", 80), error: x.error, at: Date.now() }; })].slice(-20);
581
+ if (r.body.state && r.body.state.v) saveCopy(r.body.state, { notes });
582
+ }
583
+ const r = await ask("news", { since: c.since || 0 }, via);
584
+ if (r.unreachable) return away();
585
+ if (r.status !== 200) return refused(r);
586
+ pollErr = ""; fails = 0; pollLast = Date.now(); lastVia = r.via === "relay" ? "relay" : "wifi";
587
+ const news = Array.isArray(r.body.news) ? r.body.news : [], briefs = Array.isArray(r.body.briefs) ? r.body.briefs : [];
149
588
  const batches = new Map();
150
589
  for (const n of news) { const k = n.watch + " " + n.ts; if (!batches.has(k)) batches.set(k, []); batches.get(k).push(n); }
151
590
  for (const [k, list] of batches) { const b = briefs.find((x) => x.watch + " " + x.ts === k); notify(...newsNotice(list, list[0].name, b ? b.text : "")); }
152
591
  if (news.length) { const cfg = loadConfig(); if (cfg.computer) { cfg.computer.since = Math.max(Number(cfg.computer.since) || 0, ...news.map((n) => Number(n.ts) || 0)); saveConfig(cfg); } }
592
+ const copy = loadCopy(), st = await ask("state", { etag: (copy && copy.v) || "" }, via);
593
+ if (st.status === 200 && st.body && st.body.v) { saveCopy(st.body); const cfg = loadConfig(); if (cfg.computer && !!cfg.computer.relayOff !== (st.body.relay === false)) { cfg.computer.relayOff = st.body.relay === false; saveConfig(cfg); } }
594
+ else if (st.status === 304 && copy) saveCopy(copy);
153
595
  return { ...computerState(), shown: batches.size };
154
596
  }
155
- function startComputerPoll(opts) {
156
- if (pollTimer || !(loadConfig().computer || {}).token) return;
157
- const tick = () => { if (!(loadConfig().computer || {}).token) { clearInterval(pollTimer); pollTimer = null; return; } pollComputer(opts).catch(() => {}); };
158
- setTimeout(tick, 15000).unref();
159
- pollTimer = setInterval(tick, POLL_MS); pollTimer.unref();
597
+ // Every 2 minutes; after a failed ask sooner (15 s, 30 s, a minute), so a phone
598
+ // that changed networks or a computer that woke is back without a wait.
599
+ function schedulePoll(wait) {
600
+ if (!(loadConfig().computer || {}).id || process.env.SYMBIOT_NO_POLL === "1") return;
601
+ clearTimeout(pollTimer);
602
+ pollTimer = setTimeout(() => { pollTimer = null; pollComputer().catch(() => {}); }, wait !== undefined ? wait : fails ? BACKOFF[Math.min(fails, BACKOFF.length + 1) - 1] || POLL_MS : POLL_MS);
603
+ pollTimer.unref();
160
604
  }
605
+ function startComputerPoll({ soon = false } = {}) { if (pollTimer && !soon) return; schedulePoll(soon ? 1500 : 15000); }
161
606
 
162
- // ---- both ------------------------------------------------------------------------
163
- const phoneState = () => (PHONE ? computerState() : linkState());
607
+ // ---- both ------------------------------------------------------------------------------------
608
+ const phoneState = () => (PHONE ? computerView() : linkState());
164
609
  // While the app runs: the computer listens if it's switched on; the phone asks if it's paired.
165
610
  function startPhone() { if (PHONE) startComputerPoll(); else startPhoneLink(); }
166
611
 
167
- export { PORT, lanAddresses, linkState, newCode, unpairPhone, startPhoneLink, stopPhoneLink, setPhoneLink, computerUrl, computerState, pairComputer, forgetComputer, pollComputer, startComputerPoll, phoneState, startPhone };
612
+ export { PORT, PHONE, ANDROID_APP, PHONE_OPS, lanAddresses, linkState, newCode, unpairPhone, setRelay, startPhoneLink, stopPhoneLink, setPhoneLink, setPhoneApprove, phoneApprovals, snapshot,
613
+ computerUrl, parsePairLink, pairLink, computerState, computerView, findComputers, pairComputer, forgetComputer, pollComputer, queueChange, dismissNote, startComputerPoll, phoneState, startPhone,
614
+ newKeys, linkKey, fingerprint, seal, unseal, relayOf, answer };