symbiot 0.40.5 → 0.41.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/server.mjs ADDED
@@ -0,0 +1,270 @@
1
+ // `symbiot app`: the local server behind the app window. It serves the page
2
+ // (ui.mjs) and its /api routes, each a thin call into the module that does the
3
+ // work, on 127.0.0.1 only and behind the per-install token. (Watch on your phone
4
+ // listens on your network separately, only for that: phone.mjs.)
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { readFileSync, existsSync } from "node:fs";
9
+ import { createServer } from "node:http";
10
+ import { randomBytes } from "node:crypto";
11
+ import { EMBEDDED_UI } from "./ui.mjs";
12
+ import { VERSION, LATEST_VERSION, semverGt, checkLatest, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
13
+ import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, answerQuestions, agentsList } from "./agents.mjs";
14
+ import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
15
+ import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
16
+ import { computeDrift } from "./drift.mjs";
17
+ import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
18
+ import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce } from "./writeups.mjs";
19
+ import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
20
+ import { mapPage, pressRegion, typeRegion, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
21
+ import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
22
+ import { watchState, addWatch, setEvery, removeWatch, clearNews, checkWatch, startWatches, setBrief } from "./watch.mjs";
23
+ import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
24
+
25
+ // The in-app update installs the EXACT newest version (not the `latest` tag, which
26
+ // npm's cache/propagation can resolve stale — that caused an update loop where the
27
+ // install kept re-fetching the same old version). --prefer-online skips a stale
28
+ // cached packument.
29
+ function updateCmd(latest, current, platform = process.platform) {
30
+ const target = latest && semverGt(latest, current) ? latest : "latest";
31
+ return { target, cmd: (platform === "win32" ? "npm i -g " : "npm install -g ") + "symbiot@" + target + " --prefer-online" };
32
+ }
33
+
34
+ // ---- `symbiot app` : the same UI in a chrome-less browser window ----------
35
+ // Self-contained HTML served at / — no backticks or ${} inside (it lives in a
36
+ // template literal). Talks to the local API with the per-launch token.
37
+
38
+ function readBody(req) {
39
+ return new Promise((resolve) => {
40
+ let d = ""; req.on("data", (ch) => (d += ch));
41
+ req.on("end", () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({}); } });
42
+ });
43
+ }
44
+ // http://127.0.0.1:<port>/?t=<token> -> symbiot://127.0.0.1:<port>/?t=<token>, for the Android app
45
+ function appLink(url) { return url.replace(/^http:\/\//, "symbiot://"); }
46
+ // Symbiot running in Termux (not the Android app's own)
47
+ const IN_TERMUX = process.platform === "android" && process.env.SYMBIOT_ANDROID_APP !== "1";
48
+ function openApp(url) {
49
+ try {
50
+ // Android (Termux): Symbiot's Android app shows this Symbiot full screen (it
51
+ // can't read Termux's home, this one can), so the link goes to it first, as a
52
+ // symbiot:// link: only that app takes those, where Android gives an http one
53
+ // to the browser. Without the app, `am` can't resolve it, and the URL goes to
54
+ // the phone's browser: termux-open-url ships with Termux, `am start` is the fallback.
55
+ if (process.platform === "android") {
56
+ if (hasCmd("am")) {
57
+ const r = spawnSync("am", ["start", "-a", "android.intent.action.VIEW", "-d", appLink(url)], { encoding: "utf8", timeout: 8000 });
58
+ if (r.status === 0 && !/error|unable|exception/i.test(`${r.stdout || ""}${r.stderr || ""}`)) return "Symbiot app window";
59
+ }
60
+ if (hasCmd("termux-open-url")) spawn("termux-open-url", [url], { detached: true, stdio: "ignore" }).unref();
61
+ else spawn("am", ["start", "-a", "android.intent.action.VIEW", "-d", url], { detached: true, stdio: "ignore" }).unref();
62
+ return "browser tab";
63
+ }
64
+ const chrome = chromeBinary();
65
+ if (chrome) { spawn(chrome, [`--app=${url}`, "--new-window", "--no-first-run", "--no-default-browser-check"], { detached: true, stdio: "ignore" }).unref(); return "app window"; }
66
+ // fall back to the OS default browser (a normal tab) — still fully functional
67
+ if (process.platform === "win32") { spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref(); return "browser tab"; }
68
+ spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
69
+ return "browser tab";
70
+ } catch { return null; }
71
+ }
72
+ // How long the app keeps Screens' hidden browser open after an action.
73
+ const BROWSER_KEEP = 5 * 60 * 1000;
74
+ const PLAIN_COLOURS = { g: (s) => s, d: (s) => s, b: (s) => s, y: (s) => s };
75
+ // Start the app (or open the one already running). bin: the CLI's own file,
76
+ // for start at login; since / all: what Week covers (--since, --all); c: the
77
+ // CLI's colours for what it prints.
78
+ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {}) {
79
+ const writeup = (cmd) => produce(cmd, { since, all });
80
+ const SERVER_STARTED = Date.now();
81
+ // Stable token + port so the URL survives a restart — the open tab can
82
+ // reconnect and auto-reload itself instead of you closing and reopening it.
83
+ const cfg0 = loadConfig();
84
+ let TOKEN = cfg0.appToken;
85
+ if (!TOKEN) { TOKEN = randomBytes(16).toString("hex"); try { saveConfig({ ...loadConfig(), appToken: TOKEN }); } catch {} }
86
+ const PORT = Number(process.env.SYMBIOT_PORT || cfg0.appPort) || 7391;
87
+ // Single instance: if a Symbiot app is already serving this port, don't start
88
+ // a second one (multiple instances race the config and split the open tabs) —
89
+ // just open the one that's running. SYMBIOT_FORCE_NEW overrides (e.g. tests).
90
+ // Not when "Update & restart" relaunched us (SYMBIOT_RELAUNCH): the old app is
91
+ // handing this port over and its window is still open, so finding it here would
92
+ // open a second window and exit, leaving nothing serving either window.
93
+ const RELAUNCH = process.env.SYMBIOT_RELAUNCH === "1"; delete process.env.SYMBIOT_RELAUNCH;
94
+ if (!process.env.SYMBIOT_FORCE_NEW && !RELAUNCH) {
95
+ try {
96
+ const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 800);
97
+ const r = await fetch(`http://127.0.0.1:${PORT}/api/ping`, { headers: { "x-symbiot-token": TOKEN }, signal: ctrl.signal }).catch(() => null);
98
+ clearTimeout(to);
99
+ if (r && r.ok) {
100
+ const p = await r.json().catch(() => ({}));
101
+ if (p && p.version) {
102
+ const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url);
103
+ console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
104
+ console.log(how ? c.d(` Opened the existing window (a ${how}).`) : c.d(" Open that URL in your browser."));
105
+ console.log(c.d(" (Not starting a second copy. Set SYMBIOT_FORCE_NEW=1 to force one.)"));
106
+ return;
107
+ }
108
+ }
109
+ } catch {}
110
+ }
111
+ // Screens' hidden browser stays open between map, press and type (headless.mjs),
112
+ // so an agent can type into a field and then press a separate button there.
113
+ keepBrowserOpen(BROWSER_KEEP);
114
+ const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
115
+ const screenOut =(s) => (s && s.id ? { ...s, blueprint: blueprint(s), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}) } : s && s.screens ? { ...s, screens: s.screens.map(screenOut) } : s);
116
+ const server = createServer(async (req, res) => {
117
+ const u = new URL(req.url, "http://127.0.0.1");
118
+ if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
119
+ if (u.pathname.startsWith("/api/")) {
120
+ const tok = req.headers["x-symbiot-token"] || u.searchParams.get("t");
121
+ if (tok !== TOKEN) { res.writeHead(403); res.end("forbidden"); return; }
122
+ }
123
+ try {
124
+ if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
125
+ if (u.pathname === "/api/map") return json(res, await buildMap()); // local git only — no AI key needed
126
+ if (u.pathname === "/api/scan") return json(res, { active: SCAN.active, phase: SCAN.phase, done: SCAN.done, total: SCAN.total, item: SCAN.item, elapsed: SCAN.startedAt ? Date.now() - SCAN.startedAt : 0, timeout: SCAN_TIMEOUT_MS, partial: SCAN.partial });
127
+ if (u.pathname === "/api/models") { const hw = detectHardware(); return json(res, { hardware: hw, rec: recommendModels(hw) }); }
128
+ if (u.pathname === "/api/drift") return json(res, computeDrift({ ci: u.searchParams.get("ci") === "1", fetch: u.searchParams.get("fetch") === "1" }));
129
+ if (u.pathname === "/api/node") return json(res, await nodeDetail(u.searchParams.get("id") || "")); // local
130
+ if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); const p = String(b.path || ""); const isRepo = p && existsSync(join(p, ".git")); return json(res, await (isRepo ? repoSuggest(p) : folderSuggest(p))); }
131
+ if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
132
+ if (u.pathname === "/api/tasks" && req.method !== "POST") { const arch = u.searchParams.get("archived") === "1"; return json(res, loadTasks().filter((x) => !!x.archived === arch).map((t) => ({ ...t, type: taskType(t.text) }))); }
133
+ if (u.pathname === "/api/tasks/add" && req.method === "POST") { const b = await readBody(req); return json(res, addTask(b.text, b.repo)); }
134
+ if (u.pathname === "/api/tasks/toggle" && req.method === "POST") { const b = await readBody(req); return json(res, toggleTask(String(b.id || ""))); }
135
+ if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
136
+ if (u.pathname === "/api/tasks/restore" && req.method === "POST") { const b = await readBody(req); return json(res, restoreTask(String(b.id || ""))); }
137
+ if (u.pathname === "/api/tasks/sync" && req.method === "POST") return json(res, syncTasks());
138
+ if (u.pathname === "/api/tasks/chat" && req.method === "POST") { const b = await readBody(req); return json(res, await taskChat(String(b.id || ""), b.question)); }
139
+ if (u.pathname === "/api/tasks/chat/clear" && req.method === "POST") { const b = await readBody(req); return json(res, clearTaskChat(String(b.id || ""))); }
140
+ if (u.pathname === "/api/pending") { // ticked by the agent, awaiting approval
141
+ // what npm has decides the bump offer, and for a repo that publishes on
142
+ // merge, what's unreleased: ask, then recount (answers are cached)
143
+ const list = pendingReview(), ask = list.filter((r) => r.path && (!r.bumpOffer || r.publishesOnMerge)).map((r) => r.path);
144
+ if (!ask.length) return json(res, list);
145
+ await learnNpm(ask); return json(res, pendingReview());
146
+ }
147
+ if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
148
+ if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveRepo(String(b.repo || ""), { bump: b.bump })); }
149
+ if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveChanges(String(b.repo || ""), { bump: b.bump })); }
150
+ if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
151
+ if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
152
+ if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
153
+ if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: scanHome() });
154
+ if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
155
+ if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
156
+ if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
157
+ if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
158
+ if (u.pathname === "/api/agent/grant" && req.method === "POST") { const b = await readBody(req); return json(res, grantAgent({ tool: b.tool, dir: b.dir })); }
159
+ if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || "")); return json(res, { opened: !!e && !e.busy, busy: !!(e && e.busy), auto: !!(e && e.auto), id: e ? e.id : "" }); }
160
+ if (u.pathname === "/api/setup-local" && req.method === "POST") {
161
+ const b = await readBody(req);
162
+ if (!hasOllama()) return json(res, { error: "not-installed", install: ollamaInstall() });
163
+ const model = String(b.model || "") || recommendModels(detectHardware()).best;
164
+ await ensureOllama();
165
+ const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
166
+ return json(res, { started: true, model, id: e ? e.id : "" });
167
+ }
168
+ if (u.pathname === "/api/agents") return json(res, await withReleases(agentsList()));
169
+ if (u.pathname === "/api/agents/answer" && req.method === "POST") { const b = await readBody(req); return json(res, answerQuestions(String(b.path || ""), b.answers, { rerun: !!b.rerun })); }
170
+ if (u.pathname === "/api/mail") return json(res, mailState());
171
+ if (u.pathname === "/api/mail/set" && req.method === "POST") { const b = await readBody(req); return json(res, setMail(b)); }
172
+ if (u.pathname === "/api/mail/preview") { const items = sentMail(Number(u.searchParams.get("days")) || since, true); return json(res, { count: items.length, items: items.slice(0, 20) }); }
173
+ // Screens: screenshots + named regions (screens.mjs). The image is an <img>
174
+ // src, so it carries the token in the query (?t=), which the check above accepts.
175
+ if (u.pathname === "/api/screens") return json(res, loadScreens().map(screenOut));
176
+ if (u.pathname === "/api/screens/image") { const f = screenImage(u.searchParams.get("id")); if (!f) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": "image/png", "cache-control": "private, max-age=86400" }); res.end(readFileSync(f)); return; } // a screen's image never changes
177
+ if (u.pathname === "/api/screens/capture" && req.method === "POST") { const b = await readBody(req); const d = Math.min(10, Math.max(0, Number(b.delay) || 0)); if (d) await new Promise((r) => setTimeout(r, d * 1000)); return json(res, screenOut(captureScreen(b.name, typeof b.which === "string" ? b.which : "all"))); }
178
+ // The displays connected now (for "which display" next to Capture). macOS
179
+ // can't take them all in one image (screencapture takes one display at a time).
180
+ if (u.pathname === "/api/screens/monitors") return json(res, { monitors: listMonitors(), whole: process.platform !== "darwin" });
181
+ if (u.pathname === "/api/screens/split" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(splitScreen(String(b.id || "")))); }
182
+ // Changes a desktop permission, so only on the user's confirmed click.
183
+ if (u.pathname === "/api/screens/allow" && req.method === "POST") { const b = await readBody(req); if (b.confirmed !== true) return json(res, { error: "Allowing screenshots needs your confirmation." }); return json(res, allowScreenshots()); }
184
+ if (u.pathname === "/api/screens/import" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(importScreen(b.name, b.png))); }
185
+ if (u.pathname === "/api/screens/regions" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(setRegions(String(b.id || ""), b.regions))); }
186
+ if (u.pathname === "/api/screens/rename" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(renameScreen(String(b.id || ""), b.name))); }
187
+ if (u.pathname === "/api/screens/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScreen(String(b.id || ""))); }
188
+ // A real click on the real screen: only with the user's confirmation, after
189
+ // the delay they picked (to bring the right window to the front).
190
+ if (u.pathname === "/api/screens/click" && req.method === "POST") { const b = await readBody(req); if (b.confirmed !== true) return json(res, { error: "Each click needs your confirmation." }); const d = Math.min(10, Math.max(0, Number(b.delay) || 0)); if (d) await new Promise((r) => setTimeout(r, d * 1000)); return json(res, clickRegion(String(b.id || ""), String(b.region || ""))); }
191
+ // A web page in the hidden browser (headless.mjs): mapped by itself, no
192
+ // screen needed. Press acts on the real site, signed in as you, so it's
193
+ // confirmed like a click; Sign in opens a window the user signs in with.
194
+ if (u.pathname === "/api/screens/map" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await mapPage(b.site, b.name))); }
195
+ // press / type: refused unless confirmed, or the page's site is one you trust
196
+ 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 }))); }
197
+ 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 }))); }
198
+ if (u.pathname === "/api/screens/trusted") return json(res, { sites: trustedSites() });
199
+ if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
200
+ if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
201
+ if (u.pathname === "/api/screens/signin" && req.method === "POST") { const b = await readBody(req); return json(res, await signIn(b.site)); }
202
+ // Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
203
+ if (u.pathname === "/api/watch") return json(res, watchState());
204
+ if (u.pathname === "/api/watch/add" && req.method === "POST") { const b = await readBody(req); return json(res, addWatch({ screen: b.screen ? String(b.screen) : "", site: b.site, every: b.every })); }
205
+ if (u.pathname === "/api/watch/every" && req.method === "POST") { const b = await readBody(req); return json(res, setEvery(String(b.id || ""), b.every)); }
206
+ if (u.pathname === "/api/watch/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeWatch(String(b.id || ""))); }
207
+ if (u.pathname === "/api/watch/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkWatch(String(b.id || ""))); }
208
+ if (u.pathname === "/api/watch/clear" && req.method === "POST") return json(res, clearNews());
209
+ if (u.pathname === "/api/watch/brief" && req.method === "POST") { const b = await readBody(req); return json(res, setBrief(b.on === true)); }
210
+ // Watch on your phone (phone.mjs). On the computer: listen on your network
211
+ // for the phone (only on your click), a pairing code, the phones paired.
212
+ // On the phone: pair with the computer, ask it now, forget it.
213
+ if (u.pathname === "/api/phone") return json(res, phoneState());
214
+ if (u.pathname === "/api/phone/link" && req.method === "POST") { const b = await readBody(req); return json(res, await setPhoneLink(b.on === true)); }
215
+ if (u.pathname === "/api/phone/code" && req.method === "POST") return json(res, newCode());
216
+ if (u.pathname === "/api/phone/unpair" && req.method === "POST") { const b = await readBody(req); return json(res, unpairPhone(String(b.id || ""))); }
217
+ if (u.pathname === "/api/phone/pair" && req.method === "POST") { const b = await readBody(req); return json(res, await pairComputer(b.address, b.code)); }
218
+ if (u.pathname === "/api/phone/check" && req.method === "POST") return json(res, await pollComputer());
219
+ if (u.pathname === "/api/phone/forget" && req.method === "POST") return json(res, forgetComputer());
220
+ // What symbiot-desktop added (desktop.mjs): the weekly write-up, start at login.
221
+ if (u.pathname === "/api/desktop") return json(res, { weekly: weeklyState(), autostart: autostartState() });
222
+ if (u.pathname === "/api/desktop/weekly" && req.method === "POST") { const b = await readBody(req); return json(res, setWeekly(b)); }
223
+ if (u.pathname === "/api/desktop/weekly/run" && req.method === "POST") return json(res, await runWeekly(writeup));
224
+ if (u.pathname === "/api/desktop/autostart" && req.method === "POST") { const b = await readBody(req); return json(res, setAutostart(!!b.on, bin)); }
225
+ if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, await writeup(cmd)); }
226
+ if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
227
+ if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1") await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: semverGt(LATEST_VERSION, VERSION), ...(IN_TERMUX ? { termux: true } : {}) }); }
228
+ if (u.pathname === "/api/update" && req.method === "POST") {
229
+ // Install the exact newest version (see updateCmd), then relaunch this
230
+ // same app (same port+token => same URL) and exit. The page's heartbeat
231
+ // reconnects and reloads. Free the port first and mark the new copy as a
232
+ // relaunch, so it takes over instead of finding us and bowing out.
233
+ // SYMBIOT_UPDATE_CMD replaces the install (the tests use a no-op).
234
+ const { target, cmd } = updateCmd(LATEST_VERSION, VERSION);
235
+ const inst = process.env.SYMBIOT_UPDATE_CMD || cmd;
236
+ const e = track("symbiot update", inst, homedir(), (code) => {
237
+ if (code !== 0) return;
238
+ server.close(); if (server.closeAllConnections) server.closeAllConnections();
239
+ try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: { ...process.env, SYMBIOT_RELAUNCH: "1" } }); ch.unref(); } catch {}
240
+ setTimeout(() => process.exit(0), 1200);
241
+ });
242
+ return json(res, { started: true, id: e ? e.id : "", target });
243
+ }
244
+ if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
245
+ } catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String((e && e.message) || e) })); return; }
246
+ res.writeHead(404); res.end("not found");
247
+ });
248
+ let announced = false, tries = 0, opened = false;
249
+ server.on("listening", () => {
250
+ if (announced) return; announced = true;
251
+ const url = `http://127.0.0.1:${server.address().port}/?t=${TOKEN}`;
252
+ const how = opened || RELAUNCH || process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url); opened = true; // only pop a window the first time (never in tests, never after an update)
253
+ console.log(`\n${c.g("●")} ${c.b("Symbiot")} is running at ${c.b(url)}`);
254
+ console.log(RELAUNCH ? c.d(" Restarted after an update; the open window reloads itself.") : how ? c.d(` Opened in a ${how}.`) : c.d(" Open that URL in your browser."));
255
+ console.log(c.d(" Leave this running; press Ctrl+C to stop (or click Quit in the window)."));
256
+ });
257
+ server.on("error", (e) => {
258
+ // Stable port busy (an older instance still exiting during an update, or a
259
+ // second app): retry briefly, then fall back to a random port.
260
+ if (e && e.code === "EADDRINUSE" && tries < 8) { tries++; setTimeout(() => { try { server.listen(PORT, "127.0.0.1"); } catch {} }, 500); }
261
+ else { try { server.listen(0, "127.0.0.1"); } catch {} }
262
+ });
263
+ server.listen(PORT, "127.0.0.1");
264
+ checkLatest(); setInterval(checkLatest, 2 * 60 * 1000).unref(); // background update check (every 2 min)
265
+ startWeekly(writeup); // the weekly write-up + notification, when switched on in Settings
266
+ startWatches(); // pages you watch (Screens → Watch), read every few minutes
267
+ startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
268
+ }
269
+
270
+ export { updateCmd, BROWSER_KEEP, startApp };