symbiot 0.56.0 → 0.57.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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
4
4
 
5
+ ## 0.57.0
6
+
7
+ - **Setup, before anything else.** A new Symbiot opens straight into setup: what it is (it watches, it works, it asks), your AI (your Claude subscription, found by itself), your work (your projects, found while you read), your coding agent (one click), every app you use (each connected, or marked "I don't use it"; you can't go on until they're all decided), and an optional folder of your documents. Then a short "finding your way" and Home. Where you are is kept, so closing it resumes there; Settings → Setup → Run setup again walks it again.
8
+ - **Installing opens it.** `npm install -g symbiot` on a desktop opens Symbiot right away, into setup, and says so in the terminal (npm used to hide that line).
9
+ - **No more blank orb on the first run.** Symbiot searched your folders for projects again on every Home refresh, and built the Map at startup even when you didn't open it: on a big home folder that froze it for a minute. Your projects are now found once, in the background, kept between runs and refreshed every ten minutes or when your folders change; the Map is built when you open it. Anything slow is noted in Symbiot's log.
10
+ - **The icon follows your look.** Ferrofluid, Glass or Pearl: the app menu's icon and the window's change with it.
11
+ - A new install's Home says "Nothing needs you right now" instead of "All handled".
12
+
5
13
  ## 0.56.0
6
14
 
7
15
  - **Symbiot in your app menu, no terminal.** On Linux, `npm install -g symbiot` puts Symbiot in your app menu with its own icon, the orb. Click it and Symbiot starts in the background and opens its window; click it again and the window comes up. You never need a terminal to run it. Its window shows the orb in your taskbar too, and the menu entry keeps itself pointing at the installed Symbiot (a new Node, a moved install).
package/README.md CHANGED
@@ -43,10 +43,15 @@ npm install -g symbiot # or run without installing: npx symbiot week
43
43
  With Claude Code signed in to your Claude account, that's all: Symbiot uses your
44
44
  subscription, no API key. See [Connect it (once)](#connect-it-once) for the other options.
45
45
 
46
- On Linux, installing puts **Symbiot in your app menu**, with its icon. Open it from
47
- there: it starts in the background (no terminal) and opens its window, or brings the
48
- window up if it's already running. You don't need a terminal again; `symbiot open`
49
- does the same from one.
46
+ On Linux, installing puts **Symbiot in your app menu**, with its icon, and opens it
47
+ straight into **setup**: what Symbiot is, your AI (your Claude subscription, found by
48
+ itself), where your work is (it finds your projects meanwhile), your coding agent,
49
+ every app you use (connected, or marked as one you don't use) and, if you like, a
50
+ folder of your documents. Home opens once it's done; Settings → Setup runs it again.
51
+
52
+ After that, open Symbiot from your app menu: it starts in the background (no
53
+ terminal) and opens its window, or brings the window up if it's already running.
54
+ `symbiot open` does the same from a terminal. The icon follows the look you pick.
50
55
 
51
56
  To remove it and everything it added (menu entry, icon, start at login, shortcut,
52
57
  its data): `symbiot uninstall` (`--keep-data` keeps your settings, tasks and memory).
package/desktop.mjs CHANGED
@@ -155,6 +155,26 @@ function autostartContent(node, script, platform = OS, path = process.env.PATH |
155
155
  const launcherFile = (home = homedir()) => join(home, ".local", "share", "applications", "symbiot.desktop");
156
156
  const launcherIcon = (home = homedir()) => join(home, ".local", "share", "icons", "hicolor", "scalable", "apps", "symbiot.svg");
157
157
  const ICON_SRC = fileURLToPath(new URL("./icon.svg", import.meta.url));
158
+ // The orb in each look: Ferrofluid (icon.svg, dark), Glass (clear, on a cool light
159
+ // ground) and Pearl (white pearl, on a warm light ground). The app menu's icon and
160
+ // the window's follow the look you pick (setLauncherLook).
161
+ const LOOK_COLOURS = {
162
+ glass: { bg: ["#F4F7FB", "#D9E1EC"], orb: ["#FFFFFF", "#C9D6E6", "#7F93AE"], rim: "#5B8DEF", glow: "#5B8DEF" },
163
+ pearl: { bg: ["#F7F4EE", "#E6E0D5"], orb: ["#FFFFFF", "#ECE7DF", "#A99F92"], rim: "#C9A46A", glow: "#F2A541" },
164
+ };
165
+ function iconSvg(look = "ferro") {
166
+ const base = readFileSync(ICON_SRC, "utf8"), c = LOOK_COLOURS[look];
167
+ if (!c) return base;
168
+ return base.replace('stop-color="#1B1D22"', `stop-color="${c.bg[0]}"`).replace('stop-color="#08090B"', `stop-color="${c.bg[1]}"`)
169
+ .replace('stop-color="#3A3D44"', `stop-color="${c.orb[0]}"`).replace('stop-color="#121317"', `stop-color="${c.orb[1]}"`).replace('stop-color="#030304"', `stop-color="${c.orb[2]}"`)
170
+ .replace('stop-color="#B0466E" stop-opacity="0.55"', `stop-color="${c.rim}" stop-opacity="0.45"`)
171
+ .replace(/stop-color="#F2A541" stop-opacity="0\.16"/, `stop-color="${c.glow}" stop-opacity="0.18"`);
172
+ }
173
+ // The look changed: the app menu's icon follows, if the app menu has Symbiot.
174
+ function setLauncherLook(look, home = homedir()) {
175
+ if (!existsSync(launcherFile(home))) return false;
176
+ try { writeFileSync(launcherIcon(home), iconSvg(look), { mode: 0o644 }); return true; } catch { return false; }
177
+ }
158
178
  function launcherContent(node, script, path = process.env.PATH || "") {
159
179
  const q = (s) => '"' + String(s).replace(/(["`$\\])/g, "\\$1") + '"';
160
180
  return ["[Desktop Entry]", "Type=Application", "Name=Symbiot", "GenericName=Assistant",
@@ -180,7 +200,7 @@ function installLauncher({ node = process.execPath, script, home = homedir(), pl
180
200
  let had = ""; try { had = readFileSync(file, "utf8"); } catch {}
181
201
  if (had !== want) { mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, want, { mode: 0o644 }); wrote = true; }
182
202
  let hadIcon = ""; try { hadIcon = readFileSync(icon, "utf8"); } catch {}
183
- const svg = readFileSync(ICON_SRC, "utf8");
203
+ const svg = iconSvg(loadConfig().look || "ferro");
184
204
  if (hadIcon !== svg) { mkdirSync(join(icon, ".."), { recursive: true }); writeFileSync(icon, svg, { mode: 0o644 }); wrote = true; }
185
205
  } catch (e) { return { error: (e && e.message) || String(e) }; }
186
206
  if (wrote) { try { spawn("update-desktop-database", [join(file, "..")], { stdio: "ignore", detached: true }).on("error", () => {}).unref(); } catch {} }
@@ -206,4 +226,4 @@ function setAutostart(on, script) {
206
226
  } catch (e) { return { ...autostartState(), error: String((e && e.message) || e) }; }
207
227
  }
208
228
 
209
- export { launcherPath, launcherFile, launcherIcon, launcherContent, installLauncher, removeLauncher, weeklyCfg, lastSlot, weeklyDue, setWeekly, weeklyState, latestWeek, runWeekly, startWeekly, notifyCmd, desktopNotify, autostartFile, autostartContent, autostartState, setAutostart };
229
+ export { iconSvg, setLauncherLook, launcherPath, launcherFile, launcherIcon, launcherContent, installLauncher, removeLauncher, weeklyCfg, lastSlot, weeklyDue, setWeekly, weeklyState, latestWeek, runWeekly, startWeekly, notifyCmd, desktopNotify, autostartFile, autostartContent, autostartState, setAutostart };
package/home.mjs CHANGED
@@ -14,14 +14,14 @@ import { homedir } from "node:os";
14
14
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
15
  import { watchBoard, draftReply } from "./watch.mjs";
16
16
  import { pendingReview, pushTasks, addTask } from "./tasks.mjs";
17
- import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent } from "./agents.mjs";
17
+ import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent, detectHandoffs, linkReach } from "./agents.mjs";
18
18
  import { linksState } from "./links.mjs";
19
19
  import { knowledgeState } from "./knowledge.mjs";
20
- import { CONFIG_DIR, loadTasks, saveTasks } from "./core.mjs";
20
+ import { CONFIG_DIR, loadTasks, saveTasks, loadConfig, saveConfig } from "./core.mjs";
21
21
  import { lanesState, stuckHandovers, allowHandover, skipHandover } from "./lanes.mjs";
22
22
  import { converse, actIn, actNow, taskIn, loadMind } from "./mind.mjs";
23
- import { repoPathMap, laneMap } from "./scan.mjs";
24
- import { resolveProvider } from "./ai.mjs";
23
+ import { repoPathMap, laneMap, reposState, scanRoots } from "./scan.mjs";
24
+ import { resolveProvider, PROVIDERS, claudeState } from "./ai.mjs";
25
25
  import { reportsNews, listReports } from "./reports.mjs";
26
26
  import { awaitingState, inboxSight } from "./handback.mjs";
27
27
  import { newClashes } from "./checks.mjs";
@@ -493,4 +493,42 @@ function workGo({ push = pushTasks, run = runHandoff } = {}) {
493
493
  return { started, queued, repos, ...(parked.length ? { parked } : {}), ...(r.unresolved && r.unresolved.length ? { unresolved: r.unresolved.map((u) => u.name) } : {}) };
494
494
  }
495
495
 
496
- export { marketingState, marketingGo, marketingTask, moveToMarketing, goLane, homeState, homeContext, homeAsk, homeAnswer, homeNext, nextUp, laneNamed, NEXT_FILE, workScene, workGo, displayName, firstSteps };
496
+ // ---- setup (the first run) --------------------------------------------------------
497
+ // A new Symbiot walks you through setup before anything else: what it is, your AI,
498
+ // where your work is, your agent, every app you use (connected, or "I don't use it"),
499
+ // your documents, then Home. It's in config.onboarding: { pending, step, skipped: [link
500
+ // ids you don't use] }; a Symbiot that ran before this existed isn't sent through it.
501
+ const ONB_STEPS = ["welcome", "ai", "work", "agent", "apps", "docs", "done"];
502
+ function onboarding({ fresh = false } = {}) {
503
+ const cfg = loadConfig(), o = cfg.onboarding || {};
504
+ const tryOr = (f, d) => { try { return f(); } catch { return d; } };
505
+ let st = null; try { st = claudeState(fresh); } catch {}
506
+ const r = tryOr(() => resolveProvider(), null);
507
+ const reps = tryOr(() => reposState(), { searching: false, list: [] });
508
+ const links = tryOr(() => linksState(), { items: [] }), reach = tryOr(() => linkReach(), null), skipped = new Set(o.skipped || []);
509
+ const apps = (links.items || []).map((x) => ({ id: x.id, name: x.name, group: x.group, state: x.state, ...(x.note ? { note: x.note } : {}), skipped: skipped.has(x.id),
510
+ ...(reach && reach.sites && reach.sites[x.id] ? { agents: !!reach.sites[x.id].ready, connector: reach.sites[x.id].name } : {}) }));
511
+ const det = tryOr(() => detectHandoffs(), { agents: [] }), cmd = tryOr(() => handoffCmd(), "");
512
+ return {
513
+ pending: !!o.pending, step: ONB_STEPS.includes(o.step) ? o.step : "welcome", steps: ONB_STEPS,
514
+ ai: { connected: !!r, provider: r ? r.provider : "", line: r ? `${PROVIDERS[r.provider].label}${r.model ? " · " + r.model : ""}` : "", claude: st ? { installed: st.installed, signedIn: st.signedIn } : null },
515
+ work: { searching: reps.searching, done: reps.done || 0, total: reps.total || 0, count: reps.list.length, repos: reps.list.slice(0, 60).map((x) => ({ name: x.name, path: x.path })), roots: tryOr(() => scanRoots(), []).map((r) => (r === homedir() ? "~" : r.startsWith(homedir() + "/") ? "~" + r.slice(homedir().length) : r)) },
516
+ agent: { cmd, pick: tryOr(() => pickAgent(), null), agents: (det.agents || []).map((a) => ({ label: a.label, tmpl: a.tmpl })) },
517
+ apps, groups: links.groups || [], decided: apps.every((a) => a.skipped || a.state !== "off"),
518
+ docs: { folders: tryOr(() => (knowledgeState().folders || []).map((f) => f.path), []) },
519
+ };
520
+ }
521
+ // step: where you are; skip / unskip: an app you do or don't use; skipRest: every app not connected.
522
+ function setOnboarding({ step, skip, unskip, skipRest, done, restart } = {}) {
523
+ const cfg = loadConfig(), o = { ...(cfg.onboarding || {}) }, sk = new Set(o.skipped || []);
524
+ if (restart) { o.pending = true; o.step = "welcome"; }
525
+ if (step && ONB_STEPS.includes(step)) o.step = step;
526
+ if (skip) sk.add(String(skip)); if (unskip) sk.delete(String(unskip));
527
+ if (skipRest) { try { for (const x of linksState().items) if (x.state === "off") sk.add(x.id); } catch {} }
528
+ o.skipped = [...sk];
529
+ if (done) { o.pending = false; o.done = Date.now(); o.step = "done"; }
530
+ cfg.onboarding = o; saveConfig(cfg); cached = null;
531
+ return onboarding();
532
+ }
533
+
534
+ export { marketingState, marketingGo, marketingTask, moveToMarketing, goLane, homeState, homeContext, homeAsk, homeAnswer, homeNext, nextUp, laneNamed, NEXT_FILE, workScene, workGo, displayName, firstSteps, onboarding, setOnboarding, ONB_STEPS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
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,7 +46,8 @@
46
46
  "away.mjs",
47
47
  "marketing.mjs",
48
48
  "postinstall.mjs",
49
- "icon.svg"
49
+ "icon.svg",
50
+ "scanworker.mjs"
50
51
  ],
51
52
  "engines": {
52
53
  "node": ">=18"
@@ -79,7 +80,7 @@
79
80
  "@anthropic-ai/sdk": "^0.128.0"
80
81
  },
81
82
  "scripts": {
82
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.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/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/subscription.mjs && node test/launcher.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
83
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.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/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/subscription.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
83
84
  "lint": "eslint .",
84
85
  "postinstall": "node postinstall.mjs"
85
86
  },
package/postinstall.mjs CHANGED
@@ -1,12 +1,19 @@
1
1
  // npm install -g symbiot runs this: Symbiot in your app menu, with its icon, so you
2
- // never need a terminal to start it (desktop.mjs installLauncher). Only for a global
3
- // install, and it never fails the install.
4
- import { realpathSync } from "node:fs";
2
+ // never need a terminal to start it (desktop.mjs installLauncher), and then it opens,
3
+ // so setup starts right away. Only for a global install on a desktop, and it never
4
+ // fails the install. npm hides what install scripts print, so the line goes straight
5
+ // to your terminal.
6
+ import { realpathSync, writeFileSync } from "node:fs";
7
+ import { spawn } from "node:child_process";
5
8
  import { fileURLToPath } from "node:url";
9
+ const say = (s) => { try { writeFileSync("/dev/tty", s + "\n"); } catch { console.log(s); } };
6
10
  try {
7
11
  if (process.env.npm_config_global === "true" && !process.env.SYMBIOT_NO_LAUNCHER) {
12
+ const script = realpathSync(fileURLToPath(new URL("./index.mjs", import.meta.url)));
8
13
  const { installLauncher } = await import("./desktop.mjs");
9
- const r = installLauncher({ script: realpathSync(fileURLToPath(new URL("./index.mjs", import.meta.url))) });
10
- if (r.file) console.log("Symbiot is in your app menu: open it from there, no terminal needed.");
14
+ const r = installLauncher({ script });
15
+ const desktop = !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY) && !process.env.CI && !process.env.SYMBIOT_NO_OPEN;
16
+ if (desktop) { spawn(process.execPath, [script, "open"], { detached: true, stdio: "ignore" }).unref(); say("\nSymbiot is installed and opening now. Next time, open it from your app menu: no terminal needed.\n"); }
17
+ else if (r.file) say("\nSymbiot is in your app menu: open it from there, no terminal needed.\n");
11
18
  }
12
19
  } catch {}
package/scan.mjs CHANGED
@@ -2,10 +2,11 @@
2
2
  // distros), finding repos and project folders without hanging, the facts each
3
3
  // one gives (who you are in it, its commits, open work, README, rules), the
4
4
  // Map's graph, and the one repo set every view shares.
5
+ import { Worker } from "node:worker_threads";
5
6
  import { spawnSync } from "node:child_process";
6
7
  import { homedir } from "node:os";
7
8
  import { join } from "node:path";
8
- import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
9
+ import { readFileSync, existsSync, statSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
9
10
  import { VERSION, loadConfig, saveConfig, sh, repoState } from "./core.mjs";
10
11
  import { detectHandoffs } from "./agents.mjs";
11
12
  import { PROVIDERS, resolveProvider } from "./ai.mjs";
@@ -18,7 +19,7 @@ let LAST_MAP = null; // cached graph so node clicks don't rescan
18
19
  // How this process scans: dir (--dir, one folder this run instead of the scan
19
20
  // folders), quiet (the app: no progress line) and plain (no colour). Set by
20
21
  // index.mjs from its flags.
21
- const OPTS = { dir: "", quiet: false, plain: false };
22
+ const OPTS = { dir: "", quiet: false, plain: false, cache: false };
22
23
  function setScanOptions(o) { Object.assign(OPTS, o); }
23
24
  const paint = (code) => (s) => OPTS.plain ? s : `\x1b[38;5;${code}m${s}\x1b[0m`;
24
25
  const green = paint(42), faint = paint(66), amber = paint(179);
@@ -209,11 +210,11 @@ function addScanRoot(p) {
209
210
  const cfg = loadConfig();
210
211
  let list = Array.isArray(cfg.scanRoots) && cfg.scanRoots.length ? cfg.scanRoots : scanDefaults(); // keep home when adding the first extra folder
211
212
  if (!list.includes(p)) list.push(p);
212
- cfg.scanRoots = list; saveConfig(cfg); LAST_MAP = null; return { ok: true, roots: cfg.scanRoots };
213
+ cfg.scanRoots = list; saveConfig(cfg); LAST_MAP = null; if (OPTS.cache) refreshRepos(); return { ok: true, roots: cfg.scanRoots };
213
214
  }
214
215
  function removeScanRoot(p) {
215
216
  const cfg = loadConfig(); cfg.scanRoots = (Array.isArray(cfg.scanRoots) ? cfg.scanRoots : []).filter((x) => x !== p);
216
- if (!cfg.scanRoots.length) delete cfg.scanRoots; saveConfig(cfg); LAST_MAP = null; return { ok: true, roots: cfg.scanRoots || [] };
217
+ if (!cfg.scanRoots.length) delete cfg.scanRoots; saveConfig(cfg); LAST_MAP = null; if (OPTS.cache) refreshRepos(); return { ok: true, roots: cfg.scanRoots || [] };
217
218
  }
218
219
 
219
220
  function commits(repos, sinceExpr, mineOnly = true) {
@@ -277,7 +278,50 @@ const MANIFEST_TOOL = {
277
278
  "Gemfile": "Ruby", "composer.json": "PHP", "Dockerfile": "Docker", "docker-compose.yml": "Docker",
278
279
  "terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
279
280
  };
281
+ // ---- your projects, kept (the app) -------------------------------------------------
282
+ // Finding them walks your folders and calls git in each repo: a second here, a minute
283
+ // on a big home folder, and it ran again on every Home refresh, so the app stood still
284
+ // (a blank orb for a minute on a first run). The app (setScanOptions({ cache: true }))
285
+ // keeps the list: from repos.json at once on start, refreshed in a worker thread
286
+ // (scanworker.mjs) on start, every REPO_FRESH, and when your folders change. Until the
287
+ // first search ends there's nothing yet, rather than a wait. The CLI still searches
288
+ // each time.
289
+ const REPO_FRESH = 10 * 60 * 1000;
290
+ const repoFile = () => join(homedir(), ".config", "symbiot", "repos.json");
291
+ let REPO_CACHE = null, REPO_JOB = null;
292
+ const REPO_STATE = { searching: false, done: 0, total: 0, phase: "", at: 0, onChange: null };
293
+ function loadRepoCache() {
294
+ if (REPO_CACHE) return REPO_CACHE;
295
+ try { const j = JSON.parse(readFileSync(repoFile(), "utf8")); if (j && Array.isArray(j.list)) { REPO_CACHE = { at: Number(j.at) || 0, list: j.list }; REPO_STATE.at = REPO_CACHE.at; } } catch {}
296
+ return REPO_CACHE;
297
+ }
298
+ // Find them again in a worker; when it's done, keep and save the list. One at a time.
299
+ function refreshRepos({ worker = true } = {}) {
300
+ if (REPO_JOB) return REPO_JOB;
301
+ REPO_STATE.searching = true; Object.assign(REPO_STATE, { done: 0, total: 0, phase: "" });
302
+ const finish = (list) => {
303
+ REPO_CACHE = { at: Date.now(), list: Array.isArray(list) ? list : [] }; REPO_STATE.at = REPO_CACHE.at; REPO_STATE.searching = false; REPO_JOB = null; LAST_MAP = null;
304
+ try { mkdirSync(join(repoFile(), ".."), { recursive: true }); writeFileSync(repoFile(), JSON.stringify(REPO_CACHE), { mode: 0o600 }); } catch {}
305
+ try { if (REPO_STATE.onChange) REPO_STATE.onChange(REPO_CACHE.list); } catch {}
306
+ return REPO_CACHE.list;
307
+ };
308
+ if (!worker) { const was = OPTS.cache; OPTS.cache = false; let l = []; try { l = findAllRepos(); } finally { OPTS.cache = was; } return Promise.resolve(finish(l)); }
309
+ REPO_JOB = new Promise((resolve) => {
310
+ let w; try { w = new Worker(new URL("./scanworker.mjs", import.meta.url)); } catch { const was = OPTS.cache; OPTS.cache = false; let l = []; try { l = findAllRepos(); } finally { OPTS.cache = was; } resolve(finish(l)); return; }
311
+ let got = null;
312
+ w.on("message", (m) => { if (m && m.progress) Object.assign(REPO_STATE, m.progress); if (m && m.list) got = m.list; });
313
+ w.on("error", () => {}); w.on("exit", () => resolve(finish(got || (REPO_CACHE && REPO_CACHE.list) || [])));
314
+ });
315
+ return REPO_JOB;
316
+ }
317
+ // What the app knows of your projects right now, and whether it's still looking.
318
+ const reposState = () => ({ searching: REPO_STATE.searching, done: REPO_STATE.done, total: REPO_STATE.total, phase: REPO_STATE.phase, at: REPO_STATE.at, list: (REPO_CACHE && REPO_CACHE.list) || [] });
280
319
  function findAllRepos(base) {
320
+ if (!base && OPTS.cache) {
321
+ const c = loadRepoCache();
322
+ if (!c || Date.now() - c.at > REPO_FRESH) refreshRepos();
323
+ return c ? c.list : [];
324
+ }
281
325
  const own = scanBegin();
282
326
  try {
283
327
  const roots = base ? [base] : scanRoots();
@@ -418,6 +462,7 @@ async function buildMapNow() {
418
462
  }
419
463
  async function buildMapScan() {
420
464
  const who = me();
465
+ if (OPTS.cache && (REPO_STATE.searching || !loadRepoCache())) await refreshRepos(); // the first search, not an empty Map
421
466
  const found = findAllRepos();
422
467
  const repos = [];
423
468
  scanPhase("reading repo details", found.length);
@@ -561,4 +606,4 @@ function discoveredRepos() {
561
606
  return findAllRepos();
562
607
  }
563
608
 
564
- export { laneMap, SCAN, SCAN_TIMEOUT_MS, setScanOptions, scanBegin, scanPhase, scanTick, scanExpired, scanEnd, me, authorship, authorArgs, readmeInfo, repoShape, houseRules, reportFooter, expandRoot, scanHome, scanBase, storageBlocked, prootDistros, prootHomes, scanRoots, addScanRoot, removeScanRoot, commits, openWork, findAllRepos, detectRepo, findProjectFolders, detectFolder, claudeProjects, buildMap, nodeDetail, repoPathMap, discoveredRepos };
609
+ export { refreshRepos, reposState, REPO_STATE, laneMap, SCAN, SCAN_TIMEOUT_MS, setScanOptions, scanBegin, scanPhase, scanTick, scanExpired, scanEnd, me, authorship, authorArgs, readmeInfo, repoShape, houseRules, reportFooter, expandRoot, scanHome, scanBase, storageBlocked, prootDistros, prootHomes, scanRoots, addScanRoot, removeScanRoot, commits, openWork, findAllRepos, detectRepo, findProjectFolders, detectFolder, claudeProjects, buildMap, nodeDetail, repoPathMap, discoveredRepos };
package/scanworker.mjs ADDED
@@ -0,0 +1,12 @@
1
+ // symbiot — finding your projects off the app's main thread (scan.mjs refreshRepos):
2
+ // a walk of your folders and a git call per repo can take a minute on a big home
3
+ // folder, and the app has to keep answering meanwhile. Posts { progress } as it goes
4
+ // and { list } at the end.
5
+ import { parentPort } from "node:worker_threads";
6
+ const scan = await import("./scan.mjs");
7
+ scan.setScanOptions({ quiet: true });
8
+ const tick = setInterval(() => { const s = scan.SCAN; parentPort.postMessage({ progress: { phase: s.phase, done: s.done, total: s.total } }); }, 400);
9
+ let list = [];
10
+ try { list = scan.findAllRepos(); } catch {}
11
+ clearInterval(tick);
12
+ parentPort.postMessage({ list });
package/server.mjs CHANGED
@@ -16,13 +16,13 @@ import { EMBEDDED_UI } from "./ui.mjs";
16
16
  import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
17
17
  import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull, readLastWords, sandboxState } from "./agents.mjs";
18
18
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
19
- import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap, laneMap } from "./scan.mjs";
19
+ import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap, laneMap, setScanOptions, refreshRepos, REPO_STATE } from "./scan.mjs";
20
20
  import { computeDrift } from "./drift.mjs";
21
21
  import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
22
22
  import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
23
23
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
24
24
  import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
25
- import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart, installLauncher } from "./desktop.mjs";
25
+ import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart, installLauncher, iconSvg, setLauncherLook } from "./desktop.mjs";
26
26
  import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
27
27
  import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
28
28
  import { testWeeks, testInstalls, postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
@@ -30,7 +30,7 @@ import { mindState, forget } from "./mind.mjs";
30
30
  import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
31
31
  import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
32
32
  import { adaptState, noteUse } from "./adapt.mjs";
33
- import { homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, firstSteps, marketingState, marketingGo, marketingTask, moveToMarketing } from "./home.mjs";
33
+ import { homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, firstSteps, marketingState, marketingGo, marketingTask, moveToMarketing, onboarding, setOnboarding } from "./home.mjs";
34
34
  import { MARKETING_DIR } from "./marketing.mjs";
35
35
  import { listReports, readReport, markAllRead } from "./reports.mjs";
36
36
  import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
@@ -186,6 +186,8 @@ const PLAIN_COLOURS = { g: (s) => s, d: (s) => s, b: (s) => s, y: (s) => s };
186
186
  async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {}) {
187
187
  const writeup = (cmd) => produce(cmd, { since, all });
188
188
  const SERVER_STARTED = Date.now();
189
+ // a brand-new Symbiot (no config yet) walks you through setup first (home.mjs onboarding)
190
+ const FRESH = !SANDBOX && !existsSync(CONFIG_PATH);
189
191
  // Stable token + port so the URL survives a restart — the open tab can
190
192
  // reconnect and auto-reload itself instead of you closing and reopening it.
191
193
  const cfg0 = loadConfig();
@@ -217,16 +219,21 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
217
219
  // an installed Symbiot keeps its app-menu entry pointing at itself (a new Node, a moved
218
220
  // install); not a copy run from a checkout or a test
219
221
  if (/[\\/]node_modules[\\/]symbiot[\\/]/.test(bin || "") && !SANDBOX) { try { installLauncher({ script: bin }); } catch {} }
222
+ if (FRESH) { try { const cf = loadConfig(); if (!cf.onboarding) { cf.onboarding = { pending: true, step: "welcome", skipped: [] }; saveConfig(cf); } } catch {} }
223
+ // your projects: kept, and found again off the main thread, so nothing waits on a search
224
+ setScanOptions({ cache: true }); REPO_STATE.onChange = () => { try { homeState({ fresh: true }); } catch {} };
225
+ { 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.`)); }
220
226
  let UPDATING = null; // an update in flight: { target, attempt, retrying? }
221
227
  let NEWEST_WIN = ""; // the Symbiot window opened last: older ones close themselves (one window, not a pile)
222
228
  process.on("exit", () => closeAway()); // Away's windows go with the app (a quit, an update's restart)
223
229
  const server = createServer(async (req, res) => {
230
+ const t0 = Date.now(); res.on("finish", () => { const ms = Date.now() - t0; if (ms > 1500) console.log(`slow: ${String(req.url || "").split("?")[0]} took ${(ms / 1000).toFixed(1)}s`); });
224
231
  const u = new URL(req.url, "http://127.0.0.1");
225
232
  if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
226
233
  // the app's typeface ships in the package (fonts/), so it's there offline
227
234
  // the window's own icon (its taskbar entry), the same orb as the app menu's
228
235
  if (req.method === "GET" && (u.pathname === "/favicon.svg" || u.pathname === "/favicon.ico")) {
229
- try { const b = readFileSync(fileURLToPath(new URL("./icon.svg", import.meta.url))); res.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" }); res.end(b); }
236
+ try { const b = iconSvg(u.searchParams.get("look") || loadConfig().look || "ferro"); res.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "no-cache" }); res.end(b); }
230
237
  catch { res.writeHead(404); res.end(); }
231
238
  return;
232
239
  }
@@ -389,6 +396,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
389
396
  // the work scene: what agents are doing, what's waiting, what's ready; Go starts what's waiting
390
397
  if (u.pathname === "/api/work") return json(res, workScene());
391
398
  if (u.pathname === "/api/work/go" && req.method === "POST") return json(res, workGo());
399
+ if (u.pathname === "/api/onboarding") return json(res, onboarding({ fresh: u.searchParams.get("fresh") === "1" }));
400
+ if (u.pathname === "/api/onboarding/set" && req.method === "POST") { const b = await readBody(req); return json(res, setOnboarding({ step: b.step, skip: b.skip, unskip: b.unskip, skipRest: !!b.skipRest })); }
401
+ if (u.pathname === "/api/onboarding/done" && req.method === "POST") return json(res, setOnboarding({ done: true }));
402
+ if (u.pathname === "/api/onboarding/restart" && req.method === "POST") return json(res, setOnboarding({ restart: true }));
403
+ if (u.pathname === "/api/look" && req.method === "POST") { const b = await readBody(req), look = ["ferro", "glass", "pearl"].includes(b.look) ? b.look : "ferro"; const cf = loadConfig(); if (cf.look !== look) { cf.look = look; saveConfig(cf); } return json(res, { look, launcher: setLauncherLook(look) }); }
392
404
  if (u.pathname === "/api/firststeps") return json(res, firstSteps()); // Settings' first steps: what's set up, in order
393
405
  if (u.pathname === "/api/home/answer" && req.method === "POST") { const b = await readBody(req); return json(res, homeAnswer(b.id, { pick: b.pick, text: b.text })); }
394
406
  if (u.pathname === "/api/away" && req.method === "POST") { const b = await readBody(req); return json(res, toggleAway(`http://127.0.0.1:${server.address().port}/?t=${TOKEN}`, b.open)); }
package/ui.mjs CHANGED
@@ -902,6 +902,42 @@ body.lq-away #lqaway{display:block;position:fixed;left:0;top:0;width:440px;text-
902
902
  #lqaway .awh{margin-top:16px;font-size:11.5px;opacity:.32}
903
903
  body.lq-light #lqaway{color:#151A21;text-shadow:0 1px 12px rgba(255,255,255,.8)}
904
904
  body.lq-light #lqaway .aws b{color:#9A5200}
905
+
906
+ /* Setup: the first run, over everything, until it's done */
907
+ .onb{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:rgba(5,6,8,.74);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}
908
+ .onb.hidden{display:none}
909
+ .onbin{width:min(700px,calc(100vw - 32px));max-height:calc(100vh - 40px);overflow:auto;padding:30px 32px 26px;border-radius:28px;background:rgba(19,21,25,.9);border:1px solid rgba(255,255,255,.08);box-shadow:0 30px 80px -30px rgba(0,0,0,.9);color:var(--text)}
910
+ .onbdots{display:flex;gap:6px;margin-bottom:22px}.onbdots i{display:block;height:4px;flex:1;border-radius:4px;background:rgba(255,255,255,.1)}.onbdots i.on{background:var(--amber)}.onbdots i.past{background:rgba(242,165,65,.45)}
911
+ .onb h2{margin:0 0 8px;font:650 28px/1.15 var(--sans);letter-spacing:-.02em;color:var(--bone)}
912
+ .onbl{margin:0 0 20px;font-size:15px;line-height:1.55;color:var(--faint)}
913
+ .onbcards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:6px 0 20px}
914
+ .onbcard{padding:16px;border-radius:18px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07)}
915
+ .onbcard b{display:block;margin-bottom:6px;font:600 15px var(--sans);color:var(--bone)}.onbcard span{font-size:13.5px;line-height:1.5;color:var(--faint)}
916
+ .onbnote{font-size:13px;color:var(--faint);margin:0 0 18px}
917
+ .onbok{display:flex;gap:10px;align-items:flex-start;padding:14px 16px;border-radius:16px;background:rgba(79,190,140,.08);border:1px solid rgba(79,190,140,.25);margin-bottom:18px;font-size:14.5px;line-height:1.5}
918
+ .onbok i{flex:none;width:20px;height:20px;border-radius:50%;background:#4FBE8C;color:#06120C;font:700 12px/20px var(--sans);text-align:center;font-style:normal}
919
+ .onbbox{padding:16px;border-radius:18px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.07);margin-bottom:12px}
920
+ .onbbox h3{margin:0 0 6px;font:600 15px var(--sans);color:var(--bone)}.onbbox p{margin:0 0 10px;font-size:13.5px;line-height:1.5;color:var(--faint)}
921
+ .onbbox code{font-size:12.5px;padding:1px 6px;border-radius:6px;background:rgba(255,255,255,.08)}
922
+ .onbrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.onbrow input,.onbrow select{flex:1;min-width:160px}
923
+ .onbnav{display:flex;align-items:center;gap:10px;margin-top:22px}.onbnav .sp{flex:1}
924
+ .onbnav .back{background:none;border:0;color:var(--faint);font:500 13.5px var(--sans);cursor:pointer;padding:6px 2px}
925
+ .onbrepos{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 4px}.onbrepos span{padding:5px 10px;border-radius:999px;font-size:12.5px;background:rgba(255,255,255,.06);color:var(--text)}
926
+ .onbspin{display:inline-block;width:12px;height:12px;margin-right:8px;border-radius:50%;border:2px solid rgba(242,165,65,.3);border-top-color:var(--amber);animation:onbspin 1s linear infinite;vertical-align:-1px}
927
+ @keyframes onbspin{to{transform:rotate(360deg)}}
928
+ .onbgrp{margin:16px 0 6px;font:600 11px var(--sans);letter-spacing:.12em;text-transform:uppercase;color:var(--faint)}
929
+ .onbapp{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:14px;background:rgba(255,255,255,.03);margin-bottom:6px}
930
+ .onbapp .nm{flex:1;font-size:14px;color:var(--bone)}.onbapp .st{font-size:12.5px;color:var(--faint)}.onbapp .st.ok{color:#4FBE8C}.onbapp .st.wait{color:var(--amber)}
931
+ .onbapp button{padding:5px 12px;font-size:12.5px}
932
+ .onborb{display:block;width:72px;height:72px;margin:0 0 14px;border-radius:18px}
933
+ .onbfoot{position:sticky;bottom:-26px;margin:12px -32px -26px;padding:12px 32px 22px;background:#15171B;border-top:1px solid rgba(255,255,255,.06)}.onbfoot .onbnav{margin-top:6px}.onbfoot .onbnote{margin:0}
934
+ body.lq-light .onbfoot{background:#FFFFFF;border-top-color:rgba(21,26,33,.07)}
935
+ .onbtour{margin:6px 0 0;padding:0 0 0 18px;font-size:14px;line-height:1.7;color:var(--text)}
936
+ body.lq-light .onb{background:rgba(236,239,244,.7)}
937
+ body.lq-light .onbin{background:rgba(255,255,255,.92);border-color:rgba(21,26,33,.08);box-shadow:0 30px 80px -30px rgba(40,52,74,.45)}
938
+ body.lq-light .onbcard,body.lq-light .onbbox,body.lq-light .onbapp{background:rgba(21,26,33,.035);border-color:rgba(21,26,33,.07)}
939
+ body.lq-light .onbdots i{background:rgba(21,26,33,.1)}body.lq-light .onbdots i.on{background:var(--amber)}body.lq-light .onbdots i.past{background:rgba(242,165,65,.5)}body.lq-light .onbrepos span{background:rgba(21,26,33,.06)}body.lq-light .onbbox code{background:rgba(21,26,33,.07)}
940
+ @media(max-width:640px){.onbcards{grid-template-columns:1fr}.onbin{padding:22px 18px}}
905
941
  /* the Dashboard as a stream: time runs left to right, a current per feed, now on the right */
906
942
  .bstream{position:relative;margin-top:10px;border-radius:18px;overflow:hidden;isolation:isolate;background:#08090B;box-shadow:0 0 0 1px rgba(255,255,255,.08)}
907
943
  body.lq-light .bstream{background:#EBEEF0;box-shadow:0 0 0 1px rgba(21,26,33,.08)}
@@ -955,6 +991,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
955
991
  @media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
956
992
  @media(max-width:600px){#screenname,#pagesite{flex:1 1 100%!important}.tabs{overflow-x:auto;scrollbar-width:none;padding:0 8px;gap:0}.tabs::-webkit-scrollbar{display:none}.tab{flex:none;padding:9px 11px}header{padding:12px 14px 8px}main{padding:12px}}
957
993
  </style></head><body>
994
+ <div id="onb" class="onb hidden" role="dialog" aria-modal="true" aria-labelledby="onbt"><div class="onbin"><div class="onbdots" id="onbdots"></div><div id="onbbody"></div></div></div>
958
995
  <div id="liquid" aria-label="Symbiot">
959
996
  <canvas id="lq" aria-hidden="true"></canvas>
960
997
  <div id="lqorbits"></div>
@@ -1076,6 +1113,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
1076
1113
  </section>
1077
1114
  <section id="panel-settings" class="hidden">
1078
1115
  <div id="firststeps" class="sset hidden" aria-live="polite"></div>
1116
+ <div class="sset"><h3 class="ssh">Setup</h3><div class="row"><span class="muted" style="flex:1">Walk through setup again: your AI, your work, your agent and your apps.</span><button class="ghost" id="rerunsetup">Run setup again</button></div></div>
1079
1117
  <div class="sset"><h3 class="ssh">Your AI</h3>
1080
1118
  <label>Which AI should Symbiot write with?</label>
1081
1119
  <select id="provider">
@@ -2571,7 +2609,7 @@ var LQNAMES={board:'Dashboard',map:'Map',tasks:'Workdesk',agents:'Workdesk',week
2571
2609
  var LQ_REST=60000,LQ_MAX=11;
2572
2610
  function lqMM(q){try{return !!(window.matchMedia&&window.matchMedia(q).matches);}catch(e){return false;}}
2573
2611
  function lqLookGet(){var l='';try{l=window.localStorage&&window.localStorage.getItem('symbiot-look')||'';}catch(e){}return l==='glass'||l==='pearl'?l:'ferro';}
2574
- function lqLook(l){try{if(window.localStorage)window.localStorage.setItem('symbiot-look',l);}catch(e){}lqTheme();var g=$('lqlook');if(g&&g.querySelectorAll)g.querySelectorAll('button').forEach(function(b){b.setAttribute('aria-checked',b.getAttribute('data-look')===l?'true':'false');});LQ.ripple=[0.5,0.47,LQ.t||0];}
2612
+ function lqLook(l){try{if(window.localStorage)window.localStorage.setItem('symbiot-look',l);}catch(e){}lqTheme();lqIcon(l);api('/api/look',{look:l}).catch(function(){});var g=$('lqlook');if(g&&g.querySelectorAll)g.querySelectorAll('button').forEach(function(b){b.setAttribute('aria-checked',b.getAttribute('data-look')===l?'true':'false');});LQ.ripple=[0.5,0.47,LQ.t||0];}
2575
2613
  function lqTheme(){var look=lqLookGet();var th={look:look,light:look!=='ferro',contrast:lqMM('(prefers-contrast: more)'),forced:lqMM('(forced-colors: active)'),solid:lqMM('(prefers-reduced-transparency: reduce)'),still:lqMM('(prefers-reduced-motion: reduce)'),wide:lqMM('(color-gamut: p3)'),night:false,accent:null};
2576
2614
  var h=new Date().getHours();th.night=h>=22||h<6;
2577
2615
  try{if(document.createElement&&typeof getComputedStyle==='function'&&document.body&&document.body.appendChild){var a=document.createElement('span'),b=document.createElement('span');a.style.color='AccentColor';b.style.color='CanvasText';document.body.appendChild(a);document.body.appendChild(b);
@@ -2681,7 +2719,7 @@ list=list.concat(need.items);var old={};LQ.drops.forEach(function(d){old[d.id]=d
2681
2719
  // a new blob grows where it belongs, rather than sweeping out of the core across the rest
2682
2720
  LQ.drops=list.map(function(d){var o=old[d.id];d.x=o?o.x:d.blob?d.tx:cx;d.y=o?o.y:d.blob?d.ty:cy;d.vx=o?o.vx:0;d.vy=o?o.vy:0;d.cr=o?o.cr:0;return d;});
2683
2721
  var n=(h.you||[]).length,w=h.working||0;
2684
- LQ.coreText=n?(n+(n>1?' things need':' thing needs')+' only you'+(w?' · '+w+' agent'+(w>1?'s':'')+' working':'')):(w?w+' agent'+(w>1?'s':'')+' working · nothing needs you':'All handled');
2722
+ LQ.coreText=n?(n+(n>1?' things need':' thing needs')+' only you'+(w?' · '+w+' agent'+(w>1?'s':'')+' working':'')):(w?w+' agent'+(w>1?'s':'')+' working · nothing needs you':'Nothing needs you right now');
2685
2723
  var b=document.body;if(b&&b.classList){b.classList.toggle('lq-touch',!!(a.modes&&a.modes.touch)||LQ.touch);b.classList.toggle('lq-keys',!!(a.modes&&a.modes.keyboard));}
2686
2724
  LQ.talkWeight=(a.modes&&a.modes.talkWeight)||0.35;
2687
2725
  lqLabels();}
@@ -3038,7 +3076,80 @@ awTick();awStatus();setInterval(awTick,5000);setInterval(awStatus,15000);
3038
3076
  var bye=function(ev){if(Date.now()-AWAY.shown<1500)return;if(ev&&ev.type==='keydown'&&['Meta','OS','Super','Shift','Control','Alt','Hyper'].indexOf(ev.key)>=0)return;api('/api/away',{open:false}).catch(function(){});};
3039
3077
  ['keydown','mousedown','touchstart','wheel'].forEach(function(t){document.addEventListener(t,bye,true);});
3040
3078
  var raf=window.requestAnimationFrame&&function(f){return window.requestAnimationFrame(f);};if(raf){var loop=function(){awPlace();raf(loop);};raf(loop);}}
3041
- function lqInit(){var bd=document.body;if(!bd||!bd.classList)return;lqTheme();bd.classList.add('lq-liquid');
3079
+
3080
+ // ---- Setup: the first run --------------------------------------------------------
3081
+ // A new Symbiot shows this before anything else (home.mjs onboarding): what it is, your
3082
+ // AI, where your work is, your agent, every app you use (connected, or "I don't use
3083
+ // it"), your documents, then Home. Where you are is kept, so closing it resumes there.
3084
+ var ONB=null,ONB_T=null,ONB_NAMES={welcome:'Welcome',ai:'Your AI',work:'Your work',agent:'Your agent',apps:'Your apps',docs:'Documents',done:'Done'};
3085
+ function onbLoad(fresh){return api('/api/onboarding'+(fresh?'?fresh=1':'')).then(function(o){ONB=o;var el=$('onb');if(!el)return;if(!o||!o.pending){el.classList.add('hidden');document.body.classList.remove('lq-onb');if(ONB_T){clearInterval(ONB_T);ONB_T=null;}return;}
3086
+ el.classList.remove('hidden');document.body.classList.add('lq-onb');onbRender();
3087
+ if(!ONB_T)ONB_T=setInterval(function(){if(ONB&&ONB.pending&&['ai','work','agent','apps'].indexOf(ONB.step)>=0&&!onbBusy())onbLoad(ONB.step==='ai');},2500);}).catch(function(){});}
3088
+ // not while you're typing in it
3089
+ function onbBusy(){var a=document.activeElement;return !!(a&&$('onb').contains(a)&&(a.tagName==='INPUT'||a.tagName==='SELECT'));}
3090
+ function onbGo(step){api('/api/onboarding/set',{step:step}).then(function(o){ONB=o;onbRender();var b=$('onbbody');if(b&&b.parentNode)b.parentNode.scrollTop=0;});}
3091
+ function onbNav(prev,next,label,ok){return "<div class='onbnav'>"+(prev?"<button class='back' data-go='"+prev+"'>Back</button>":"")+"<span class='sp'></span>"+(next?"<button class='act' data-go='"+next+"'"+(ok===false?' disabled':'')+">"+esc(label||'Continue')+"</button>":"")+"</div>";}
3092
+ function onbRender(){var o=ONB,b=$('onbbody'),d=$('onbdots');if(!o||!b)return;var st=o.steps||[],k=st.indexOf(o.step);
3093
+ d.innerHTML=st.map(function(x,i){return "<i class='"+(i===k?'on':i<k?'past':'')+"' title='"+esc(ONB_NAMES[x]||x)+"'></i>";}).join('');
3094
+ var h='';
3095
+ if(o.step==='welcome'){
3096
+ h="<img class='onborb' src='/favicon.svg?look="+encodeURIComponent(lqLookGet()||'ferro')+"' alt=''><h2 id='onbt'>Meet Symbiot</h2><p class='onbl'>It works alongside you, so your time goes on what only you can do.</p><div class='onbcards'>"+
3097
+ [['It watches','What you work on and what comes in: your projects, your mail, chats and code.'],['It works','It hands tasks to your coding agent, which does them on its own and tells you what it did.'],['It asks','Only what only you can do: a sign-in, a decision, your OK before anything goes out.']].map(function(c){return "<div class='onbcard'><b>"+c[0]+"</b><span>"+c[1]+"</span></div>";}).join('')+
3098
+ "</div><p class='onbnote'>Setup takes a few minutes: your AI, your work, your agent and every app you use. Symbiot starts finding your projects meanwhile.</p>"+onbNav('', 'ai', 'Set it up');}
3099
+ else if(o.step==='ai'){var a=o.ai||{},c=a.claude||{};
3100
+ h="<h2 id='onbt'>Your AI</h2><p class='onbl'>Symbiot thinks with an AI: it reads what comes in, talks with you and writes for you.</p>";
3101
+ if(a.connected)h+="<div class='onbok'><i>✓</i><div>Symbiot is using <b>"+esc(a.line)+"</b>"+(a.provider==='claude'?". It’s the Claude account you’re signed in with in Claude Code: no key, nothing extra to pay.":".")+"</div></div>";
3102
+ else h+="<div class='onbbox'><h3>Your Claude subscription (recommended)</h3><p>"+(c.installed?"Claude Code is on this computer but not signed in. Open a terminal, run <code>claude</code> once and sign in with your Claude account.":"Install Claude Code (<code>npm install -g @anthropic-ai/claude-code</code>), run <code>claude</code> once and sign in with your Claude account.")+" No key, and nothing extra to pay.</p><button class='ghost' id='onbrecheck'>I’ve signed in: check again</button></div>"+
3103
+ "<div class='onbbox'><h3>Or an API key, or a free local model</h3><div class='onbrow'><select id='onbprov'><option value='anthropic'>Claude (Anthropic) API key</option><option value='openai'>OpenAI API key</option><option value='gemini'>Gemini API key</option><option value='ollama'>Local model (Ollama), no key</option></select><input id='onbkey' type='password' placeholder='paste your key'><button class='ghost' id='onbconnect'>Connect</button></div><p id='onbaimsg' style='margin:8px 0 0'></p></div>";
3104
+ h+=onbNav('welcome','work','Continue',!!a.connected);}
3105
+ else if(o.step==='work'){var w=o.work||{};
3106
+ h="<h2 id='onbt'>Your work</h2><p class='onbl'>Symbiot finds the projects on this computer, so it knows what you’re working on and where to send each task.</p>";
3107
+ h+=w.searching?"<p><span class='onbspin'></span>Looking through your folders"+(w.total?": "+w.done+" of "+w.total+" checked":"")+"…</p>":"<p>"+(w.count?"Found <b>"+w.count+"</b> project"+(w.count>1?'s':'')+".":"No projects found yet. Add the folder they’re in.")+"</p>";
3108
+ if(w.repos&&w.repos.length)h+="<div class='onbrepos'>"+w.repos.slice(0,40).map(function(r){return "<span title='"+esc(r.path)+"'>"+esc(r.name)+"</span>";}).join('')+(w.count>40?"<span>and "+(w.count-40)+" more</span>":"")+"</div>";
3109
+ h+="<div class='onbbox' style='margin-top:14px'><p>Looking in: "+((w.roots||[]).map(function(r){return "<code>"+esc(r)+"</code>";}).join(' ')||'your home folder')+"</p><div class='onbrow'><input id='onbroot' placeholder='Add a folder, e.g. ~/work'><button class='ghost' id='onbaddroot'>Add</button></div></div>";
3110
+ h+=onbNav('ai','agent');}
3111
+ else if(o.step==='agent'){var g=o.agent||{},cur=(g.agents||[]).find(function(x){return x.tmpl===g.cmd;}),pk=g.pick;
3112
+ h="<h2 id='onbt'>Your agent</h2><p class='onbl'>Your coding agent takes the tasks Symbiot hands it and does them on its own, inside that project. Symbiot stops it from pushing to main, publishing, deleting outside the project or touching your keys.</p>";
3113
+ if(g.cmd)h+="<div class='onbok'><i>✓</i><div>Tasks go to <b>"+esc(cur?cur.label:'your agent')+"</b>.</div></div>";
3114
+ else if(pk)h+="<div class='onbbox'><h3>"+esc(pk.name)+" is on this computer</h3><p>"+esc(pk.label)+"</p><button class='act' id='onbuse' data-cmd='"+esc(pk.tmpl)+"'>Use "+esc(pk.name)+"</button></div>";
3115
+ else h+="<div class='onbbox'><h3>No coding agent found</h3><p>Install Claude Code (<code>npm install -g @anthropic-ai/claude-code</code>) to let Symbiot hand work over. You can carry on and add it later in Settings.</p></div>";
3116
+ h+=onbNav('work','apps');}
3117
+ else if(o.step==='apps'){var ap=o.apps||[],left=ap.filter(function(x){return !x.skipped&&x.state==='off';}).length;
3118
+ h="<h2 id='onbt'>Your apps</h2><p class='onbl'>Connect every app you use, so Symbiot sees what comes in. Each opens in a window to sign in; Symbiot keeps the sign-in on this computer. Mark the ones you don’t use.</p>";
3119
+ (o.groups||[]).forEach(function(gp){var rows=ap.filter(function(x){return x.group===gp;});if(!rows.length)return;h+="<div class='onbgrp'>"+esc(gp)+"</div>";
3120
+ rows.forEach(function(x){var stt=x.skipped?"<span class='st'>Not used</span><button class='ghost' data-unskip='"+esc(x.id)+"'>Undo</button>":
3121
+ x.state==='off'?"<button class='act' data-link='"+esc(x.id)+"'>Connect</button><button class='ghost' data-skip='"+esc(x.id)+"'>I don’t use it</button>":
3122
+ x.state==='ok'?"<span class='st ok'>✓ Connected"+(x.agents?" · your agents can use it too":"")+"</span>":
3123
+ "<span class='st wait'>"+(x.state==='signedout'?'Signed out':'Sign in in the window that opened')+"</span><button class='ghost' data-check='"+esc(x.id)+"'>Check</button><button class='ghost' data-link='"+esc(x.id)+"'>Open again</button>";
3124
+ h+="<div class='onbapp'><span class='nm'>"+esc(x.name)+"</span>"+stt+"</div>";});});
3125
+ h+="<div class='onbfoot'><p class='onbnote'>"+(left?left+" left to decide. ":"All decided. ")+(left?"<button class='ghost' id='onbskiprest'>I don’t use the rest</button>":"")+"</p>"+onbNav('agent','docs','Continue',!!o.decided)+"</div>";}
3126
+ else if(o.step==='docs'){var fs=(o.docs||{}).folders||[];
3127
+ h="<h2 id='onbt'>Your documents</h2><p class='onbl'>Optional. A folder of your company’s documents lets Symbiot quote them when you ask, and spot where they disagree (a deadline on someone’s leave, a price told two ways).</p>";
3128
+ if(fs.length)h+="<div class='onbok'><i>✓</i><div>"+fs.map(function(f){return "<code>"+esc(f)+"</code>";}).join(' ')+"</div></div>";
3129
+ h+="<div class='onbbox'><div class='onbrow'><input id='onbdoc' placeholder='A folder, e.g. ~/Company'><button class='ghost' id='onbadddoc'>Add</button></div><p id='onbdocmsg' style='margin:8px 0 0'></p></div>";
3130
+ h+=onbNav('apps','done',fs.length?'Continue':'Skip for now');}
3131
+ else{var conn=(o.apps||[]).filter(function(x){return x.state!=='off'&&!x.skipped;}).length;
3132
+ h="<h2 id='onbt'>You’re set up</h2><p class='onbl'>Here’s what Symbiot has to work with. You can change any of it in Settings.</p><div class='onbbox'><p>"+
3133
+ [(o.ai&&o.ai.connected?'✓ ':'· ')+'AI: '+esc((o.ai&&o.ai.line)||'not connected'),(((o.work||{}).count)?'✓ ':'· ')+((o.work||{}).count||0)+' project'+(((o.work||{}).count)===1?'':'s'),(o.agent&&o.agent.cmd?'✓ ':'· ')+'Agent: '+(o.agent&&o.agent.cmd?'ready':'none yet'),(conn?'✓ ':'· ')+(conn?conn+' app'+(conn===1?'':'s')+' connected':'no apps connected yet')].join('<br>')+
3134
+ "</p></div><h3 style='margin:18px 0 4px;font:600 15px var(--sans);color:var(--bone)'>Finding your way</h3><ul class='onbtour'><li>Each orb is a place: tap it to open it, right-click to go back.</li><li>Amber means something needs you.</li><li>Talk to Symbiot at the bottom: ask anything, or tell it what to do.</li></ul>"+
3135
+ "<div class='onbnav'><button class='back' data-go='docs'>Back</button><span class='sp'></span><button class='act' id='onbfinish'>Open Symbiot</button></div>";}
3136
+ b.innerHTML=h;
3137
+ b.querySelectorAll('[data-go]').forEach(function(x){x.addEventListener('click',function(){onbGo(x.getAttribute('data-go'));});});
3138
+ var on=function(id,f){var x=$(id);if(x)x.addEventListener('click',f);};
3139
+ on('onbrecheck',function(){onbLoad(true);});
3140
+ on('onbconnect',function(){var pv=$('onbprov').value,m=$('onbaimsg');m.textContent='Checking…';api('/api/connect',{provider:pv,key:$('onbkey').value.trim(),model:''}).then(function(r){m.textContent=r.message||'';onbLoad(true);});});
3141
+ var pvs=$('onbprov');if(pvs)pvs.addEventListener('change',function(){$('onbkey').style.display=pvs.value==='ollama'?'none':'';});
3142
+ on('onbaddroot',function(){var v=$('onbroot').value.trim();if(!v)return;api('/api/scanroots/add',{path:v}).then(function(){onbLoad();});});
3143
+ on('onbuse',function(){var c=$('onbuse').getAttribute('data-cmd');api('/api/agentcmd',{cmd:c}).then(function(){onbLoad();});});
3144
+ on('onbskiprest',function(){api('/api/onboarding/set',{skipRest:true}).then(function(r){ONB=r;onbRender();});});
3145
+ on('onbadddoc',function(){var v=$('onbdoc').value.trim();if(!v)return;api('/api/knowledge/add',{path:v}).then(function(r){$('onbdocmsg').textContent=r&&r.error?r.error:'';onbLoad();});});
3146
+ on('onbfinish',function(){api('/api/onboarding/done',{}).then(function(){onbLoad();LQ.fresh=true;lqLoad(true);});});
3147
+ b.querySelectorAll('[data-link]').forEach(function(x){x.addEventListener('click',function(){x.disabled=true;api('/api/links/link',{id:x.getAttribute('data-link')}).then(function(){onbLoad();});});});
3148
+ b.querySelectorAll('[data-check]').forEach(function(x){x.addEventListener('click',function(){x.disabled=true;api('/api/links/check',{id:x.getAttribute('data-check')}).then(function(){onbLoad();});});});
3149
+ b.querySelectorAll('[data-skip]').forEach(function(x){x.addEventListener('click',function(){api('/api/onboarding/set',{skip:x.getAttribute('data-skip')}).then(function(r){ONB=r;onbRender();});});});
3150
+ b.querySelectorAll('[data-unskip]').forEach(function(x){x.addEventListener('click',function(){api('/api/onboarding/set',{unskip:x.getAttribute('data-unskip')}).then(function(r){ONB=r;onbRender();});});});}
3151
+ function lqIcon(l){var k=document.querySelector('link[rel=icon]');if(k)k.setAttribute('href','/favicon.svg?look='+encodeURIComponent(l||'ferro'));}
3152
+ function lqInit(){var bd=document.body;if(!bd||!bd.classList)return;lqIcon(lqLookGet());lqTheme();bd.classList.add('lq-liquid');
3042
3153
  ['(prefers-color-scheme: light)','(prefers-contrast: more)','(forced-colors: active)','(prefers-reduced-transparency: reduce)','(prefers-reduced-motion: reduce)'].forEach(function(q){try{var mq=window.matchMedia&&window.matchMedia(q);if(mq&&mq.addEventListener)mq.addEventListener('change',lqTheme);else if(mq&&mq.addListener)mq.addListener(lqTheme);}catch(e){}});
3043
3154
  if(document.addEventListener){document.addEventListener('contextmenu',function(ev){var b=document.body,t=ev&&ev.target;if(!b||!b.classList||!b.classList.contains('lq-liquid'))return;if(t&&t.closest&&t.closest('input,textarea,select,[contenteditable]'))return;var sel=window.getSelection?String(window.getSelection()):'';if(sel)return;ev.preventDefault();lqBack();});
3044
3155
  document.addEventListener('mouseup',function(ev){var b=document.body;if(ev&&ev.button===3&&b&&b.classList&&b.classList.contains('lq-liquid')){ev.preventDefault();lqBack();}});}
@@ -3067,5 +3178,5 @@ var G=lqGL();if(G){try{lqStep();lqDraw(G);}catch(e){}}if(typeof window.requestAn
3067
3178
  function frame(){if(!document.hidden){LQ.frame++;if(LQ.mode!=='pool'||LQ.frame%3===0)lqStep();var every=(GLSOFT||GLSTILL)?glEvery(LQ.mode==='aware'):LQ.mode==='pool'?6:LQ.mode==='rest'?2:1;if(G&&LQ.frame%every===0)lqDraw(G);}raf(frame);} // moves every frame; drawn less at rest, behind a panel, without a GPU
3068
3179
  raf(frame);}
3069
3180
 
3070
- initGraphEvents();syncP();refresh();loadMap();firstTab();lqInit();awayInit();lqDeep();loadWhatsNew();loadAgentCfg();loadScanRoots();loadKnowledge();loadPhone();loadLinks();loadMind();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(function(){whenFree(document.querySelector('main'),'watch',loadWatchUI);},60000);loadPhoneLink();
3181
+ initGraphEvents();syncP();refresh();firstTab();lqInit();onbLoad();var rr=$('rerunsetup');if(rr)rr.addEventListener('click',function(){api('/api/onboarding/restart',{}).then(function(){lqSink();onbLoad();});});awayInit();lqDeep();loadWhatsNew();loadAgentCfg();loadScanRoots();loadKnowledge();loadPhone();loadLinks();loadMind();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(function(){whenFree(document.querySelector('main'),'watch',loadWatchUI);},60000);loadPhoneLink();
3071
3182
  </script></body></html>`;