symbiot 0.63.6 → 0.63.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mdns.mjs CHANGED
@@ -67,15 +67,17 @@ function decMessage(buf) {
67
67
  }
68
68
 
69
69
  // ---- the computer: answer while the link is on ------------------------------------------
70
- // opts: { id (a few hex characters, from the fingerprint), port, fp, addresses: () => [...] }.
70
+ // opts: { id (a few hex characters, from the fingerprint), port, fp, addresses: (asker) => [...] }.
71
+ // addresses gets the asking device's address (none for an announcement), so the answer
72
+ // carries only what that device can reach: RFC 6762 6.2's "the interface it came in on".
71
73
  // Gives { stop() } (a goodbye, so phones forget it at once), or null where nothing
72
74
  // can listen on 5353 (it's in use without sharing, or there's no network).
73
75
  // (socket: a dgram-like factory, for the tests)
74
76
  const udp = (o) => createSocket(o);
75
77
  function announce({ id, port, fp, addresses }, { onError = () => {}, socket = udp } = {}) {
76
78
  const inst = `Symbiot-${id}.${SERVICE}`, host = `symbiot-${id}.local`;
77
- const records = (ttl = TTL) => {
78
- const ips = addresses();
79
+ const records = (ttl = TTL, asker) => {
80
+ const ips = addresses(asker);
79
81
  return { answers: [{ name: SERVICE, type: T.PTR, ttl, data: inst }],
80
82
  additionals: [{ name: inst, type: T.SRV, ttl, flush: true, data: { port, target: host } }, { name: inst, type: T.TXT, ttl, flush: true, data: { v: 1, fp } },
81
83
  ...ips.map((ip) => ({ name: host, type: T.A, ttl, flush: true, data: ip }))] };
@@ -89,7 +91,7 @@ function announce({ id, port, fp, addresses }, { onError = () => {}, socket = ud
89
91
  let m; try { m = decMessage(buf); } catch { return; }
90
92
  if (m.response || !m.questions.some(ours)) return;
91
93
  const legacy = from.port !== PORT; // a one-shot query from any port: answered to it directly (RFC 6762 6.7)
92
- const msg = encMessage({ id: legacy ? m.id : 0, response: true, questions: legacy ? m.questions.filter(ours) : [], ...records(legacy ? 10 : TTL) });
94
+ const msg = encMessage({ id: legacy ? m.id : 0, response: true, questions: legacy ? m.questions.filter(ours) : [], ...records(legacy ? 10 : TTL, from.address) });
93
95
  if (legacy || m.questions.some((q) => q.unicast)) send(msg, from.port, from.address); else send(msg);
94
96
  });
95
97
  sock.bind(PORT, () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.63.6",
3
+ "version": "0.63.7",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,6 +46,8 @@
46
46
  "away.mjs",
47
47
  "marketing.mjs",
48
48
  "tray.mjs",
49
+ "capture.mjs",
50
+ "demo.mjs",
49
51
  "postinstall.mjs",
50
52
  "icon.svg",
51
53
  "scanworker.mjs",
@@ -77,7 +79,7 @@
77
79
  "developer-tools",
78
80
  "cli"
79
81
  ],
80
- "author": "Garth White <garthwhite507@gmail.com>",
82
+ "author": "Garth White <garth@symbiot.co.za>",
81
83
  "license": "Elastic-2.0",
82
84
  "repository": {
83
85
  "type": "git",
@@ -91,7 +93,7 @@
91
93
  "@anthropic-ai/sdk": "^0.128.0"
92
94
  },
93
95
  "scripts": {
94
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/signin.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/drafts.mjs && node test/linkedin.mjs && node test/secrets.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/licence.mjs && node test/lang.mjs && node test/browser.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/phone.mjs && node test/windows.mjs && node test/marketing.mjs && node test/tray.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
96
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/signin.mjs && node test/choose.mjs && node test/replace.mjs && node test/tabs.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/drafts.mjs && node test/linkedin.mjs && node test/secrets.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/licence.mjs && node test/lang.mjs && node test/browser.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/phone.mjs && node test/windows.mjs && node test/marketing.mjs && node test/tray.mjs && node test/capture.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
95
97
  "lint": "eslint .",
96
98
  "postinstall": "node postinstall.mjs"
97
99
  },
package/phone.mjs CHANGED
@@ -126,6 +126,26 @@ function lanAddresses(ifs = networkInterfaces()) {
126
126
  const rank = (ip) => (/^192\.168\./.test(ip) ? 0 : /^10\./.test(ip) ? 1 : /^172\.(1[6-9]|2\d|3[01])\./.test(ip) ? 2 : 3);
127
127
  return out.sort((a, b) => rank(a) - rank(b));
128
128
  }
129
+ // What the network announcement (mdns.mjs) gives a device that asks: the address on
130
+ // its own subnet, as it came in on that interface. With no asker (an announcement), or
131
+ // none on its subnet, every address but a VPN's: Tailscale, WireGuard and other tunnels
132
+ // (100.64.0.0/10 is Tailscale's), which a phone on the Wi-Fi can't reach.
133
+ const overlay = (name, ip) => /^(tailscale|ts\d|wg|tun|utun|tap|zt|nordlynx|proton|mullvad)/i.test(name) || /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip);
134
+ const ip4 = (ip) => String(ip).split(".").reduce((n, x) => ((n << 8) | (Number(x) & 255)) >>> 0, 0);
135
+ function mdnsAddresses(asker, ifs = networkInterfaces()) {
136
+ const all = [];
137
+ for (const [name, list] of Object.entries(ifs || {})) {
138
+ if (/^(docker|br-|veth|virbr|vmnet|vboxnet|lxc|lxd|podman|cni|flannel)/i.test(name)) continue;
139
+ for (const a of list || []) if ((a.family === "IPv4" || a.family === 4) && !a.internal) all.push({ name, ip: a.address, mask: a.netmask || "255.255.255.0" });
140
+ }
141
+ const from = String(asker || "").replace(/^::ffff:/i, "");
142
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(from)) {
143
+ const same = all.filter((a) => (ip4(a.ip) & ip4(a.mask)) === (ip4(from) & ip4(a.mask)));
144
+ if (same.length) return same.map((a) => a.ip);
145
+ }
146
+ const lan = all.filter((a) => !overlay(a.name, a.ip));
147
+ return (lan.length ? lan : all).map((a) => a.ip);
148
+ }
129
149
 
130
150
  let listener = null, listenErr = "", pairing = null; // pairing: { code, until, tries }
131
151
  const lastSeen = new Map(); // phone id -> { at, via }
@@ -137,7 +157,7 @@ function linkState() {
137
157
  if (pairing && Date.now() > pairing.until) pairing = null;
138
158
  const link = pairing && listener ? pairLink(pairing.code, l) : "";
139
159
  return { role: "computer", on: l.on, port: l.port, listening: !!listener, ...(listenErr ? { error: listenErr } : {}), addresses: lanAddresses(),
140
- ...(l.pub ? { fp: fingerprint(l.pub) } : {}), relay: { on: l.relay, ...relayState() }, found: !!mdns,
160
+ ...(l.pub ? { fp: fingerprint(l.pub) } : {}), relay: { on: l.relay && relayAllowed(), ...(relayAllowed() ? {} : { off: "SYMBIOT_NO_RELAY" }), ...relayState() }, found: !!mdns,
141
161
  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
162
  ...(pairing ? { code: pairing.code, until: pairing.until } : {}), ...(link ? { link, qr: qrSvg(link) } : {}) };
143
163
  }
@@ -225,12 +245,33 @@ const OPS = {
225
245
  // ---- the copy a phone keeps ---------------------------------------------------------------
226
246
  // Small and in plain words: your open tasks, what needs you, agents' questions, the
227
247
  // 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.
248
+ // approve from the phone has to match), what Watch found, what was approved from a
249
+ // phone lately, and the newest reports (reportsForPhone). v changes only when something
250
+ // in it does.
230
251
  let snapCache = null;
231
252
  const plainText = (s, n = 280) => { const t = String(s || "").replace(/\s+/g, " ").trim(); return t.length > n ? t.slice(0, n - 1) + "…" : t; };
232
253
  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
254
  const askId = (path) => "ask:" + sha(String(path)).slice(0, 12);
255
+ // The newest few of what this computer's agents wrote up (reports.mjs), so the phone's
256
+ // Reports has them too, to read an audit or a plan away from the desk, offline as well.
257
+ // Each is cut at a paragraph past REPORT_CAP: the copy goes through the relay, which
258
+ // takes 256 KB at most, and the rest is here to read.
259
+ const PHONE_REPORTS = 5, REPORT_CAP = 12000;
260
+ function cutReport(text, cap = REPORT_CAP) {
261
+ const t = String(text || ""); if (t.length <= cap) return { text: t, cut: false };
262
+ const head = t.slice(0, cap), k = head.lastIndexOf("\n\n");
263
+ return { text: (k > cap * 0.5 ? head.slice(0, k) : head).replace(/\s+$/, ""), cut: true };
264
+ }
265
+ async function reportsForPhone({ list } = {}) {
266
+ if (!list) list = (await import("./reports.mjs")).listReports();
267
+ return list.slice(0, PHONE_REPORTS).map((r) => {
268
+ let t = ""; try { t = readFileSync(r.file, "utf8"); } catch { return null; }
269
+ const c = cutReport(t);
270
+ return { id: r.id, title: plainText(r.title, 140), name: r.name, lane: r.lane || "", run: r.run || "", mtime: r.mtime, size: r.size, text: c.text, ...(c.cut ? { cut: true } : {}) };
271
+ }).filter(Boolean);
272
+ }
273
+ // On the phone: the copy, and the computer it's from, for its Reports (server.mjs). null on a computer.
274
+ function computerCopy() { const c = loadConfig().computer, copy = c ? loadCopy() : null; return copy ? { copy, from: copy.name || c.name || "your computer" } : null; }
234
275
  async function snapshot({ now = Date.now(), fresh = false } = {}) {
235
276
  if (!fresh && snapCache && now - snapCache.at < 5000) return snapCache.snap;
236
277
  const tryOr = async (f, d) => { try { return await f(); } catch { return d; } };
@@ -249,7 +290,8 @@ async function snapshot({ now = Date.now(), fresh = false } = {}) {
249
290
  const board = watch ? await tryOr(() => watch.watchBoard(24, now), { cards: [] }) : { cards: [] };
250
291
  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
292
  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 };
293
+ const reports = await tryOr(() => reportsForPhone(), []);
294
+ const body = { tasks, needs, asks, approves, watch: seen, recent, reports, relay: linkCfg().relay && relayAllowed() };
253
295
  const snap = { v: sha(JSON.stringify(body)).slice(0, 16), name: hostname(), at: now, ...body };
254
296
  snapCache = { at: now, snap };
255
297
  return snap;
@@ -326,7 +368,7 @@ let mdns = null;
326
368
  function startMdns() {
327
369
  if (mdns || process.env.SYMBIOT_NO_MDNS === "1") return;
328
370
  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: () => {} });
371
+ mdns = announce({ id: sha(Buffer.from(k.pub, "base64url")).slice(0, 4), port: l.port, fp: fingerprint(k.pub), addresses: (asker) => mdnsAddresses(asker) }, { onError: () => {} });
330
372
  }
331
373
  function stopMdns() { if (mdns) mdns.stop(); mdns = null; }
332
374
 
@@ -526,7 +568,7 @@ function computerView() {
526
568
  const st = computerState(), copy = loadCopy(), q = loadQueue();
527
569
  if (!copy) return { ...st, copy: null };
528
570
  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] = [];
571
+ for (const k of ["tasks", "needs", "asks", "approves", "watch", "recent", "notes", "reports"]) if (!Array.isArray(v[k])) v[k] = [];
530
572
  for (const o of q) {
531
573
  const a = o.args || {};
532
574
  if (o.op === "task.add") v.tasks.unshift({ id: "q:" + o.id, text: plainText(a.text), repo: a.repo || "", done: false, waiting: true });
@@ -552,14 +594,37 @@ function queueChange(op, args = {}) {
552
594
  if (process.env.SYMBIOT_NO_POLL !== "1") setTimeout(() => { pollComputer().catch(() => {}); }, 30).unref();
553
595
  return computerView();
554
596
  }
597
+ // What waits on you there, each by a key that holds while it waits: an agent's
598
+ // questions (by what they ask, so a new question tells you again), work waiting for
599
+ // your Approve (by its tasks: a file more isn't news, a task more is), and Home's
600
+ // other needs. Watch's news is told on its own (newsNotice), so it isn't here.
601
+ function waitingOn(s) {
602
+ const out = new Map();
603
+ for (const a of (s && s.asks) || []) { const qs = (a.questions || []).map((q) => q.q); out.set(a.id + ":" + sha(JSON.stringify(qs)).slice(0, 8), `${a.name} asks: ${qs[0] || "a question"}`); }
604
+ for (const r of (s && s.approves) || []) { const ids = (r.tasks || []).map((t) => t.id).sort(), n = ids.length;
605
+ out.set("approve:" + r.repo + ":" + sha(JSON.stringify(ids)).slice(0, 8), `${r.repo}: ${n ? `${n} task${n > 1 ? "s" : ""} done` : "changes without a task"}, waiting for your Approve`); }
606
+ for (const n of (s && s.needs) || []) out.set("need:" + n.id, n.sub ? `${n.title}: ${n.sub}` : n.title);
607
+ return out;
608
+ }
609
+ // What's newly waiting since the copy before, as one notification (a poll that finds
610
+ // three things buzzes once), or null. Nothing for the first copy after pairing: what
611
+ // was already waiting isn't news. What went (answered, approved) says nothing.
612
+ function waitingNotice(was, now, name = "your computer") {
613
+ if (!was || !now) return null;
614
+ const before = waitingOn(was), fresh = [...waitingOn(now)].filter(([k]) => !before.has(k)).map(([, t]) => plainText(t, 160));
615
+ if (!fresh.length) return null;
616
+ const more = fresh.length > 4 ? `\nand ${fresh.length - 4} more` : "";
617
+ return [`${fresh.length === 1 ? "1 thing needs" : `${fresh.length} things need`} you · ${name}`, fresh.slice(0, 4).join("\n") + more];
618
+ }
555
619
  function dismissNote(id) { const c = loadCopy(); if (c) saveCopy(c, { notes: (c.notes || []).filter((n) => n.id !== id) }); return computerView(); }
556
620
  // 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).
621
+ // notification per watch per read), then the copy (unchanged costs nothing), and
622
+ // tell what newly waits on you in it (waitingNotice).
558
623
  async function pollComputer(opts = {}) { if (polling) return polling; polling = pollOnce(opts).finally(() => { polling = null; schedulePoll(); }); return polling; }
559
624
  async function pollOnce({ fetchFn = fetch, notify = desktopNotify, discover } = {}) {
560
625
  const c = loadConfig().computer; if (!c) return { ...computerState(), shown: 0 };
561
626
  if (!readSecret()) return { ...computerState(), shown: 0 };
562
- const via = { fetchFn, ...(discover ? { discover } : {}) };
627
+ const via = { fetchFn, ...(discover ? { discover } : {}) }, was = loadCopy();
563
628
  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
629
  const refused = (r) => {
565
630
  pollErr = (r.body && r.body.error) || `${c.name} answered ${r.status}.`; fails = 0;
@@ -592,7 +657,9 @@ async function pollOnce({ fetchFn = fetch, notify = desktopNotify, discover } =
592
657
  const copy = loadCopy(), st = await ask("state", { etag: (copy && copy.v) || "" }, via);
593
658
  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
659
  else if (st.status === 304 && copy) saveCopy(copy);
595
- return { ...computerState(), shown: batches.size };
660
+ const waiting = waitingNotice(was, loadCopy(), c.name);
661
+ if (waiting) notify(...waiting);
662
+ return { ...computerState(), shown: batches.size + (waiting ? 1 : 0) };
596
663
  }
597
664
  // Every 2 minutes; after a failed ask sooner (15 s, 30 s, a minute), so a phone
598
665
  // that changed networks or a computer that woke is back without a wait.
@@ -609,6 +676,6 @@ const phoneState = () => (PHONE ? computerView() : linkState());
609
676
  // While the app runs: the computer listens if it's switched on; the phone asks if it's paired.
610
677
  function startPhone() { if (PHONE) startComputerPoll(); else startPhoneLink(); }
611
678
 
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,
679
+ export { computerCopy, reportsForPhone, cutReport, PHONE_REPORTS, REPORT_CAP, PORT, PHONE, ANDROID_APP, PHONE_OPS, lanAddresses, mdnsAddresses, linkState, newCode, unpairPhone, setRelay, startPhoneLink, stopPhoneLink, setPhoneLink, setPhoneApprove, phoneApprovals, snapshot,
680
+ computerUrl, parsePairLink, pairLink, computerState, computerView, waitingNotice, findComputers, pairComputer, forgetComputer, pollComputer, queueChange, dismissNote, startComputerPoll, phoneState, startPhone,
614
681
  newKeys, linkKey, fingerprint, seal, unseal, relayOf, answer };
package/reports.mjs CHANGED
@@ -68,6 +68,20 @@ function readReport(id, { list, mark = true, seenFile = SEEN_FILE } = {}) {
68
68
  const draft = isDraftReport(r.title, r.name);
69
69
  return { ...r, new: false, text, html: mdHtml(text, { id: r.id, base: dirname(r.file), roots: imageRoots(r) }) + (cut ? "<p class='muted'>(cut at 512 KB: open the file for the rest)</p>" : ""), draft, ...(draft ? { decided: decidedOn(r, seenFile) } : { ideas: reportIdeas(text) }) };
70
70
  }
71
+ // On a phone: its computer's newest reports, from the copy it sent (phone.mjs
72
+ // computerCopy, reportsForPhone), beside the phone's own. Their ids start "pc:", they
73
+ // say where they're from, and one is read from the copy, so it opens offline too.
74
+ // It can't be asked about or ticked into ideas here: that's the computer's (remote).
75
+ function phoneReports(pc) {
76
+ if (!pc || !pc.copy || !Array.isArray(pc.copy.reports)) return [];
77
+ return pc.copy.reports.map((r) => ({ id: "pc:" + r.id, title: r.title, name: r.name, lane: r.lane, run: r.run, file: `${pc.from}: ${r.lane ? r.lane + " · " : ""}${r.name}`, folder: "", size: r.size, mtime: r.mtime, new: false, running: false, computer: pc.from }));
78
+ }
79
+ function phoneReport(pc, id) {
80
+ const r = phoneReports(pc).find((x) => x.id === String(id || "")), raw = r && pc.copy.reports.find((x) => "pc:" + x.id === r.id);
81
+ if (!r || !raw) return { error: "That report isn't in the copy from your computer any more: it sends its newest few." };
82
+ const text = String(raw.text || "");
83
+ return { ...r, remote: true, text, html: mdHtml(text) + (raw.cut ? mdHtml(`_That's its first part: the rest is on ${pc.from}._`) : ""), draft: false, ideas: [] };
84
+ }
71
85
  // Mark them all read (the list's "Mark all read").
72
86
  function markAllRead({ list, seenFile = SEEN_FILE } = {}) {
73
87
  const d = loadSeen(seenFile); for (const r of list || listReports()) d.seen[r.id] = r.mtime;
@@ -215,4 +229,4 @@ function decide(r, status, file = SEEN_FILE) { const d = loadSeen(file); d.decid
215
229
  // How many reports are new, and the newest one's title (Home's droplet).
216
230
  function reportsNews(opts) { const l = listReports(opts).filter((r) => r.new); return { count: l.length, latest: l[0] ? l[0].title : "" }; }
217
231
 
218
- export { SEEN_FILE, NOT_REPORTS, titleOf, listReports, readReport, reportImage, imagePath, markAllRead, reportsNews, mdHtml, reportIdeas, isDraftReport, decidedOn, decide, IDEAS_MAX };
232
+ export { SEEN_FILE, NOT_REPORTS, titleOf, listReports, readReport, phoneReports, phoneReport, reportImage, imagePath, markAllRead, reportsNews, mdHtml, reportIdeas, isDraftReport, decidedOn, decide, IDEAS_MAX };
package/server.mjs CHANGED
@@ -25,20 +25,21 @@ import { computeDrift } from "./drift.mjs";
25
25
  import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
26
26
  import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
27
27
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
28
- import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite, openSymbiotBrowser, closeSymbiotBrowser, setBrowserHub, siteUrl, browserHub } from "./headless.mjs";
28
+ import { mapPage, wholePage, pressRegion, typeRegion, chooseRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite, openSymbiotBrowser, closeSymbiotBrowser, onBrowserClosed, setBrowserHub, siteUrl, browserHub } from "./headless.mjs";
29
29
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart, installLauncher, iconSvg, setLauncherLook } from "./desktop.mjs";
30
30
  import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
31
- import { linksState, linkSite, checkLink, unlinkSite, addSite, signInAsked } from "./links.mjs";
31
+ import { linksState, linkSite, checkLink, unlinkSite, addSite, signInAsked, sitesCheck } from "./links.mjs";
32
32
  import { testWeeks, testInstalls, postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
33
33
  import { mindState, forget } from "./mind.mjs";
34
- import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
34
+ import { lanesTick, lanesState, partlyDone, handingOf, loadLedger, orcaRelink } from "./lanes.mjs";
35
35
  import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
36
36
  import { adaptState, noteUse } from "./adapt.mjs";
37
37
  import { reportIdeasAdd, reportAsk, reportDraftAnswer, homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, workTick, firstSteps, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, onboarding, setOnboarding, startOnboarding, phoneSetupFirst } from "./home.mjs";
38
38
  import { MARKETING_DIR, MARKETING, draftPreview, laneMedia, displayName, setDraftMedia } from "./marketing.mjs";
39
- import { trays, trayMedia, setBlur, renderCapture } from "./tray.mjs";
40
- import { listReports, readReport, reportImage, markAllRead } from "./reports.mjs";
41
- import { phoneState, setPhoneLink, newCode, unpairPhone, setRelay, findComputers, pairComputer, forgetComputer, pollComputer, queueChange, dismissNote, setPhoneApprove, startPhone } from "./phone.mjs";
39
+ import { trays, trayMedia, setBlur, addBlur, removeBlur, setReviewed, renderCapture } from "./tray.mjs";
40
+ import { captureProduct, captureProducts } from "./capture.mjs";
41
+ import { listReports, readReport, reportImage, markAllRead, phoneReports, phoneReport } from "./reports.mjs";
42
+ import { computerCopy, phoneState, setPhoneLink, newCode, unpairPhone, setRelay, findComputers, pairComputer, forgetComputer, pollComputer, queueChange, dismissNote, setPhoneApprove, startPhone } from "./phone.mjs";
42
43
  import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
43
44
  import { runChecks, checksState, markClashesSeen } from "./checks.mjs";
44
45
 
@@ -50,6 +51,24 @@ function updateCmd(latest, current, platform = process.platform) {
50
51
  const target = latest && semverGt(latest, current) ? latest : "latest";
51
52
  return { target, cmd: (platform === "win32" ? "npm i -g " : "npm install -g ") + "symbiot@" + target + " --prefer-online" };
52
53
  }
54
+ // npm lists a new version minutes before its download is there: 1 to 5 minutes for
55
+ // most releases, 11 for 0.59.0 (~/.symbiot/agent.log, 0.52.0 to 0.63.3). Five tries 30s
56
+ // apart gave up on 0.59.0, 0.62.0 and 0.63.3 and sent you to a terminal. So the update
57
+ // looks for the download itself before it runs npm, and keeps looking (and trying
58
+ // again on a 404) for up to 20 minutes. Only a 404 waits: anything else (offline, say)
59
+ // goes on to npm, whose own error says what's wrong.
60
+ const UPDATE_EVERY = Number(process.env.SYMBIOT_UPDATE_EVERY) || 30000, UPDATE_WAIT = 20 * 60 * 1000;
61
+ async function downloadReady(version, registry = REGISTRY, fetchFn = fetch) {
62
+ if (!/^\d+\.\d+\.\d+/.test(String(version))) return true; // "latest": npm picks it, nothing to look at
63
+ try { return (await fetchFn(`${registry}/symbiot/-/symbiot-${encodeURIComponent(version)}.tgz`, { method: "HEAD", signal: AbortSignal.timeout(10000) })).status !== 404; } catch { return true; }
64
+ }
65
+ // This update's own lines in agent.log (an earlier run's 404 isn't this one's), and
66
+ // the line that says what went wrong.
67
+ function updateOutcome(log) {
68
+ const mine = String(log || "").slice(String(log || "").lastIndexOf("=== symbiot update"));
69
+ const why = (mine.match(/npm (?:error|ERR!) (?!code\b|syscall\b|errno\b|path\b|dest\b|404\s*$|A complete log)([^\n]+)/m) || [])[1] || "";
70
+ return { notYet: /E404|ETARGET|notarget|No matching version|is not in this registry/i.test(mine), why: why.trim().slice(0, 200) };
71
+ }
53
72
 
54
73
  // ---- what's new: the changelog's releases between two versions -------------------
55
74
  // CHANGELOG.md's "## 0.45.0 — 2026-10-06" sections newer than `from` and up to
@@ -247,8 +266,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
247
266
  setScanOptions({ cache: true }); REPO_STATE.onChange = () => { try { homeState({ fresh: true }); } catch {} };
248
267
  if (!SANDBOX) { refreshRevoked().catch(() => {}); setInterval(() => refreshRevoked().catch(() => {}), 6 * 3600 * 1000).unref(); } // cancelled Pro keys, about daily
249
268
  { const t0 = Date.now(); refreshRepos().then((l) => console.log(`Found ${l.length} project${l.length === 1 ? "" : "s"} in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)); }
250
- let UPDATING = null; // an update in flight: { target, attempt, retrying? }
251
- const UPDATE_TRIES = 20;
269
+ let UPDATING = null; // an update in flight: { target, attempt, since, retrying? }
270
+ let UPDATE_FAILED = null; // the last one that didn't go through: { target, error, at }
271
+ let HUB_SEEN = 0; // when the Symbiot Browser's own page last asked for the sites (browser.html)
272
+ onBrowserClosed(() => { sitesCheck(); }); // its last window closed by its X: checked, as Done does
252
273
  let NEWEST_WIN = ""; // the Symbiot window opened last: older ones close themselves (one window, not a pile)
253
274
  process.on("exit", () => closeAway()); // Away's windows go with the app (a quit, an update's restart)
254
275
  const server = createServer(async (req, res) => {
@@ -333,7 +354,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
333
354
  const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
334
355
  return json(res, { started: true, model, id: e ? e.id : "" });
335
356
  }
336
- if (u.pathname === "/api/agents") return json(res, await withReleases(agentsList()));
357
+ if (u.pathname === "/api/agents") { // handing: what it handed to other lanes, on the newest block per folder: where each stands, and how far the run on it has got
358
+ const seen = new Set(), map = laneMap(), ledger = loadLedger(), list = agentsList().map((a) => { if (!a.path || seen.has(a.path)) return a; seen.add(a.path); let h = []; try { h = handingOf(a.path, { map, ledger }); } catch {} return h.length ? { ...a, handing: h } : a; });
359
+ return json(res, await withReleases(list));
360
+ }
337
361
  // the answer, saved in the folder that asked; it says which lane that is, so "Sent to …"
338
362
  // names where it went (a Marketing answer said "Sent to Paperclip Steve")
339
363
  if (u.pathname === "/api/agents/answer" && req.method === "POST") { const b = await readBody(req), path = String(b.path || ""), r = answerQuestions(path, b.answers, { rerun: !!b.rerun }); return json(res, r && r.ok ? { ...r, lane: laneOfFolder(path) } : r); }
@@ -366,15 +390,19 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
366
390
  // A web page in the hidden browser (headless.mjs): mapped by itself, no
367
391
  // screen needed. Press acts on the real site, signed in as you, so it's
368
392
  // confirmed like a click; Sign in opens a window the user signs in with.
369
- if (u.pathname === "/api/screens/map" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await mapPage(b.site, b.name, { whole: b.whole === true }))); }
393
+ if (u.pathname === "/api/screens/map" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await mapPage(b.site, b.name, { whole: b.whole === true, who: String(b.who || "") }))); }
370
394
  // press / type: refused unless confirmed, or the page's site is one you trust
371
- if (u.pathname === "/api/screens/press" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await pressRegion(String(b.id || ""), String(b.region || ""), { confirmed: b.confirmed === true, noSend: b.noSend === true }))); }
395
+ if (u.pathname === "/api/screens/press" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await pressRegion(String(b.id || ""), String(b.region || ""), { confirmed: b.confirmed === true, noSend: b.noSend === true, who: String(b.who || "") }))); }
372
396
  // scroll and whole page: they only look, so they never ask
373
- if (u.pathname === "/api/screens/whole" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await wholePage(String(b.id || "")))); }
374
- if (u.pathname === "/api/screens/scroll" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await scrollPage(String(b.id || ""), String(b.to || "down")))); }
375
- if (u.pathname === "/api/screens/type" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true, noSend: b.noSend === true }))); }
397
+ if (u.pathname === "/api/screens/whole" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await wholePage(String(b.id || ""), { who: String(b.who || "") }))); }
398
+ if (u.pathname === "/api/screens/scroll" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await scrollPage(String(b.id || ""), String(b.to || "down"), { who: String(b.who || "") }))); }
399
+ if (u.pathname === "/api/screens/type" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true, noSend: b.noSend === true, who: String(b.who || "") }))); }
400
+ // its own route, so an app from before it answers 404 instead of retyping the whole field
401
+ if (u.pathname === "/api/screens/replace" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true, noSend: b.noSend === true, replace: String(b.replace == null ? "" : b.replace), who: String(b.who || "") }))); }
402
+ // choose: an option in a menu (a <select>, or the site's own), asked like press and type
403
+ if (u.pathname === "/api/screens/choose" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await chooseRegion(String(b.id || ""), String(b.region || ""), b.option, { confirmed: b.confirmed === true, who: String(b.who || "") }))); }
376
404
  // upload: a file of yours to the page's file box, asked like press and type
377
- if (u.pathname === "/api/screens/upload" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await uploadRegion(String(b.id || ""), String(b.region || ""), Array.isArray(b.files) ? b.files.map(String) : String(b.files || ""), { confirmed: b.confirmed === true }))); }
405
+ if (u.pathname === "/api/screens/upload" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await uploadRegion(String(b.id || ""), String(b.region || ""), Array.isArray(b.files) ? b.files.map(String) : String(b.files || ""), { confirmed: b.confirmed === true, who: String(b.who || "") }))); }
378
406
  if (u.pathname === "/api/screens/trusted") return json(res, { sites: trustedSites() });
379
407
  if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
380
408
  if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
@@ -383,10 +411,13 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
383
411
  if (u.pathname === "/api/mind") return json(res, mindState());
384
412
  if (u.pathname === "/api/mind/forget" && req.method === "POST") { const b = await readBody(req); return json(res, forget(String(b.id || ""))); }
385
413
  // Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
386
- if (u.pathname === "/api/links") { let reach = null; try { reach = linkReach(); } catch {} return json(res, { ...linksState(), ...(reach ? { reach } : {}) }); } // reach: which sites agent runs can use too (agents.mjs)
414
+ if (u.pathname === "/api/links") { if (u.searchParams.get("hub") === "1") HUB_SEEN = Date.now(); let reach = null; try { reach = linkReach(); } catch {} return json(res, { ...linksState(), ...(reach ? { reach } : {}) }); } // reach: which sites agent runs can use too (agents.mjs)
387
415
  if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""), b.here ? { open: async (url) => ({ ok: true, url }) } : undefined)); } // here: from the Symbiot Browser, which opens the site itself
388
416
  if (u.pathname === "/api/browser/open" && req.method === "POST") { const b = await readBody(req); if (process.env.SYMBIOT_NO_OPEN === "1") return json(res, { ok: false, note: "not opened (SYMBIOT_NO_OPEN)", page: browserHub() }); return json(res, await openSymbiotBrowser(b.url ? siteUrl(String(b.url)) : "")); }
389
- if (u.pathname === "/api/browser/done" && req.method === "POST") { if (process.env.SYMBIOT_NO_OPEN === "1") return json(res, { closed: 0 }); const r = await closeSymbiotBrowser(); setTimeout(() => { for (const it of linksState().items) if (it.state !== "off" && it.state !== "ok") checkLink(it.id).catch(() => {}); }, 1500); return json(res, { ...r, checking: true }); }
417
+ // Done: it closes, and every site is checked (links.mjs sitesCheck). Its page's X sends
418
+ // closed: the same, unless the page is back within a moment (a reload, not a close).
419
+ if (u.pathname === "/api/browser/done" && req.method === "POST") { if (process.env.SYMBIOT_NO_OPEN === "1") return json(res, { closed: 0 }); const r = await closeSymbiotBrowser(); sitesCheck(); return json(res, { ...r, checking: true }); }
420
+ if (u.pathname === "/api/browser/closed" && req.method === "POST") { const at = Date.now(); if (process.env.SYMBIOT_NO_OPEN !== "1") setTimeout(() => { if (HUB_SEEN > at) return; closeSymbiotBrowser().catch(() => {}).then(() => sitesCheck()); }, 3000).unref(); return json(res, { ok: true }); }
390
421
  // any site, added in Settings → Connections or the Symbiot Browser's address bar, or a card's / reply's "Sign in to <site>": linked and opened to sign in
391
422
  if (u.pathname === "/api/links/site" && req.method === "POST") { const b = await readBody(req), here = b.here ? { open: async (url) => ({ ok: true, url }) } : undefined; return json(res, b.id ? await linkSite(String(b.id), here) : await addSite(String(b.site || ""), here)); }
392
423
  if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
@@ -416,10 +447,15 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
416
447
  if (u.pathname === "/api/marketing/open" && req.method === "POST") { const b = await readBody(req), f = join(MARKETING_DIR, String(b.rel || "")); return json(res, f.startsWith(MARKETING_DIR + "/") && existsSync(f) ? { ok: openUrl(f) } : { error: "That draft isn't there any more." }); }
417
448
  // the pick tray (tray.mjs): its captures and their blur boxes, a capture (only from inside
418
449
  // a tray), a box switched on or off and the capture blurred again, a capture as a post's media
419
- if (u.pathname === "/api/marketing/tray") return json(res, { trays: trays() });
450
+ if (u.pathname === "/api/marketing/tray") { let products = []; try { products = captureProducts(); } catch {} return json(res, { trays: trays(), products }); }
420
451
  if (u.pathname === "/api/marketing/tray/media") { const m = trayMedia(String(u.searchParams.get("rel") || "")); if (!m) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": m.type, "cache-control": "private, max-age=86400" }); res.end(readFileSync(m.file)); return; }
421
452
  if (u.pathname === "/api/marketing/tray/blur" && req.method === "POST") { const b = await readBody(req), r = setBlur(String(b.tray || ""), String(b.name || ""), b.id, !!b.on); if (r.error) return json(res, r); const d = await renderCapture(String(b.tray), String(b.name)); return json(res, { ...r, rendered: !!d.ok, ...(d.error ? { note: d.error } : {}) }); }
422
- if (u.pathname === "/api/marketing/tray/use" && req.method === "POST") { const b = await readBody(req); return json(res, setDraftMedia(String(b.rel || ""), [].concat(b.file || []).map(String))); }
453
+ if (u.pathname === "/api/marketing/tray/use" && req.method === "POST") { const b = await readBody(req); return json(res, setDraftMedia(String(b.rel || ""), [].concat(b.file || []).map(String))); } // one to look over first is refused there (marketing.mjs lookOver)
454
+ // a box of your own (drawn on it), one gone, and a capture looked over (tray.mjs); then it's blurred again
455
+ if (u.pathname === "/api/marketing/tray/box" && req.method === "POST") { const b = await readBody(req), r = b.remove != null ? removeBlur(String(b.tray || ""), String(b.name || ""), b.remove) : addBlur(String(b.tray || ""), String(b.name || ""), b.box); if (r.error) return json(res, r); const d = await renderCapture(String(b.tray), String(b.name)); return json(res, { ...r, rendered: !!d.ok, ...(d.error ? { note: d.error } : {}) }); }
456
+ if (u.pathname === "/api/marketing/tray/reviewed" && req.method === "POST") { const b = await readBody(req); return json(res, setReviewed(String(b.tray || ""), String(b.name || ""))); }
457
+ // Symbiot's own capture of a product, for its pick tray (capture.mjs): `symbiot capture`, the lane's first run, or the page's Capture
458
+ if (u.pathname === "/api/marketing/capture" && req.method === "POST") { const b = await readBody(req); return json(res, await captureProduct({ product: String(b.product || ""), url: String(b.url || ""), window: String(b.window || ""), screens: [].concat(b.screens || []).map(String), clips: b.clips !== false, ...(Number(b.seconds) > 0 ? { seconds: Math.min(20, Number(b.seconds)) } : {}) })); }
423
459
  if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
424
460
  if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""))); }
425
461
  if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
@@ -433,8 +469,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
433
469
  // reads your LinkedIn only on your click (confirmed), never by itself
434
470
  if (u.pathname === "/api/posts/voice" && req.method === "POST") { const b = await readBody(req); if (b.confirmed !== true) return json(res, { error: "Reading your LinkedIn posts needs your click." }); return json(res, await voiceFromLinkedIn()); }
435
471
  // Reports (reports.mjs): what runs wrote up in their .symbiot/; read by id, never by path.
436
- if (u.pathname === "/api/reports") return json(res, { reports: listReports() });
437
- if (u.pathname === "/api/reports/read") return json(res, readReport(u.searchParams.get("id") || ""));
472
+ // on a phone, its computer's newest reports too (from the copy it keeps)
473
+ if (u.pathname === "/api/reports") return json(res, { reports: [...listReports(), ...phoneReports(computerCopy())].sort((a, b) => b.mtime - a.mtime) });
474
+ if (u.pathname === "/api/reports/read") { const id = u.searchParams.get("id") || ""; return json(res, id.startsWith("pc:") ? phoneReport(computerCopy(), id) : readReport(id)); }
438
475
  // A report's image (an <img> src, so its token is in ?t=): by the report's id and a src it shows, from its allowed folders only.
439
476
  if (u.pathname === "/api/reports/image") { const f = reportImage(u.searchParams.get("id") || "", u.searchParams.get("src") || ""); if (f.error) { res.writeHead(404, { "content-type": "text/plain" }); res.end(f.error); return; } res.writeHead(200, { "content-type": f.type, "cache-control": "private, no-cache", "x-content-type-options": "nosniff" }); res.end(readFileSync(f.file)); return; }
440
477
  if (u.pathname === "/api/reports/seen" && req.method === "POST") return json(res, markAllRead());
@@ -516,7 +553,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
516
553
  // a sandbox (symbiot app --fresh) never offers an update: it would replace your real install.
517
554
  // A headless browser (an agent's screenshot of Home) never becomes the newest window:
518
555
  // that closed the user's real one, again and again (2026-10-08, "symbiot keeps crashing").
519
- if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1" && !headlessAgent(req.headers["user-agent"])) NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && !DESKTOP && semverGt(LATEST_VERSION, VERSION), ...(DESKTOP ? { desktop: true } : {}), ...(UPDATING && UPDATING.retrying ? { retrying: { ...UPDATING, of: UPDATE_TRIES } } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
556
+ if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1" && !headlessAgent(req.headers["user-agent"])) NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && !DESKTOP && semverGt(LATEST_VERSION, VERSION), ...(DESKTOP ? { desktop: true } : {}), ...(UPDATING ? { updating: { ...UPDATING, every: UPDATE_EVERY, wait: UPDATE_WAIT } } : {}), ...(UPDATE_FAILED ? { updateFailed: UPDATE_FAILED } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
520
557
  // What's new: after an update, since the version you last saw (until you click Got it);
521
558
  // ?latest=1, what the update on offer brings, from its package on npm
522
559
  if (u.pathname === "/api/whatsnew") {
@@ -550,26 +587,36 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
550
587
  // relaunch, so it takes over instead of finding us and bowing out.
551
588
  // SYMBIOT_UPDATE_CMD replaces the install (the tests use a no-op).
552
589
  if (SANDBOX) return json(res, { error: "This is a sandbox (symbiot app --fresh): update your real Symbiot instead." });
590
+ // One at a time: a second click (another window, or one while it waits) started a
591
+ // second npm install over the first, and they collided (ENOTEMPTY, 0.61.1).
592
+ // (an install still running after 10 minutes has hung: a click starts it afresh)
593
+ if (UPDATING && (UPDATING.retrying || Date.now() - UPDATING.at < 10 * 60 * 1000)) return json(res, { started: true, already: true, target: UPDATING.target });
553
594
  const { target, cmd } = updateCmd(LATEST_VERSION, VERSION);
554
- const inst = process.env.SYMBIOT_UPDATE_CMD || cmd;
555
- // npm lists a new version a little before its download is there (a 404 for the
556
- // .tgz): then it tries again every 30s for 10 minutes (UPDATE_TRIES), and says so,
557
- // instead of giving up and sending you to a terminal. 5 tries (2.5 minutes) gave up
558
- // on 0.63.3, still a 404 four minutes after it was published (2026-10-12).
559
- const attempt = (n) => track("symbiot update", inst, homedir(), (code) => {
560
- if (code !== 0) {
561
- const tail = readRunLog(join(homedir(), ".symbiot", "agent.log")).slice(-3000);
562
- if (n < UPDATE_TRIES && /E404|ETARGET|notarget|No matching version|is not in this registry/i.test(tail)) { UPDATING = { target, retrying: true, attempt: n + 1, at: Date.now() }; setTimeout(() => attempt(n + 1), 30000).unref(); }
563
- else UPDATING = null;
564
- return;
565
- }
566
- server.close(); if (server.closeAllConnections) server.closeAllConnections();
567
- try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: { ...process.env, SYMBIOT_RELAUNCH: "1" }, cwd: homedir() }); ch.unref(); } catch {}
568
- setTimeout(() => process.exit(0), 1200);
569
- });
570
- UPDATING = { target, attempt: 1, at: Date.now() };
571
- const e = attempt(1);
572
- return json(res, { started: true, id: e ? e.id : "", target });
595
+ const inst = process.env.SYMBIOT_UPDATE_CMD || cmd, since = Date.now();
596
+ // not downloadable yet (see downloadReady): look again in 30s, for up to 20 minutes, and say so
597
+ const again = (n, error) => {
598
+ if (Date.now() - since + UPDATE_EVERY > UPDATE_WAIT) { UPDATING = null; UPDATE_FAILED = { target, error, at: Date.now() }; return null; }
599
+ UPDATING = { target, retrying: true, attempt: n + 1, since, at: Date.now() }; setTimeout(() => attempt(n + 1), UPDATE_EVERY).unref(); return null;
600
+ };
601
+ const attempt = async (n) => {
602
+ if (!(await downloadReady(target))) return again(n, `npm lists ${target}, but its download still wasn't there after 20 minutes.`);
603
+ const e = track("symbiot update", inst, homedir(), (code) => {
604
+ if (code !== 0) {
605
+ const o = updateOutcome(readRunLog(join(homedir(), ".symbiot", "agent.log")).slice(-6000));
606
+ if (o.notYet) again(n, o.why || `npm couldn't find ${target}'s download.`);
607
+ else { UPDATING = null; UPDATE_FAILED = { target, error: o.why || `npm stopped (exit code ${code}).`, at: Date.now() }; }
608
+ return;
609
+ }
610
+ server.close(); if (server.closeAllConnections) server.closeAllConnections();
611
+ try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: { ...process.env, SYMBIOT_RELAUNCH: "1" }, cwd: homedir() }); ch.unref(); } catch {}
612
+ setTimeout(() => process.exit(0), 1200);
613
+ });
614
+ if (!e) { UPDATING = null; UPDATE_FAILED = { target, error: "Symbiot couldn't start npm.", at: Date.now() }; }
615
+ return e;
616
+ };
617
+ UPDATING = { target, attempt: 1, since, at: Date.now() }; UPDATE_FAILED = null;
618
+ const e = await attempt(1);
619
+ return json(res, { started: true, id: e ? e.id : "", target, ...(UPDATING && UPDATING.retrying ? { waiting: true } : {}) });
573
620
  }
574
621
  if (u.pathname === "/api/quit") { stopVoices(); res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
575
622
  } catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String((e && e.message) || e) })); return; }
@@ -629,4 +676,4 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
629
676
  setInterval(() => { try { awaitTick(); } catch {} }, 60000).unref(); // a reply an agent's email waits on: found, and handed on
630
677
  }
631
678
 
632
- export { headlessAgent, updateCmd, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly, openApp };
679
+ export { headlessAgent, updateCmd, downloadReady, updateOutcome, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly, openApp };
package/tray.mjs CHANGED
@@ -13,7 +13,7 @@ import { join, dirname, basename } from "node:path";
13
13
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, realpathSync, renameSync, rmSync, mkdtempSync } from "node:fs";
14
14
  import { tmpdir } from "node:os";
15
15
  import { spawn, spawnSync } from "node:child_process";
16
- import { MARKETING_DIR, MEDIA_TYPES, draftFiles, parseDraft, inLane } from "./marketing.mjs";
16
+ import { MARKETING_DIR, MEDIA_TYPES, draftFiles, parseDraft, inLane, reviewOf } from "./marketing.mjs";
17
17
 
18
18
  const NAME = /^\w[\w.-]{0,100}$/; // a capture's file: no slashes, no dot first
19
19
  const IMG = /\.(?:png|jpe?g|gif|webp)$/i, VID = /\.(?:mp4|webm|mov)$/i;
@@ -44,12 +44,15 @@ function trayOf(dir, rel) {
44
44
  const use = (it.kind === "still" ? files.find((f) => IMG.test(f)) : files.find((f) => VID.test(f))) || show;
45
45
  const spec = NAME.test(`${it.name}.blur.json`) ? readJson(join(root, `${it.name}.blur.json`)) : null;
46
46
  const [w, h] = spec && Array.isArray(spec.size) ? spec.size.map(Number) : [0, 0];
47
- const regions = spec && Array.isArray(spec.regions) && w > 0 && h > 0 ? spec.regions.filter((r) => r && r.id != null).map((r) => ({ id: r.id, kind: String(r.kind || "box"), hint: String(r.hint || ""), on: !!r.on,
47
+ const regions = spec && Array.isArray(spec.regions) && w > 0 && h > 0 ? spec.regions.filter((r) => r && r.id != null).map((r) => ({ id: r.id, kind: String(r.kind || "box"), hint: String(r.hint || ""), on: !!r.on, ...(r.hand ? { hand: true } : {}),
48
48
  x: r.x / w, y: r.y / h, w: r.w / w, h: r.h / h, ...(r.from != null || r.to != null ? { from: Number(r.from) || 0, to: Number(r.to) || 0 } : {}) })) : [];
49
49
  let at = 0; try { at = show ? statSync(join(root, show)).mtimeMs : 0; } catch {}
50
50
  return { name: it.name, kind: String(it.kind || (spec && spec.kind) || "still"), caption: String(it.caption || (spec && spec.caption) || ""), screen: String(it.screen || ""), for: (Array.isArray(it.for) ? it.for : []).map(String),
51
- show: show ? `${rel}/${show}` : "", showKind: VID.test(show) ? "video" : "image", use: use ? `${rel}/${use}` : "", blur: !!spec, regions, parts: Array.isArray(it.parts) ? it.parts.map(String) : [], at };
51
+ show: show ? `${rel}/${show}` : "", showKind: VID.test(show) ? "video" : "image", use: use ? `${rel}/${use}` : "", blur: !!spec, regions, parts: Array.isArray(it.parts) ? it.parts.map(String) : [], at,
52
+ ...(spec && spec.desktop ? { desktop: true } : {}), ...(spec && Array.isArray(spec.size) ? { size: spec.size.map(Number) } : {}) };
52
53
  });
54
+ // to look over before it's used: its own reason, or a reel's from the clips in it
55
+ for (const it of items) { const why = lookFirst(root, it); if (why) it.review = why; }
53
56
  return { rel, folder: dirname(rel), items };
54
57
  }
55
58
  // Every tray in the lane, each with its product's drafts (not those in the tray) to use a
@@ -75,6 +78,43 @@ function setBlur(tray, name, id, on, { dir = MARKETING_DIR } = {}) {
75
78
  try { writeFileSync(f, JSON.stringify(spec, null, 2)); } catch (e) { return { error: "Couldn't save it: " + ((e && e.message) || e) }; }
76
79
  return { ok: true, on: !!on, ids };
77
80
  }
81
+ // ---- yours: a box drawn, and a capture looked over --------------------------------------
82
+ // A box of your own over a capture (x, y, w, h as fractions of it, as the page draws it),
83
+ // blurred: on a clip, all the way through. { ok, id } or { error }.
84
+ function specOf(dir, tray, name) {
85
+ if (!isTray(dir, tray) || !NAME.test(String(name || ""))) return { error: "That capture isn't in a tray any more." };
86
+ const f = join(dir, tray, `${name}.blur.json`), spec = readJson(f);
87
+ if (!spec || !Array.isArray(spec.regions)) return { error: "That capture has no blur boxes to add to." };
88
+ return { f, spec };
89
+ }
90
+ const saveSpec = (f, spec) => { try { writeFileSync(f, JSON.stringify(spec, null, 2)); return null; } catch (e) { return { error: "Couldn't save it: " + ((e && e.message) || e) }; } };
91
+ function addBlur(tray, name, box, { dir = MARKETING_DIR } = {}) {
92
+ const o = specOf(dir, tray, name); if (o.error) return o;
93
+ const [W, H] = Array.isArray(o.spec.size) ? o.spec.size.map(Number) : [0, 0];
94
+ if (!(W > 0 && H > 0)) return { error: "Couldn't tell how big the capture is." };
95
+ const n = (v) => Math.min(1, Math.max(0, Number(v)));
96
+ const b = box || {}, x = n(b.x), y = n(b.y), w = Math.min(1 - x, n(b.w)), h = Math.min(1 - y, n(b.h));
97
+ if (![b.x, b.y, b.w, b.h].every((v) => Number.isFinite(Number(v))) || w * W < 4 || h * H < 4) return { error: "Draw the box over what to blur: drag across it." };
98
+ const id = Math.max(0, ...o.spec.regions.map((r) => Number(r && r.id) || 0)) + 1;
99
+ o.spec.regions.push({ id, kind: "yours", x: Math.round(x * W), y: Math.round(y * H), w: Math.round(w * W), h: Math.round(h * H), hint: "", on: true, hand: true });
100
+ return saveSpec(o.f, o.spec) || { ok: true, id };
101
+ }
102
+ // A box you drew, gone. One Symbiot found stays (switch it off instead), so it can go back on.
103
+ function removeBlur(tray, name, id, { dir = MARKETING_DIR } = {}) {
104
+ const o = specOf(dir, tray, name); if (o.error) return o;
105
+ const r = o.spec.regions.find((x) => x && String(x.id) === String(id));
106
+ if (!r) return { error: "That blur box isn't there any more." };
107
+ if (!r.hand) return { error: "Symbiot found that one: switch it off instead, so it can go back on." };
108
+ o.spec.regions = o.spec.regions.filter((x) => x !== r);
109
+ return saveSpec(o.f, o.spec) || { ok: true };
110
+ }
111
+ // A capture to look over before it's used (marketing.mjs reviewOf): its own reason, or a reel's from its clips.
112
+ const lookFirst = (root, it) => reviewOf(root, it.name, it.kind, it.parts);
113
+ function setReviewed(tray, name, { dir = MARKETING_DIR, now = Date.now() } = {}) {
114
+ const o = specOf(dir, tray, name); if (o.error) return o;
115
+ o.spec.reviewed = now;
116
+ return saveSpec(o.f, o.spec) || { ok: true };
117
+ }
78
118
  // The capture rendered again from its original with its boxes as they are now, and every
79
119
  // reel it's in joined again (a box blurred in a clip is blurred in its reel too). One at a
80
120
  // time a capture: two clicks don't write the same files at once. { ok, files } or { error }.
@@ -151,4 +191,4 @@ function ffmpeg(args, { timeout = 180000 } = {}) {
151
191
  });
152
192
  }
153
193
 
154
- export { trayDirs, trayMedia, trayOf, trays, setBlur, renderCapture };
194
+ export { trayDirs, trayMedia, trayOf, trays, setBlur, addBlur, removeBlur, setReviewed, renderCapture };