symbiot 0.55.3 → 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,20 @@
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
+
13
+ ## 0.56.0
14
+
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).
16
+ - **`symbiot uninstall`** removes Symbiot and everything it added: the menu entry and icon, start at login, the Away shortcut, and its data (`--keep-data` keeps your settings, tasks and memory). Claude Code and its sign-in stay.
17
+ - `symbiot open` does what the icon does, from a terminal.
18
+
5
19
  ## 0.55.3
6
20
 
7
21
  - **Your Claude subscription, no API key.** With Claude Code signed in to your Claude account (Pro or Max), Symbiot now answers through it: chats, write-ups, posts, screenshots. Nothing to connect, no separate bill. It comes first when you haven't picked an AI, ahead of an old saved key. `symbiot login` and Settings → Your AI offer it first; if Claude Code isn't signed in yet, they tell you to run `claude` once and sign in. Before, a fresh install sent you to `symbiot login`, which only asked for an API key.
package/README.md CHANGED
@@ -43,6 +43,19 @@ 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, 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.
55
+
56
+ To remove it and everything it added (menu entry, icon, start at login, shortcut,
57
+ its data): `symbiot uninstall` (`--keep-data` keeps your settings, tasks and memory).
58
+
46
59
  ## Prefer a window? `symbiot app`
47
60
 
48
61
  ```bash
package/away.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  // Any key or click, or Super+` again, closes every window. Not a lock: Super+Esc
15
15
  // locks the computer.
16
16
  import { spawn, spawnSync } from "node:child_process";
17
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
17
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { homedir } from "node:os";
20
20
  import { CONFIG_DIR, chromeBinary } from "./core.mjs";
@@ -110,4 +110,14 @@ function installShortcut({ cmd, home = homedir(), desktop = process.env.XDG_CURR
110
110
  return { ok: true, file: f, replaced: /key:\s*"grave"/.test(had) };
111
111
  }
112
112
 
113
- export { placeable, grantProfile, parseMonitors, screens, awayQueries, openAway, closeAway, awayOpen, toggleAway, withShortcut, installShortcut, COSMIC_CUSTOM };
113
+ // Uninstalling: our Super+` line comes out of COSMIC's custom shortcuts; the file
114
+ // goes if nothing else was in it. True when there was one.
115
+ function removeShortcut(home = homedir()) {
116
+ const f = COSMIC_CUSTOM(home); let had = ""; try { had = readFileSync(f, "utf8"); } catch { return false; }
117
+ const lines = had.split("\n"), kept = lines.filter((l) => !/Spawn\("[^"]*symbiot[^"]* away"\)/.test(l));
118
+ if (kept.length === lines.length) return false;
119
+ const rest = kept.join("\n");
120
+ try { if (!rest.replace(/[{}\s]/g, "")) unlinkSync(f); else writeFileSync(f, rest); } catch {}
121
+ return true;
122
+ }
123
+ export { removeShortcut, placeable, grantProfile, parseMonitors, screens, awayQueries, openAway, closeAway, awayOpen, toggleAway, withShortcut, installShortcut, COSMIC_CUSTOM };
package/desktop.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // start-at-login launches that server in the background (no window) when you
5
5
  // log in, so the write-up still happens on days you never open Symbiot.
6
6
  // `symbiot app` then finds that running copy and opens its window.
7
+ import { fileURLToPath } from "node:url";
7
8
  import { spawn } from "node:child_process";
8
9
  import { homedir } from "node:os";
9
10
  import { join, dirname } from "node:path";
@@ -144,6 +145,72 @@ function autostartContent(node, script, platform = OS, path = process.env.PATH |
144
145
  return ["[Desktop Entry]", "Type=Application", "Name=Symbiot", "Comment=Symbiot in the background: the weekly write-up and its notification",
145
146
  `Exec=env SYMBIOT_NO_OPEN=1 ${q("PATH=" + path)} ${q(node)} ${q(script)} app`, "Terminal=false", "NoDisplay=true", "X-GNOME-Autostart-enabled=true", ""].join("\n");
146
147
  }
148
+ // ---- the app launcher ---------------------------------------------------------
149
+ // Symbiot in your app menu, with its icon: click it and Symbiot starts in the
150
+ // background (no terminal) and opens its window, or brings the window up if it's
151
+ // running (`symbiot open`). Linux (XDG): an entry in ~/.local/share/applications and
152
+ // the icon in your icon theme. `npm install -g` writes it (postinstall.mjs), and each
153
+ // start of an installed Symbiot writes it again if it's missing or out of date (a
154
+ // new Node, a moved install), so it never points at something gone.
155
+ const launcherFile = (home = homedir()) => join(home, ".local", "share", "applications", "symbiot.desktop");
156
+ const launcherIcon = (home = homedir()) => join(home, ".local", "share", "icons", "hicolor", "scalable", "apps", "symbiot.svg");
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
+ }
178
+ function launcherContent(node, script, path = process.env.PATH || "") {
179
+ const q = (s) => '"' + String(s).replace(/(["`$\\])/g, "\\$1") + '"';
180
+ return ["[Desktop Entry]", "Type=Application", "Name=Symbiot", "GenericName=Assistant",
181
+ "Comment=Your work, your agents and what needs you, in one place",
182
+ `Exec=env ${q("PATH=" + path)} ${q(node)} ${q(script)} open`, "Icon=symbiot", "Terminal=false",
183
+ "Categories=Office;Utility;Development;", "Keywords=assistant;agents;tasks;week;standup;", "StartupNotify=true",
184
+ // the app's window is Chrome's --app window on Symbiot's address: this groups it under this icon
185
+ "StartupWMClass=chrome-127.0.0.1__-Default", ""].join("\n");
186
+ }
187
+ // The PATH the launcher starts it with: yours, without the throwaway folders npm adds
188
+ // while it installs (…/node_modules/.bin), each once, and Node's own folder in it.
189
+ function launcherPath(path, node) {
190
+ const seen = new Set(), out = [];
191
+ for (const d of [...String(path || "").split(":"), dirname(node)]) if (d && !/node_modules[\\/]\.bin|node-gyp-bin/.test(d) && !seen.has(d)) { seen.add(d); out.push(d); }
192
+ return out.join(":");
193
+ }
194
+ // Writes the entry and icon when they're missing or differ. { written, file } or { skipped }.
195
+ function installLauncher({ node = process.execPath, script, home = homedir(), platform = OS, path = process.env.PATH || "" } = {}) {
196
+ if (platform !== "linux" || !script) return { skipped: true };
197
+ const file = launcherFile(home), icon = launcherIcon(home), want = launcherContent(node, script, launcherPath(path, node));
198
+ let wrote = false;
199
+ try {
200
+ let had = ""; try { had = readFileSync(file, "utf8"); } catch {}
201
+ if (had !== want) { mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, want, { mode: 0o644 }); wrote = true; }
202
+ let hadIcon = ""; try { hadIcon = readFileSync(icon, "utf8"); } catch {}
203
+ const svg = iconSvg(loadConfig().look || "ferro");
204
+ if (hadIcon !== svg) { mkdirSync(join(icon, ".."), { recursive: true }); writeFileSync(icon, svg, { mode: 0o644 }); wrote = true; }
205
+ } catch (e) { return { error: (e && e.message) || String(e) }; }
206
+ if (wrote) { try { spawn("update-desktop-database", [join(file, "..")], { stdio: "ignore", detached: true }).on("error", () => {}).unref(); } catch {} }
207
+ return { written: wrote, file };
208
+ }
209
+ function removeLauncher(home = homedir()) {
210
+ const gone = [];
211
+ for (const f of [launcherFile(home), launcherIcon(home)]) { try { if (existsSync(f)) { unlinkSync(f); gone.push(f); } } catch {} }
212
+ return gone;
213
+ }
147
214
  function autostartState() { const file = autostartFile(); return { on: existsSync(file), file, ...(OS === "android-app" ? { phone: "app" } : OS === "android" ? { phone: "termux" } : {}) }; }
148
215
  // `script` is index.mjs's real path. From npx that's a cache folder that goes
149
216
  // away, so it has to be installed for this to keep working.
@@ -159,4 +226,4 @@ function setAutostart(on, script) {
159
226
  } catch (e) { return { ...autostartState(), error: String((e && e.message) || e) }; }
160
227
  }
161
228
 
162
- export { 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/icon.svg ADDED
@@ -0,0 +1,33 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
2
+ <title>Symbiot</title>
3
+ <defs>
4
+ <radialGradient id="bg" cx="50%" cy="42%" r="70%">
5
+ <stop offset="0" stop-color="#1B1D22"/>
6
+ <stop offset="1" stop-color="#08090B"/>
7
+ </radialGradient>
8
+ <radialGradient id="orb" cx="40%" cy="34%" r="66%">
9
+ <stop offset="0" stop-color="#3A3D44"/>
10
+ <stop offset="0.45" stop-color="#121317"/>
11
+ <stop offset="1" stop-color="#030304"/>
12
+ </radialGradient>
13
+ <radialGradient id="rim" cx="50%" cy="50%" r="50%">
14
+ <stop offset="0.86" stop-color="#000" stop-opacity="0"/>
15
+ <stop offset="0.97" stop-color="#B0466E" stop-opacity="0.55"/>
16
+ <stop offset="1" stop-color="#F2A541" stop-opacity="0"/>
17
+ </radialGradient>
18
+ <radialGradient id="glow" cx="50%" cy="56%" r="50%">
19
+ <stop offset="0.55" stop-color="#F2A541" stop-opacity="0.16"/>
20
+ <stop offset="1" stop-color="#F2A541" stop-opacity="0"/>
21
+ </radialGradient>
22
+ <linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
23
+ <stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85"/>
24
+ <stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
25
+ </linearGradient>
26
+ </defs>
27
+ <rect width="256" height="256" rx="58" fill="url(#bg)"/>
28
+ <circle cx="128" cy="136" r="96" fill="url(#glow)"/>
29
+ <circle cx="128" cy="128" r="78" fill="url(#orb)"/>
30
+ <circle cx="128" cy="128" r="78" fill="url(#rim)"/>
31
+ <ellipse cx="102" cy="92" rx="30" ry="17" transform="rotate(-28 102 92)" fill="url(#shine)"/>
32
+ <ellipse cx="160" cy="176" rx="22" ry="5" transform="rotate(-30 160 176)" fill="#FFFFFF" opacity="0.12"/>
33
+ </svg>
package/index.mjs CHANGED
@@ -23,14 +23,14 @@
23
23
  // tasks.mjs (tasks, review and Approve), agents.mjs (handing work to an agent),
24
24
  // drift.mjs, server.mjs (`symbiot app`'s server) and ui.mjs (its page).
25
25
 
26
- import { spawn } from "node:child_process";
26
+ import { spawn, spawnSync } from "node:child_process";
27
27
  import { homedir } from "node:os";
28
28
  import { join, basename } from "node:path";
29
- import { realpathSync, readFileSync } from "node:fs";
29
+ import { realpathSync, readFileSync, mkdirSync, openSync, rmSync, existsSync } from "node:fs";
30
30
  import { createInterface } from "node:readline";
31
31
  import { fileURLToPath } from "node:url";
32
32
  import { EMBEDDED_UI } from "./ui.mjs";
33
- import { VERSION, CONFIG_PATH, loadConfig, saveConfig, repoState, semverGt } from "./core.mjs";
33
+ import { VERSION, CONFIG_PATH, CONFIG_DIR, loadConfig, saveConfig, repoState, semverGt } from "./core.mjs";
34
34
  import { HANDOFF_PROMPT, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, fillHandoff, runHandoff, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, parseQuestions, agentQuestions } from "./agents.mjs";
35
35
  import { AI_UI, PROVIDERS, resolveProvider, validate, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel, claudeState, connectProvider } from "./ai.mjs";
36
36
  import { setScanOptions, scanBase, authorship, readmeInfo, repoShape, houseRules, reportFooter, findAllRepos, buildMap } from "./scan.mjs";
@@ -42,7 +42,7 @@ import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, isTrus
42
42
  import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat } from "./watch.mjs";
43
43
  import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
44
44
  import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, openUrl, addMedia as addPostMedia, removeMedia as removePostMedia, mediaDir as postMediaDir, pictureOfPage, clipOfPage } from "./post.mjs";
45
- import { startApp, updateCmd, isAppRunningWeekly } from "./server.mjs";
45
+ import { startApp, updateCmd, isAppRunningWeekly, askRunningApp, openApp } from "./server.mjs";
46
46
  import { listReports, readReport } from "./reports.mjs";
47
47
  import { runSandbox } from "./sandbox.mjs";
48
48
  import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, searchKnowledge, waitingOn, ownerOf, myName, setMyName, itemLine, caseLine } from "./knowledge.mjs";
@@ -350,6 +350,43 @@ async function cmdAway() {
350
350
  if (!r) { console.log(c.y("Symbiot's app isn't running.") + c.d(" Start it with symbiot app, then Away works.")); process.exitCode = 1; return; }
351
351
  if (r.error) { console.log(c.y(r.error)); process.exitCode = 1; }
352
352
  }
353
+ // symbiot open: what the app-menu icon runs. Running: its window comes up. Not
354
+ // running: it starts in the background (no terminal; what it prints goes to
355
+ // ~/.config/symbiot/app.log) and opens its own window.
356
+ async function cmdOpen() {
357
+ const cfg = loadConfig(), port = Number(process.env.SYMBIOT_PORT || cfg.appPort) || 7391;
358
+ const p = cfg.appToken ? await askRunningApp("ping", { port, token: cfg.appToken, ms: 1500 }) : null;
359
+ if (p && p.version) { openApp(`http://127.0.0.1:${port}/?t=${cfg.appToken}`); return; }
360
+ let fd = "ignore"; try { mkdirSync(CONFIG_DIR, { recursive: true }); fd = openSync(join(CONFIG_DIR, "app.log"), "a"); } catch {}
361
+ const env = { ...process.env }; delete env.SYMBIOT_NO_OPEN;
362
+ spawn(process.execPath, [realpathSync(fileURLToPath(import.meta.url)), "app"], { detached: true, stdio: ["ignore", fd, fd], env }).unref();
363
+ }
364
+ // symbiot uninstall: everything Symbiot put on this computer, then the program.
365
+ // --keep-data keeps ~/.config/symbiot (settings, tasks, memory); --yes doesn't ask.
366
+ async function cmdUninstall() {
367
+ const keep = has("keep-data");
368
+ console.log(c.b("Remove Symbiot from this computer") + "\n");
369
+ console.log(" · its app-menu entry and icon, start at login, the Super+` shortcut");
370
+ console.log(keep ? " · " + c.d("keeps your data in " + CONFIG_DIR) : " · everything it knows: settings, tasks, memory, drafts, screenshots (" + CONFIG_DIR + ")");
371
+ if (!has("keep-program")) console.log(" · the program itself (npm uninstall -g symbiot)");
372
+ console.log(c.d(" Claude Code and its sign-in stay. Folders it made inside your projects (.symbiot/) stay too."));
373
+ if (!has("yes")) {
374
+ if (!process.stdin.isTTY) { console.log("\n" + c.y("Run it again with --yes to go ahead.")); process.exitCode = 1; return; }
375
+ if (((await ask("\nType yes to remove it: ")) || "").trim().toLowerCase() !== "yes") { console.log("Nothing removed."); return; }
376
+ }
377
+ const { removeLauncher, setAutostart } = await import("./desktop.mjs");
378
+ const { removeShortcut } = await import("./away.mjs");
379
+ await askRunningApp("quit", { ms: 2000 });
380
+ try { setAutostart(false); } catch {}
381
+ removeLauncher(); removeShortcut();
382
+ try { rmSync(join(homedir(), ".symbiot"), { recursive: true, force: true }); } catch {}
383
+ if (!keep) { try { rmSync(CONFIG_DIR, { recursive: true, force: true }); } catch {} }
384
+ console.log(c.g("✓ ") + "Removed what Symbiot added" + (keep ? ", your data kept." : ", and its data."));
385
+ if (has("keep-program")) return;
386
+ const r = spawnSync("npm", ["uninstall", "-g", "symbiot"], { stdio: "inherit", shell: process.platform === "win32" });
387
+ console.log(r.status === 0 ? c.g("✓ ") + "Symbiot is uninstalled." : c.y("Couldn't remove the program: run npm uninstall -g symbiot"));
388
+ if (existsSync(CONFIG_DIR) && !keep) console.log(c.d("(Something wrote to " + CONFIG_DIR + " meanwhile; delete it if you like.)"));
389
+ }
353
390
  async function cmdScreens() {
354
391
  const [sub = "list", a1, a2, a3] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--name");
355
392
  const out = (x) => { console.log(JSON.stringify(x, null, 2)); if (x && x.error) process.exitCode = 1; };
@@ -775,6 +812,9 @@ ${c.b("Usage")}
775
812
  symbiot standup yesterday + today, for standup
776
813
  symbiot todo what's still on your plate
777
814
  symbiot app open the visual app in your browser
815
+ symbiot open start Symbiot in the background and open its window
816
+ (what its app-menu icon does)
817
+ symbiot uninstall [--keep-data] remove Symbiot and everything it added
778
818
  symbiot away [--shortcut] Symbiot full screen on every screen while you're away;
779
819
  again closes it. --shortcut: Super+\` does it
780
820
  symbiot app --fresh [--keep] try it as someone new: a brand-new Symbiot in
@@ -833,6 +873,8 @@ async function main() {
833
873
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
834
874
  if (cmd === "app" || cmd === "ui") return cmdApp();
835
875
  if (cmd === "away") return cmdAway();
876
+ if (cmd === "open") return cmdOpen();
877
+ if (cmd === "uninstall") return cmdUninstall();
836
878
  if (cmd === "models" || cmd === "hardware") return cmdModels();
837
879
  if (cmd === "setup-local" || cmd === "setup-ollama") return cmdSetupLocal();
838
880
  if (cmd === "drift") return cmdDrift();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.55.3",
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": {
@@ -44,7 +44,10 @@
44
44
  "mapknn.mjs",
45
45
  "guard.mjs",
46
46
  "away.mjs",
47
- "marketing.mjs"
47
+ "marketing.mjs",
48
+ "postinstall.mjs",
49
+ "icon.svg",
50
+ "scanworker.mjs"
48
51
  ],
49
52
  "engines": {
50
53
  "node": ">=18"
@@ -77,8 +80,9 @@
77
80
  "@anthropic-ai/sdk": "^0.128.0"
78
81
  },
79
82
  "scripts": {
80
- "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/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
81
- "lint": "eslint ."
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",
84
+ "lint": "eslint .",
85
+ "postinstall": "node postinstall.mjs"
82
86
  },
83
87
  "devDependencies": {
84
88
  "eslint": "^9.39.5",
@@ -0,0 +1,19 @@
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), 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";
8
+ import { fileURLToPath } from "node:url";
9
+ const say = (s) => { try { writeFileSync("/dev/tty", s + "\n"); } catch { console.log(s); } };
10
+ try {
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)));
13
+ const { installLauncher } = await import("./desktop.mjs");
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");
18
+ }
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 } 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();
@@ -204,7 +206,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
204
206
  if (p && p.version) {
205
207
  const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url);
206
208
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
207
- console.log(how ? c.d(` Opened the existing window (a ${how}).`) : c.d(" Open that URL in your browser."));
209
+ console.log(how ? c.d(` Opened the existing window (${/^[aeiou]/.test(how) ? "an" : "a"} ${how}).`) : c.d(" Open that URL in your browser."));
208
210
  console.log(c.d(" (Not starting a second copy. Set SYMBIOT_FORCE_NEW=1 to force one.)"));
209
211
  return;
210
212
  }
@@ -214,13 +216,27 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
214
216
  keepBrowserOpen(BROWSER_KEEP);
215
217
  const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
216
218
  const screenOut =(s) => (s && s.id ? { ...s, blueprint: blueprint(s), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}) } : s && s.screens ? { ...s, screens: s.screens.map(screenOut) } : s);
219
+ // an installed Symbiot keeps its app-menu entry pointing at itself (a new Node, a moved
220
+ // install); not a copy run from a checkout or a test
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.`)); }
217
226
  let UPDATING = null; // an update in flight: { target, attempt, retrying? }
218
227
  let NEWEST_WIN = ""; // the Symbiot window opened last: older ones close themselves (one window, not a pile)
219
228
  process.on("exit", () => closeAway()); // Away's windows go with the app (a quit, an update's restart)
220
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`); });
221
231
  const u = new URL(req.url, "http://127.0.0.1");
222
232
  if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
223
233
  // the app's typeface ships in the package (fonts/), so it's there offline
234
+ // the window's own icon (its taskbar entry), the same orb as the app menu's
235
+ if (req.method === "GET" && (u.pathname === "/favicon.svg" || u.pathname === "/favicon.ico")) {
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); }
237
+ catch { res.writeHead(404); res.end(); }
238
+ return;
239
+ }
224
240
  if (req.method === "GET" && u.pathname === "/fonts/Geist-Variable.woff2") {
225
241
  try { const b = readFileSync(fileURLToPath(new URL("./fonts/Geist-Variable.woff2", import.meta.url))); res.writeHead(200, { "content-type": "font/woff2", "cache-control": "public, max-age=31536000, immutable" }); res.end(b); }
226
242
  catch { res.writeHead(404); res.end(); }
@@ -380,6 +396,11 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
380
396
  // the work scene: what agents are doing, what's waiting, what's ready; Go starts what's waiting
381
397
  if (u.pathname === "/api/work") return json(res, workScene());
382
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) }); }
383
404
  if (u.pathname === "/api/firststeps") return json(res, firstSteps()); // Settings' first steps: what's set up, in order
384
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 })); }
385
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)); }
@@ -477,7 +498,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
477
498
  const url = `http://127.0.0.1:${server.address().port}/?t=${TOKEN}`;
478
499
  const how = opened || RELAUNCH || process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url); opened = true; // only pop a window the first time (never in tests, never after an update)
479
500
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} is running at ${c.b(url)}`);
480
- console.log(RELAUNCH ? c.d(" Restarted after an update; the open window reloads itself.") : how ? c.d(` Opened in a ${how}.`) : c.d(" Open that URL in your browser."));
501
+ console.log(RELAUNCH ? c.d(" Restarted after an update; the open window reloads itself.") : how ? c.d(` Opened in ${/^[aeiou]/.test(how) ? "an" : "a"} ${how}.`) : c.d(" Open that URL in your browser."));
481
502
  console.log(c.d(" Leave this running; press Ctrl+C to stop (or click the X in the window's top corner)."));
482
503
  });
483
504
  server.on("error", (e) => {
@@ -508,4 +529,4 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
508
529
  setInterval(() => { try { awaitTick(); } catch {} }, 60000).unref(); // a reply an agent's email waits on: found, and handed on
509
530
  }
510
531
 
511
- export { updateCmd, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly };
532
+ export { updateCmd, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly, openApp };
package/ui.mjs CHANGED
@@ -5,7 +5,8 @@
5
5
  // Kept apart from the CLI/server so UI edits can't break the backend (and vice
6
6
  // versa); test/smoke.mjs boots the real page and exercises every handler.
7
7
  export const EMBEDDED_UI = `<!doctype html><html><head><meta charset="utf8">
8
- <meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" id="themecolor" content="#08090B"><meta name="color-scheme" content="dark light"><title>Symbiot</title>
8
+ <meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" id="themecolor" content="#08090B">
9
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg"><meta name="color-scheme" content="dark light"><title>Symbiot</title>
9
10
  <style>
10
11
  @font-face{font-family:'Geist';src:url('/fonts/Geist-Variable.woff2') format('woff2');font-weight:100 900;font-style:normal;font-display:swap}
11
12
  :root{--ink:#0E1A1F;--ink2:#15262C;--ink3:#1D333A;--line:#24404A;--bone:#F4F1EA;--text:#B7C9C4;--faint:#7E9690;--green:#3DDC97;--amber:#F2A541;--green-dim:#16322D;--sans:'Geist',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
@@ -901,6 +902,42 @@ body.lq-away #lqaway{display:block;position:fixed;left:0;top:0;width:440px;text-
901
902
  #lqaway .awh{margin-top:16px;font-size:11.5px;opacity:.32}
902
903
  body.lq-light #lqaway{color:#151A21;text-shadow:0 1px 12px rgba(255,255,255,.8)}
903
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}}
904
941
  /* the Dashboard as a stream: time runs left to right, a current per feed, now on the right */
905
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)}
906
943
  body.lq-light .bstream{background:#EBEEF0;box-shadow:0 0 0 1px rgba(21,26,33,.08)}
@@ -954,6 +991,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
954
991
  @media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
955
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}}
956
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>
957
995
  <div id="liquid" aria-label="Symbiot">
958
996
  <canvas id="lq" aria-hidden="true"></canvas>
959
997
  <div id="lqorbits"></div>
@@ -1075,6 +1113,7 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
1075
1113
  </section>
1076
1114
  <section id="panel-settings" class="hidden">
1077
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>
1078
1117
  <div class="sset"><h3 class="ssh">Your AI</h3>
1079
1118
  <label>Which AI should Symbiot write with?</label>
1080
1119
  <select id="provider">
@@ -2570,7 +2609,7 @@ var LQNAMES={board:'Dashboard',map:'Map',tasks:'Workdesk',agents:'Workdesk',week
2570
2609
  var LQ_REST=60000,LQ_MAX=11;
2571
2610
  function lqMM(q){try{return !!(window.matchMedia&&window.matchMedia(q).matches);}catch(e){return false;}}
2572
2611
  function lqLookGet(){var l='';try{l=window.localStorage&&window.localStorage.getItem('symbiot-look')||'';}catch(e){}return l==='glass'||l==='pearl'?l:'ferro';}
2573
- 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];}
2574
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};
2575
2614
  var h=new Date().getHours();th.night=h>=22||h<6;
2576
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);
@@ -2680,7 +2719,7 @@ list=list.concat(need.items);var old={};LQ.drops.forEach(function(d){old[d.id]=d
2680
2719
  // a new blob grows where it belongs, rather than sweeping out of the core across the rest
2681
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;});
2682
2721
  var n=(h.you||[]).length,w=h.working||0;
2683
- 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');
2684
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));}
2685
2724
  LQ.talkWeight=(a.modes&&a.modes.talkWeight)||0.35;
2686
2725
  lqLabels();}
@@ -3037,7 +3076,80 @@ awTick();awStatus();setInterval(awTick,5000);setInterval(awStatus,15000);
3037
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(){});};
3038
3077
  ['keydown','mousedown','touchstart','wheel'].forEach(function(t){document.addEventListener(t,bye,true);});
3039
3078
  var raf=window.requestAnimationFrame&&function(f){return window.requestAnimationFrame(f);};if(raf){var loop=function(){awPlace();raf(loop);};raf(loop);}}
3040
- 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');
3041
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){}});
3042
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();});
3043
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();}});}
@@ -3066,5 +3178,5 @@ var G=lqGL();if(G){try{lqStep();lqDraw(G);}catch(e){}}if(typeof window.requestAn
3066
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
3067
3179
  raf(frame);}
3068
3180
 
3069
- 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();
3070
3182
  </script></body></html>`;