symbiot 0.63.6 → 0.63.8

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 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 };