symbiot 0.13.0 → 0.20.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.
Files changed (2) hide show
  1. package/index.mjs +306 -42
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -21,7 +21,7 @@ import Anthropic from "@anthropic-ai/sdk";
21
21
  import { execSync, spawn } from "node:child_process";
22
22
  import { homedir, totalmem, cpus as oscpus } from "node:os";
23
23
  import { join } from "node:path";
24
- import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, statSync, realpathSync } from "node:fs";
24
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, statSync, realpathSync, openSync, writeSync } from "node:fs";
25
25
  import { createInterface } from "node:readline";
26
26
  import { createServer } from "node:http";
27
27
  import { randomBytes } from "node:crypto";
@@ -56,6 +56,7 @@ const has = (name) => argv.includes(`--${name}`);
56
56
  const PLAIN = has("plain") || !process.stdout.isTTY;
57
57
  let SERVING = false; // set while `symbiot app` runs — silences the CLI spinner
58
58
  let LAST_MAP = null; // cached graph so node clicks don't rescan
59
+ const HANDOFFS = []; // live registry of agents Symbiot has handed work to
59
60
 
60
61
  // ---- colour + spinner ------------------------------------------------------
61
62
  const c = PLAIN
@@ -103,6 +104,31 @@ function addTask(text, repo) {
103
104
  }
104
105
  function toggleTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.done = !it.done; saveTasks(t); } return it || { error: "not found" }; }
105
106
  function removeTask(id) { saveTasks(loadTasks().filter((x) => x.id !== id)); return { ok: true }; }
107
+ function restoreTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.archived = false; it.done = false; delete it.archivedAt; saveTasks(t); } return it || { error: "not found" }; }
108
+ // Which task texts the agent checked off in a repo's .symbiot/TASKS.md
109
+ function completedInRepo(repoPath) {
110
+ try { return readFileSync(join(repoPath, ".symbiot", "TASKS.md"), "utf8").split("\n").filter((l) => /^\s*-\s*\[x\]/i.test(l)).map((l) => l.replace(/^\s*-\s*\[x\]\s*/i, "").trim().toLowerCase()); }
111
+ catch { return []; }
112
+ }
113
+ function repoPathMap() {
114
+ const byName = {};
115
+ const src = (LAST_MAP && LAST_MAP.nodes) ? LAST_MAP.nodes.filter((n) => n.type === "repo" || n.type === "folder").map((n) => ({ name: n.label, path: n.meta && n.meta.path })) : findAllRepos();
116
+ for (const r of src) if (r.path && !byName[r.name]) byName[r.name] = r.path;
117
+ return byName;
118
+ }
119
+ // "Check what was handed out, see what's completed, then archive it": mark done
120
+ // any task the agent ticked in TASKS.md, then auto-archive every done task.
121
+ function syncTasks() {
122
+ const t = loadTasks(); const map = repoPathMap(); let completed = 0, archived = 0; const checkedByRepo = {};
123
+ for (const x of t) {
124
+ if (x.archived || x.done || !x.repo) continue;
125
+ if (!(x.repo in checkedByRepo)) checkedByRepo[x.repo] = map[x.repo] ? completedInRepo(map[x.repo]) : [];
126
+ if (checkedByRepo[x.repo].includes(x.text.toLowerCase())) { x.done = true; completed++; }
127
+ }
128
+ for (const x of t) { if (x.done && !x.archived) { x.archived = true; x.archivedAt = Date.now(); archived++; } }
129
+ saveTasks(t);
130
+ return { completed, archived };
131
+ }
106
132
  // Deterministic task classification (no model) so tasks batch by kind instead
107
133
  // of arriving as a flat, mixed pile. Order matters (most specific first).
108
134
  const TASK_ORDER = ["Fixes", "Tests & CI", "Security", "Performance", "Refactor", "UI/UX", "Docs", "Features & other"];
@@ -135,13 +161,15 @@ function buildTasksMd(name, ctx, list) {
135
161
  L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Confirm with me before anything destructive."');
136
162
  return L.join("\n") + "\n";
137
163
  }
138
- function pushTasks() {
139
- const tasks = loadTasks().filter((t) => !t.done);
164
+ function pushTasks(filter) {
165
+ let tasks = loadTasks().filter((t) => !t.done && !t.archived);
166
+ if (filter && filter.type) tasks = tasks.filter((t) => taskType(t.text) === filter.type);
167
+ if (filter && filter.repo) tasks = tasks.filter((t) => t.repo === filter.repo);
140
168
  if (!tasks.length) return { empty: true, written: [], unresolved: [] };
141
169
  // Resolve repo name -> path from the already-scanned map when we have it
142
170
  // (avoids a fresh full scan); fall back to a scan only if needed.
143
171
  const byName = {};
144
- const src = (LAST_MAP && LAST_MAP.nodes) ? LAST_MAP.nodes.filter((n) => n.type === "repo").map((n) => ({ name: n.label, path: n.meta && n.meta.path })) : findAllRepos(BASE);
172
+ const src = (LAST_MAP && LAST_MAP.nodes) ? LAST_MAP.nodes.filter((n) => n.type === "repo" || n.type === "folder").map((n) => ({ name: n.label, path: n.meta && n.meta.path })) : findAllRepos();
145
173
  for (const r of src) if (r.path && !byName[r.name]) byName[r.name] = r.path;
146
174
  const groups = {}; for (const t of tasks) { const k = t.repo || ""; (groups[k] = groups[k] || []).push(t); }
147
175
  const written = [], unresolved = [];
@@ -232,8 +260,8 @@ function sh(cmd) {
232
260
  // crawling into them — this is the big speedup for the map scan.
233
261
  function findGitDirs(base, limit) {
234
262
  return sh(
235
- `find ${JSON.stringify(base)} -maxdepth 6 ` +
236
- `\\( -name node_modules -o -name .cache -o -name .local -o -name .npm -o -name venv -o -name .venv -o -name .git-crypt \\) -prune ` +
263
+ `find ${JSON.stringify(base)} -maxdepth 7 ` +
264
+ `\\( -name node_modules -o -name .cache -o -name .local -o -name .npm -o -name venv -o -name .venv -o -name .gradle -o -name Pods -o -name .git-crypt \\) -prune ` +
237
265
  `-o -name .git -print 2>/dev/null | head -${limit}`,
238
266
  ).split("\n").filter(Boolean);
239
267
  }
@@ -344,10 +372,30 @@ function reportFooter(repoPath, auth, state, rd) {
344
372
  if (state.behind) b.push(`${state.behind} behind upstream`);
345
373
  return b.join(" · ");
346
374
  }
375
+ function expandRoot(p) { p = String(p || "").trim(); return p.startsWith("~") ? join(homedir(), p.slice(1)) : p; }
376
+ // Where to look for repos: configured folders, or --dir, else your home folder.
377
+ function scanRoots() {
378
+ if (flag("dir", null)) return [BASE];
379
+ const r = loadConfig().scanRoots;
380
+ const roots = (Array.isArray(r) ? r : []).map(expandRoot).filter((x) => { try { return existsSync(x); } catch { return false; } });
381
+ return roots.length ? roots : [homedir()];
382
+ }
383
+ function addScanRoot(p) {
384
+ p = expandRoot(p); if (!p) return { error: "empty" };
385
+ try { if (!existsSync(p)) return { error: "folder not found: " + p }; } catch { return { error: "can't read: " + p }; }
386
+ const cfg = loadConfig();
387
+ let list = Array.isArray(cfg.scanRoots) && cfg.scanRoots.length ? cfg.scanRoots : [homedir()]; // keep home when adding the first extra folder
388
+ if (!list.includes(p)) list.push(p);
389
+ cfg.scanRoots = list; saveConfig(cfg); LAST_MAP = null; return { ok: true, roots: cfg.scanRoots };
390
+ }
391
+ function removeScanRoot(p) {
392
+ const cfg = loadConfig(); cfg.scanRoots = (Array.isArray(cfg.scanRoots) ? cfg.scanRoots : []).filter((x) => x !== p);
393
+ if (!cfg.scanRoots.length) delete cfg.scanRoots; saveConfig(cfg); LAST_MAP = null; return { ok: true, roots: cfg.scanRoots || [] };
394
+ }
347
395
  function findRepos(base, sinceDays) {
348
- const repos = [];
349
- for (const g of findGitDirs(base, 200)) {
350
- const repo = g.replace(/\/\.git$/, "");
396
+ const repos = []; const seen = new Set();
397
+ for (const root of scanRoots()) for (const g of findGitDirs(root, 200)) {
398
+ const repo = g.replace(/\/\.git$/, ""); if (seen.has(repo)) continue; seen.add(repo);
351
399
  const n = Number(sh(`git -C ${JSON.stringify(repo)} log --since="${sinceDays} days ago" --oneline 2>/dev/null | wc -l`).trim());
352
400
  const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
353
401
  if (n > 0) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
@@ -453,7 +501,10 @@ async function callOllama(r, system, prompt) {
453
501
  body: JSON.stringify({ model: r.model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
454
502
  });
455
503
  const text = await res.text();
456
- if (!res.ok) throw new Error(`Ollama ${res.status}: ${text.slice(0, 200)}`);
504
+ if (!res.ok) {
505
+ if (/not found|no such model|try pulling/i.test(text)) throw new Error(`the model "${r.model}" isn't downloaded yet — run symbiot setup-local --model ${r.model} (or click "Set up a free local model" in Settings)`);
506
+ throw new Error(`Ollama ${res.status}: ${text.slice(0, 200)}`);
507
+ }
457
508
  return (JSON.parse(text).message?.content || "").trim();
458
509
  }
459
510
 
@@ -543,23 +594,83 @@ function recommendModels(hw) {
543
594
  // gnome-terminal --working-directory={dir} -- claude "{prompt}"
544
595
  // Not tied to any one tool — you decide what runs.
545
596
  const IDE_LIST = [["code", "VS Code"], ["cursor", "Cursor"], ["windsurf", "Windsurf"], ["zed", "Zed"], ["subl", "Sublime Text"], ["idea", "IntelliJ IDEA"], ["nvim", "Neovim"]];
546
- const AGENT_LIST = [["claude", "Claude Code", 'claude -p "{prompt}"'], ["aider", "Aider", 'aider --message "{prompt}"'], ["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"']];
597
+ const AGENT_LIST = [
598
+ ["claude", "Claude Code — make changes", 'claude -p "{prompt}" --permission-mode acceptEdits'],
599
+ ["claude", "Claude Code — plan only (asks first)", 'claude -p "{prompt}"'],
600
+ ["aider", "Aider — make changes", 'aider --message "{prompt}" --yes'],
601
+ ["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
602
+ ];
547
603
  function detectHandoffs() {
548
604
  const editors = IDE_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label]) => ({ label, tmpl: `${cmd} {dir}`, kind: "editor" }));
549
605
  const agents = AGENT_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label, tmpl]) => ({ label, tmpl, kind: "agent" }));
606
+ // Orca IDE: its own CLI (not the ~/.local/bin GPU wrapper, which ignores args).
607
+ // Registers the repo, then opens a terminal tab running the agent — Orca shows
608
+ // the live session itself.
609
+ const orca = join(homedir(), ".local/share/orca-ide/app/resources/bin/orca-ide");
610
+ if (existsSync(orca)) agents.unshift({ label: "Orca IDE (terminal tab)", tmpl: `${orca} repo add --path {dir}; ${orca} terminal create --worktree path:{dir} --command "claude {prompt}" --focus`, kind: "agent" });
550
611
  return { agents, editors };
551
612
  }
552
613
  const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
553
614
  const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
554
615
  const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Confirm before anything destructive.";
555
616
  function handoffCmd() { const cfg = loadConfig(); return cfg.agentCmd || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
556
- function runHandoff(repoPath) {
617
+ // Run a shell command as a tracked, logged background job that shows up live in
618
+ // the Agents tab. Shared by the agent handoff and the local-model setup.
619
+ function track(name, cmd, cwd, onExit) {
557
620
  try {
558
- const tmpl = handoffCmd(); if (!tmpl || !repoPath) return false;
559
- const cmd = tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
560
- spawn(cmd, { shell: true, cwd: repoPath, detached: true, stdio: "ignore" }).unref();
561
- return true;
562
- } catch { return false; }
621
+ const dir = join(cwd, ".symbiot"); mkdirSync(dir, { recursive: true });
622
+ const logp = join(dir, "agent.log");
623
+ let fd = "ignore"; try { fd = openSync(logp, "a"); writeSync(fd, `\n=== ${name} ${new Date().toISOString()} ===\n$ ${cmd}\n`); } catch {}
624
+ const entry = { id: randomBytes(4).toString("hex"), name, path: cwd, log: logp, startedAt: Date.now(), status: "running", exitCode: null, endedAt: null };
625
+ const child = spawn(cmd, { shell: true, cwd, detached: true, stdio: ["ignore", fd === "ignore" ? "ignore" : fd, fd === "ignore" ? "ignore" : fd] });
626
+ entry.pid = child.pid;
627
+ child.on("exit", (code) => { entry.status = code === 0 ? "done" : "failed"; entry.exitCode = code; entry.endedAt = Date.now(); if (onExit) try { onExit(code); } catch {} });
628
+ child.on("error", () => { entry.status = "failed"; entry.endedAt = Date.now(); });
629
+ child.unref();
630
+ HANDOFFS.unshift(entry);
631
+ if (HANDOFFS.length > 30) HANDOFFS.length = 30;
632
+ return entry;
633
+ } catch { return null; }
634
+ }
635
+ function runHandoff(repoPath) {
636
+ const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
637
+ const cmd = tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
638
+ return track(repoPath.split("/").pop(), cmd, repoPath);
639
+ }
640
+ // ---- local model one-command setup (Ollama), OS-aware ---------------------
641
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
642
+ function hasOllama() { return !!sh("ollama --version").trim(); } // cross-platform (not POSIX command -v)
643
+ function ollamaInstall() {
644
+ if (process.platform === "darwin") return { cmd: "brew install ollama", alt: "https://ollama.com/download" };
645
+ if (process.platform === "win32") return { cmd: "winget install Ollama.Ollama", alt: "https://ollama.com/download" };
646
+ return { cmd: "curl -fsSL https://ollama.com/install.sh | sh", alt: "https://ollama.com/download" };
647
+ }
648
+ async function ollamaUp() { try { const r = await fetch("http://127.0.0.1:11434/api/tags"); return r.ok; } catch { return false; } }
649
+ async function ensureOllama() {
650
+ if (await ollamaUp()) return true;
651
+ try { spawn("ollama", ["serve"], { detached: true, stdio: "ignore" }).unref(); } catch {}
652
+ for (let i = 0; i < 16; i++) { await sleep(500); if (await ollamaUp()) return true; }
653
+ return false;
654
+ }
655
+ function useOllamaModel(model) { const cfg = loadConfig(); cfg.provider = "ollama"; cfg.ollama = { baseUrl: "http://localhost:11434", model }; delete cfg.apiKey; saveConfig(cfg); }
656
+ async function cmdSetupLocal() {
657
+ const model = flag("model", null) || recommendModels(detectHardware()).best;
658
+ if (!hasOllama()) {
659
+ const inst = ollamaInstall();
660
+ console.log("\n" + c.y("Ollama isn't installed") + c.d(" — the free, private local-model runner.") + "\n");
661
+ console.log(` On ${process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux"}, install it then re-run ${c.b("symbiot setup-local")}:`);
662
+ console.log(" " + c.b(inst.cmd));
663
+ console.log(c.d(" or download: " + inst.alt));
664
+ return;
665
+ }
666
+ process.stdout.write(c.d("Starting Ollama… "));
667
+ if (!(await ensureOllama())) { console.log(c.y("couldn't reach it — start it with ") + c.b("ollama serve")); return; }
668
+ console.log(c.g("ready"));
669
+ console.log("\n" + c.b("Pulling " + model) + c.d(" (first time downloads a few GB — progress below)\n"));
670
+ const code = await new Promise((res) => { const ch = spawn("ollama", ["pull", model], { stdio: "inherit" }); ch.on("close", res); ch.on("error", () => res(1)); });
671
+ if (code !== 0) { console.log("\n" + c.y("Pull failed.") + c.d(" Try a smaller model: ") + c.b("symbiot setup-local --model llama3.2:3b")); return; }
672
+ useOllamaModel(model);
673
+ console.log("\n" + c.g("✓ ") + `Symbiot now runs on ${model} locally — free & private. Try: ` + c.b("symbiot week"));
563
674
  }
564
675
  function cmdModels() {
565
676
  const hw = detectHardware(); const rec = recommendModels(hw);
@@ -693,7 +804,7 @@ function driftRepo(p, opts = {}) {
693
804
  }
694
805
  function computeDrift(opts = {}) {
695
806
  const deploys = loadDeploys();
696
- const repos = findAllRepos(BASE).slice(0, 20).map((r) => driftRepo(r.path, { deploys, ci: opts.ci, fetch: opts.fetch }));
807
+ const repos = findAllRepos().slice(0, 20).map((r) => driftRepo(r.path, { deploys, ci: opts.ci, fetch: opts.fetch }));
697
808
  return { repos, ci: !!opts.ci };
698
809
  }
699
810
  function cmdDrift() {
@@ -726,9 +837,10 @@ const MANIFEST_TOOL = {
726
837
  "terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
727
838
  };
728
839
  function findAllRepos(base) {
729
- const repos = [];
730
- for (const g of findGitDirs(base, 120)) {
731
- const repo = g.replace(/\/\.git$/, "");
840
+ const roots = base ? [base] : scanRoots();
841
+ const repos = []; const seen = new Set();
842
+ for (const root of roots) for (const g of findGitDirs(root, 300)) {
843
+ const repo = g.replace(/\/\.git$/, ""); if (seen.has(repo)) continue; seen.add(repo);
732
844
  const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
733
845
  if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
734
846
  }
@@ -739,7 +851,7 @@ function findAllRepos(base) {
739
851
  const key = cd.startsWith("/") ? cd : join(r.path, cd);
740
852
  if (!byCommon[key] || byCommon[key].recency < r.recency) byCommon[key] = r;
741
853
  }
742
- return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 30);
854
+ return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 60);
743
855
  }
744
856
  function detectRepo(r) {
745
857
  const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -3000`).split("\n").filter(Boolean);
@@ -757,9 +869,31 @@ function detectRepo(r) {
757
869
  const last = sh(`git -C ${JSON.stringify(r.path)} log -1 --format=%cd --date=short 2>/dev/null`).trim();
758
870
  return { ...r, langs, tools: [...tools], mine, files: files.length, branch, last };
759
871
  }
872
+ // Non-git PROJECT folders inside your scan roots (you added them = consent):
873
+ // a directory with a manifest but no .git — "not everything is a repo".
874
+ function findProjectFolders(root) {
875
+ const manifests = sh(
876
+ `find ${JSON.stringify(root)} -maxdepth 3 ` +
877
+ `\\( -name node_modules -o -name .cache -o -name .local -o -name .npm -o -name venv -o -name .venv -o -name .gradle -o -name Pods -o -name .git \\) -prune ` +
878
+ `-o -type f \\( -name package.json -o -name requirements.txt -o -name pyproject.toml -o -name go.mod -o -name Cargo.toml -o -name pom.xml -o -name build.gradle -o -name Gemfile -o -name composer.json -o -name Dockerfile -o -name pubspec.yaml -o -name CMakeLists.txt \\) -print 2>/dev/null | head -200`,
879
+ ).split("\n").filter(Boolean);
880
+ const dirs = new Set(); for (const m of manifests) dirs.add(m.replace(/\/[^/]+$/, ""));
881
+ const out = [];
882
+ for (const d of dirs) {
883
+ if (sh(`git -C ${JSON.stringify(d)} rev-parse --is-inside-work-tree 2>/dev/null`).trim() === "true") continue; // inside a repo already
884
+ out.push(d); if (out.length >= 24) break;
885
+ }
886
+ return out;
887
+ }
888
+ function detectFolder(path) {
889
+ const files = sh(`find ${JSON.stringify(path)} -maxdepth 2 \\( -name node_modules -o -name .git \\) -prune -o -type f -print 2>/dev/null | head -2000`).split("\n").filter(Boolean);
890
+ const count = {}; const tools = new Set();
891
+ for (const f of files) { const base = f.split("/").pop(); if (MANIFEST_TOOL[base]) tools.add(MANIFEST_TOOL[base]); const ext = (base.includes(".") ? base.split(".").pop() : "").toLowerCase(); if (EXT_LANG[ext]) count[EXT_LANG[ext]] = (count[EXT_LANG[ext]] || 0) + 1; }
892
+ return { path, name: path.split("/").pop(), langs: Object.entries(count).sort((a, b) => b[1] - a[1]).map((x) => x[0]), tools: [...tools], files: files.length };
893
+ }
760
894
  function buildMap() {
761
895
  const who = me();
762
- const repos = findAllRepos(BASE).map(detectRepo);
896
+ const repos = findAllRepos().map(detectRepo);
763
897
  const nodes = []; const edges = []; const have = new Set();
764
898
  const add = (n) => { if (!have.has(n.id)) { have.add(n.id); nodes.push(n); } };
765
899
  add({ id: "me", type: "person", label: who.name || "You", weight: 22 });
@@ -771,10 +905,36 @@ function buildMap() {
771
905
  for (const L of r.langs.slice(0, 3)) { const id = "lang:" + L; add({ id, type: "lang", label: L, weight: 15 }); edges.push({ source: rid, target: id }); }
772
906
  for (const T of r.tools) { const id = "tool:" + T; add({ id, type: "tool", label: T, weight: 12 }); edges.push({ source: rid, target: id }); }
773
907
  }
908
+ // Non-git project folders in the scan roots (capped; deduped against repos).
909
+ try {
910
+ const repoPaths = new Set(repos.map((r) => r.path)); let folders = 0;
911
+ for (const root of scanRoots()) { for (const d of findProjectFolders(root)) {
912
+ if (repoPaths.has(d) || have.has("folder:" + d) || folders >= 20) continue;
913
+ const det = detectFolder(d); folders++;
914
+ add({ id: "folder:" + d, type: "folder", label: det.name, weight: 10, meta: { path: d, langs: det.langs.slice(0, 3), tools: det.tools, files: det.files } });
915
+ edges.push({ source: "me", target: "folder:" + d });
916
+ for (const L of det.langs.slice(0, 3)) { const id = "lang:" + L; add({ id, type: "lang", label: L, weight: 15 }); edges.push({ source: "folder:" + d, target: id }); }
917
+ } }
918
+ } catch {}
919
+ // What you build WITH (person-level, not per-repo): detected agents/editors
920
+ // and the AI currently powering Symbiot — so the initial scan shows the whole
921
+ // setup, not just code. All local detection, nothing invasive.
922
+ try {
923
+ const hands = detectHandoffs();
924
+ for (const a of [...hands.agents, ...hands.editors]) {
925
+ const id = "agent:" + a.label;
926
+ add({ id, type: "agent", label: a.label.replace(/\s*\(.*\)$/, ""), weight: 11, meta: { cmd: a.tmpl, kind: a.kind } });
927
+ edges.push({ source: "me", target: id });
928
+ }
929
+ const prov = resolveProvider();
930
+ if (prov) { const id = "ai:" + prov.provider; add({ id, type: "ai", label: PROVIDERS[prov.provider].label, weight: 14, meta: { model: prov.model, source: prov.source } }); edges.push({ source: "me", target: id }); }
931
+ } catch {}
774
932
  const out = { nodes, edges, stats: {
775
933
  repos: repos.length,
934
+ folders: nodes.filter((n) => n.type === "folder").length,
776
935
  languages: nodes.filter((n) => n.type === "lang").length,
777
936
  tools: nodes.filter((n) => n.type === "tool").length,
937
+ agents: nodes.filter((n) => n.type === "agent").length,
778
938
  commits: repos.reduce((s, r) => s + r.mine, 0),
779
939
  files: repos.reduce((s, r) => s + (r.files || 0), 0),
780
940
  base: BASE,
@@ -801,6 +961,14 @@ function nodeDetail(id) {
801
961
  const repos = map.edges.filter((e) => e.target === id).map((e) => { const r = map.nodes.find((x) => x.id === e.source); return r ? r.label : null; }).filter(Boolean);
802
962
  return { type: id.startsWith("lang:") ? "lang" : "tool", label: n ? n.label : id.split(":")[1], repos };
803
963
  }
964
+ if (id.startsWith("agent:") || id.startsWith("ai:")) {
965
+ const n = map.nodes.find((x) => x.id === id);
966
+ return { type: id.startsWith("agent:") ? "agent" : "ai", label: n ? n.label : id.split(":")[1], meta: (n && n.meta) || {} };
967
+ }
968
+ if (id.startsWith("folder:")) {
969
+ const path = id.slice(7); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
970
+ return { type: "folder", label: n ? n.label : path.split("/").pop(), path, langs: m.langs || [], tools: m.tools || [], files: m.files || 0 };
971
+ }
804
972
  return { error: "unknown node" };
805
973
  }
806
974
  // AI review of one repo: what it does, what it's for, possible upgrades.
@@ -1090,6 +1258,11 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1090
1258
  .task .rm:hover{color:var(--amber)}
1091
1259
  .tgroup{margin:14px 0 4px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--green);font-weight:700}
1092
1260
  .tcount{color:var(--faint);font-weight:400}
1261
+ .taskfilter{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:10px 0 2px}
1262
+ .fl{font-size:10px;color:var(--faint);text-transform:uppercase;letter-spacing:.12em;margin:0 2px 0 8px}
1263
+ .fchip{font-size:12px;padding:4px 10px;border-radius:999px;border:1px solid var(--line);background:var(--ink3);color:var(--text);cursor:pointer;font:inherit}
1264
+ .fchip.on{background:var(--green-dim);border-color:#2a6b52;color:var(--green)}
1265
+ .task .rm.restore{color:var(--green);font-size:15px}
1093
1266
  .drift{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
1094
1267
  .drift .dh{display:flex;gap:8px;align-items:center}
1095
1268
  .drift .dn{color:var(--bone);font-weight:600}
@@ -1100,6 +1273,15 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1100
1273
  .drift .ev{color:var(--faint);font-family:ui-monospace,Menlo,monospace;font-size:11px}
1101
1274
  .dot-w{width:9px;height:9px;border-radius:50%;background:var(--amber);display:inline-block}
1102
1275
  .dot-c{width:9px;height:9px;border-radius:50%;background:var(--green);display:inline-block}
1276
+ .agent{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
1277
+ .adot{width:10px;height:10px;border-radius:50%;display:inline-block;flex:none}
1278
+ .adot.run{background:var(--amber);animation:pulse 1s ease-in-out infinite}
1279
+ .adot.ok{background:var(--green)} .adot.fail{background:#E5695B}
1280
+ @keyframes pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(242,165,65,.5)}50%{opacity:.4;box-shadow:0 0 0 5px rgba(242,165,65,0)}}
1281
+ .alogout{white-space:pre-wrap;background:var(--ink);border:1px solid var(--line);border-radius:8px;padding:10px;margin-top:10px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;color:var(--text);max-height:260px;overflow:auto}
1282
+ .bar{height:3px;background:var(--green-dim);border-radius:2px;overflow:hidden;margin-top:8px}
1283
+ .bar > i{display:block;height:100%;width:35%;background:var(--green);border-radius:2px;animation:slide 1.3s ease-in-out infinite}
1284
+ @keyframes slide{0%{margin-left:-35%}100%{margin-left:100%}}
1103
1285
  .out2{white-space:pre-wrap;background:var(--ink);border:1px solid var(--line);border-radius:9px;padding:10px;margin-top:10px;font-size:13px;line-height:1.55;color:var(--bone)}
1104
1286
  .legend{display:flex;gap:14px;align-items:center;margin-top:10px;font-size:12px;color:var(--faint);flex-wrap:wrap}
1105
1287
  .lg{display:inline-flex;gap:6px;align-items:center}
@@ -1114,6 +1296,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1114
1296
  <button class="tab" data-tab="standup">Standup</button>
1115
1297
  <button class="tab" data-tab="todo">Todo</button>
1116
1298
  <button class="tab" data-tab="tasks">Tasks</button>
1299
+ <button class="tab" data-tab="agents">Agents</button>
1117
1300
  <button class="tab" data-tab="settings">Settings</button>
1118
1301
  </div>
1119
1302
  <main>
@@ -1129,6 +1312,9 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1129
1312
  <span class="lg"><i style="background:var(--bone)"></i>repos</span>
1130
1313
  <span class="lg"><i style="background:var(--amber)"></i>languages</span>
1131
1314
  <span class="lg"><i style="background:#6bb3ff"></i>tools</span>
1315
+ <span class="lg"><i style="background:#c58af9"></i>agents</span>
1316
+ <span class="lg"><i style="background:#5fe3b0"></i>AI</span>
1317
+ <span class="lg"><i style="background:#b7a98c"></i>folders (no git)</span>
1132
1318
  <span class="muted" style="margin-left:8px">scroll to zoom &middot; drag to pan &middot; click a node</span>
1133
1319
  <button class="ghost" id="remap" style="margin-left:auto">Rescan</button>
1134
1320
  </div>
@@ -1143,9 +1329,15 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1143
1329
  </section>
1144
1330
  <section id="panel-tasks" class="hidden">
1145
1331
  <div class="row"><input id="newtask" placeholder="Add a task..." style="flex:1"><button class="act" id="addtask">Add</button><button class="ghost" id="pushtasks" title="Write .symbiot/TASKS.md into each repo for your coding agent">Send to repos</button></div>
1332
+ <div class="note muted" style="margin-top:2px">The checkbox marks a task <b>done</b> (it auto-archives). To give tasks to your agent, use <b>Send to repos</b> &mdash; filter by tag below to choose which.</div>
1146
1333
  <div id="pushout"></div>
1334
+ <div id="taskfilter" class="taskfilter"></div>
1147
1335
  <div id="tasklist"></div>
1148
1336
  </section>
1337
+ <section id="panel-agents" class="hidden">
1338
+ <div class="row"><span class="muted">Agents Symbiot has handed work to — live status and output.</span><button class="ghost" id="agentsrefresh" style="margin-left:auto">Refresh</button></div>
1339
+ <div id="agentslist"></div>
1340
+ </section>
1149
1341
  <section id="panel-drift" class="hidden">
1150
1342
  <div class="row"><span class="muted">What's out of sync, stuck or at risk across your repos — local git facts.</span>
1151
1343
  <label class="muted" style="margin-left:auto"><input type="checkbox" id="driftfetch"> fetch latest</label>
@@ -1170,6 +1362,12 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1170
1362
  <div class="row" style="margin-top:16px"><button class="act" id="save">Save &amp; connect</button>
1171
1363
  <span class="note" id="saveMsg"></span></div>
1172
1364
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
1365
+ <label>Folders to scan for repos</label>
1366
+ <div id="scanroots"></div>
1367
+ <div class="row" style="margin-top:6px"><input id="newroot" placeholder="/path/to/folder (or ~/work) — where your projects live" style="flex:1"><button class="ghost" id="addroot">Add folder</button></div>
1368
+ <div class="note muted" id="scanrootnote">Point Symbiot at where your work lives — inside or outside your home folder. Defaults to your home folder.</div>
1369
+ </div>
1370
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
1173
1371
  <label>Hand off to your agent when you "Send to repos"</label>
1174
1372
  <input id="agentcmd" type="text" placeholder="e.g. claude -p &quot;{prompt}&quot; · code {dir} · leave blank to just write the file">
1175
1373
  <div class="note muted">Runs in each repo after tasks are written. Use <b>{dir}</b> = repo path, <b>{prompt}</b> = the task instruction. Works with any agent or editor &mdash; it's your command.</div>
@@ -1177,6 +1375,8 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1177
1375
  </div>
1178
1376
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
1179
1377
  <button class="ghost" id="recbtn">Recommend models for my machine</button>
1378
+ <button class="ghost" id="setuplocal" style="margin-left:8px">Set up a free local model</button>
1379
+ <div id="setupout" class="note muted" style="margin-top:10px"></div>
1180
1380
  <div id="recout" style="margin-top:12px"></div>
1181
1381
  </div>
1182
1382
  </section>
@@ -1189,19 +1389,21 @@ var KEYURL={anthropic:'https://console.anthropic.com/settings/keys',openai:'http
1189
1389
  var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
1190
1390
  function $(id){return document.getElementById(id);}
1191
1391
  var current='map';var mapLoaded=false;var driftLoaded=false;
1192
- var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff'};
1392
+ var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff',agent:'#c58af9',ai:'#5fe3b0',folder:'#b7a98c'};
1193
1393
  function tabs(){return document.querySelectorAll('.tab');}
1194
1394
  function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
1195
- var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isRun=(tab==='week'||tab==='standup'||tab==='todo');
1395
+ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isRun=(tab==='week'||tab==='standup'||tab==='todo');
1196
1396
  $('panel-map').classList.toggle('hidden',!isMap);
1197
1397
  $('panel-run').classList.toggle('hidden',!isRun);
1198
1398
  $('panel-settings').classList.toggle('hidden',!isSet);
1199
1399
  $('panel-tasks').classList.toggle('hidden',!isTasks);
1200
1400
  $('panel-drift').classList.toggle('hidden',!isDrift);
1401
+ $('panel-agents').classList.toggle('hidden',!isAgents);
1201
1402
  if(isRun){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');}
1202
1403
  if(isMap&&!mapLoaded)loadMap();
1203
1404
  if(isTasks)loadTasks();
1204
- if(isDrift&&!driftLoaded)loadDrift();}
1405
+ if(isDrift&&!driftLoaded)loadDrift();
1406
+ if(isAgents)loadAgents(); else stopAgentsPoll();}
1205
1407
  tabs().forEach(function(t){t.addEventListener('click',function(){setTab(t.dataset.tab);});});
1206
1408
  function refresh(){api('/api/status').then(function(s){$('status').textContent=s.connected?s.line:'Not connected - open Settings';});}
1207
1409
  $('write').addEventListener('click',function(){$('out').textContent='Writing...';$('out').classList.add('muted');$('copy').classList.add('hidden');
@@ -1241,7 +1443,10 @@ if(d.type==='repo'){var chips="";['branch: '+(d.branch||'?'),d.commits+' commits
1241
1443
  el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='chips'>"+chips+"</div><button class='act' id='suggest' style='margin-top:12px'>Suggest next steps</button><div id='sugout'></div>";
1242
1444
  document.getElementById('suggest').addEventListener('click',function(){var o=document.getElementById('sugout');o.innerHTML="<div class='out2'>Thinking...</div>";api('/api/suggest',{path:d.path}).then(function(r){if(r.error==='not-connected'){o.innerHTML="<div class='out2'>Connect a model in Settings to get suggestions - Ollama is free and runs locally.</div>";return;}o.innerHTML="<div class='out2'>"+esc(r.text||'(no output)')+"</div>"+(r.footer?"<div class='rfoot'>"+esc(r.footer)+"</div>":"");});});return;}
1243
1445
  if(d.type==='lang'||d.type==='tool'){var lis=(d.repos||[]).map(function(r){return "<li>"+esc(r)+"</li>";}).join("");el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>Used in "+((d.repos||[]).length)+" repos</div><ul>"+lis+"</ul>";return;}
1244
- if(d.type==='person'){var st=d.stats||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>"+st.repos+" repos &middot; "+st.commits+" commits &middot; "+st.languages+" languages &middot; "+st.tools+" tools</div>";return;}}
1446
+ if(d.type==='agent'){var m=d.meta||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>"+(m.kind==='agent'?'AI coding agent you have installed':'editor you have installed')+"</div>"+(m.cmd?"<div class='out2' style='font-size:11px'>"+esc(m.cmd)+"</div>":"");return;}
1447
+ if(d.type==='ai'){var m=d.meta||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>Currently powering Symbiot &middot; model <b>"+esc(m.model||'?')+"</b>"+(m.source?" &middot; via "+esc(m.source):"")+"</div>";return;}
1448
+ if(d.type==='folder'){var chips='';(d.langs||[]).concat(d.tools||[]).forEach(function(x){chips+="<span class='tag'>"+esc(x)+"</span>";});el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>project folder &middot; not a git repo &middot; "+(d.files||0)+" files</div><div class='chips'>"+chips+"</div><div class='k' style='font-family:ui-monospace,monospace'>"+esc(d.path||'')+"</div><div class='k'>Tasks can be sent here too &mdash; the agent runs in this folder.</div>";return;}
1449
+ if(d.type==='person'){var st=d.stats||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>"+st.repos+" repos &middot; "+st.commits+" commits &middot; "+st.languages+" languages &middot; "+st.tools+" tools"+(st.agents?" &middot; "+st.agents+" agents/editors":"")+"</div>";return;}}
1245
1450
  var reviewCache={};var IDEAS=[];var IREPO="";
1246
1451
  function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
1247
1452
  function reviewHtml(name,body,verdict,ideas,tasks,footer){IDEAS=ideas||[];IREPO=name;
@@ -1264,17 +1469,48 @@ el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h
1264
1469
  Promise.all([api('/api/review',{path:path}),api('/api/tasks')]).then(function(res){var r=res[0]||{},tasks=res[1]||[];
1265
1470
  if(r.error==='not-connected'){el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>Connect a model in Settings to get a review - Ollama is free and runs locally.</div>"+(r.footer?"<div class='rfoot'>"+esc(r.footer)+"</div>":"");return;}
1266
1471
  var body=r.text||'(no output)';reviewCache[path]={text:body,verdict:r.verdict||"",ideas:r.ideas||[],footer:r.footer};el.innerHTML=reviewHtml(name,body,r.verdict||"",r.ideas||[],tasks,r.footer);wireIdeas();});}
1267
- function taskRow(t){return "<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'><input type='checkbox' class='taskchk'"+(t.done?" checked":"")+"><span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+"<button class='rm' title='remove'>&times;</button></div>";}
1472
+ var ALLTASKS=[];var TFILTER={type:'',repo:''};var TARCH=false;
1473
+ var TORDER=['Fixes','Tests & CI','Security','Performance','Refactor','UI/UX','Docs','Features & other'];
1474
+ function taskRow(t,arch){return "<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'>"+(arch?"":"<input type='checkbox' class='taskchk' title='mark done (auto-archives on sync)'"+(t.done?" checked":"")+">")+"<span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+(arch?"<button class='rm restore' title='restore to active'>&#8630;</button>":"<button class='rm' title='remove'>&times;</button>")+"</div>";}
1268
1475
  function wireTaskRows(el){el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
1269
- row.querySelector('.taskchk').addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
1270
- row.querySelector('.rm').addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();if(!el.querySelector('.task'))renderTasks([]);});});});}
1271
- function renderTasks(list){var el=document.getElementById('tasklist');if(!list||!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>No tasks yet. Tick an idea in a repo's review, or add one above.</div>";return;}
1272
- var order=['Fixes','Tests & CI','Security','Performance','Refactor','UI/UX','Docs','Features & other'];
1476
+ var cb=row.querySelector('.taskchk');if(cb)cb.addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
1477
+ var rm=row.querySelector('.rm');if(rm&&rm.classList.contains('restore'))rm.addEventListener('click',function(){api('/api/tasks/restore',{id:id}).then(loadArchived);});
1478
+ else if(rm)rm.addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();});});});}
1479
+ function fchip(dim,val,label){var on=(TFILTER[dim]||'')===val;return "<button class='fchip"+(on?' on':'')+"' data-dim='"+dim+"' data-val='"+esc(val)+"'>"+esc(label)+"</button>";}
1480
+ function renderFilter(){var box=document.getElementById('taskfilter');
1481
+ if(TARCH){box.innerHTML="<button class='fchip on' id='archtoggle'>&#8617; back to active</button>";document.getElementById('archtoggle').addEventListener('click',function(){loadTasks();});return;}
1482
+ var types={},repos={};ALLTASKS.forEach(function(t){types[t.type||'Features & other']=1;if(t.repo)repos[t.repo]=1;});
1483
+ var fb="<span class='fl'>Type</span>"+fchip('type','','All')+Object.keys(types).sort(function(a,b){return TORDER.indexOf(a)-TORDER.indexOf(b);}).map(function(t){return fchip('type',t,t);}).join('');
1484
+ fb+="<span class='fl'>Repo</span>"+fchip('repo','','All')+Object.keys(repos).sort().map(function(r){return fchip('repo',r,r);}).join('');
1485
+ fb+="<button class='fchip' id='archtoggle' style='margin-left:auto'>&#128451; archived</button>";
1486
+ box.innerHTML=fb;
1487
+ box.querySelectorAll('.fchip[data-dim]').forEach(function(c){c.addEventListener('click',function(){TFILTER[c.getAttribute('data-dim')]=c.getAttribute('data-val');renderTasks();});});
1488
+ document.getElementById('archtoggle').addEventListener('click',loadArchived);}
1489
+ function renderTasks(){var el=document.getElementById('tasklist');TARCH=false;renderFilter();
1490
+ var list=ALLTASKS.filter(function(t){return (!TFILTER.type||(t.type||'Features & other')===TFILTER.type)&&(!TFILTER.repo||t.repo===TFILTER.repo);});
1491
+ if(!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>"+(ALLTASKS.length?"Nothing matches this filter.":"No tasks yet. Tick ideas in a repo review, or add one above.")+"</div>";return;}
1273
1492
  var groups={};list.forEach(function(t){var ty=t.type||'Features & other';(groups[ty]=groups[ty]||[]).push(t);});
1274
- var keys=Object.keys(groups).sort(function(a,b){var ia=order.indexOf(a),ib=order.indexOf(b);return (ia<0?99:ia)-(ib<0?99:ib);});
1275
- var h="";keys.forEach(function(ty){h+="<div class='tgroup'>"+esc(ty)+" <span class='tcount'>"+groups[ty].length+"</span></div>";groups[ty].forEach(function(t){h+=taskRow(t);});});
1493
+ var keys=Object.keys(groups).sort(function(a,b){var ia=TORDER.indexOf(a),ib=TORDER.indexOf(b);return (ia<0?99:ia)-(ib<0?99:ib);});
1494
+ var h="";keys.forEach(function(ty){h+="<div class='tgroup'>"+esc(ty)+" <span class='tcount'>"+groups[ty].length+"</span></div>";groups[ty].forEach(function(t){h+=taskRow(t,false);});});
1276
1495
  el.innerHTML=h;wireTaskRows(el);}
1277
- function loadTasks(){api('/api/tasks').then(renderTasks);}
1496
+ function loadTasks(){TARCH=false;api('/api/tasks/sync',{}).then(function(){api('/api/tasks').then(function(list){ALLTASKS=list;renderTasks();});});}
1497
+ function loadArchived(){TARCH=true;api('/api/tasks?archived=1').then(function(list){ALLTASKS=list;renderFilter();var el=document.getElementById('tasklist');
1498
+ if(!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>Nothing archived yet. Completed tasks land here.</div>";return;}
1499
+ var h="<div class='tgroup'>Archived <span class='tcount'>"+list.length+"</span></div>";list.forEach(function(t){h+=taskRow(t,true);});el.innerHTML=h;wireTaskRows(el);});}
1500
+ var agentsTimer=null;
1501
+ function stopAgentsPoll(){if(agentsTimer){clearTimeout(agentsTimer);agentsTimer=null;}}
1502
+ function fmtE(ms){var s=Math.floor(ms/1000);if(s<60)return s+'s';var m=Math.floor(s/60);return m+'m '+(s%60)+'s';}
1503
+ function loadAgents(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
1504
+ if(!list||!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>No agents yet. In <b>Tasks</b>, tick ideas and hit <b>Send to repos</b> (with an agent command set in Settings) &mdash; you'll watch it work here.</div>";stopAgentsPoll();return;}
1505
+ el.innerHTML=list.map(function(a){var cls=a.status==='running'?'run':(a.status==='done'?'ok':'fail');
1506
+ var st=a.status==='running'?('working &middot; '+fmtE(a.elapsed)):(esc(a.status)+' &middot; '+fmtE(a.elapsed)+(a.exitCode!=null?' &middot; exit '+a.exitCode:''));
1507
+ var b="<div class='dh'><span class='adot "+cls+"'></span><span class='dn'>"+esc(a.name)+"</span><span class='dd'>"+st+"</span></div>";
1508
+ if(a.status==='running')b+="<div class='bar'><i></i></div>";
1509
+ b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
1510
+ return "<div class='agent'>"+b+"</div>";}).join("");
1511
+ el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
1512
+ var anyRunning=list.some(function(a){return a.status==='running';});
1513
+ stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=setTimeout(loadAgents,2000);});}
1278
1514
  function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
1279
1515
  var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
1280
1516
  api('/api/drift?ci='+ci+'&fetch='+ft).then(function(d){driftLoaded=true;var repos=d.repos||[];var risky=repos.filter(function(r){return r.flags.some(function(f){return f.level==='warn';});});
@@ -1290,13 +1526,13 @@ out.innerHTML=h;});}
1290
1526
  function addTaskUI(){var i=document.getElementById('newtask');var v=(i.value||'').trim();if(!v)return;api('/api/tasks/add',{text:v,repo:''}).then(function(){i.value='';loadTasks();});}
1291
1527
  function pushTasksUI(){var o=document.getElementById('pushout');var btn=document.getElementById('pushtasks');btn.disabled=true;
1292
1528
  o.innerHTML="<div class='muted' style='margin-top:10px'><span class='dot-c' style='background:var(--amber)'></span> Writing .symbiot/TASKS.md into your repos&hellip;</div>";
1293
- api('/api/tasks/push',{}).then(function(r){btn.disabled=false;
1529
+ api('/api/tasks/push',{type:TFILTER.type||'',repo:TFILTER.repo||''}).then(function(r){btn.disabled=false;
1294
1530
  if(r.empty){o.innerHTML="<div class='muted' style='margin-top:10px'>No open tasks to send. Tick ideas in a repo review, or add tasks above.</div>";return;}
1295
1531
  var n=(r.written||[]).length;var h="<div class='drift' style='margin-top:10px'><div class='dh'><span class='dot-c'></span><span class='dn'>Done &mdash; wrote "+n+" file"+(n===1?"":"s")+"</span></div>";
1296
1532
  if(n){h+="<ul>";r.written.forEach(function(w){h+="<li class='info'>&#10003; <b>"+esc(w.name)+"</b> <span class='ev'>"+esc(w.file)+" ("+w.count+" task"+(w.count===1?"":"s")+")</span></li>";});h+="</ul>";}
1297
1533
  if(r.unresolved&&r.unresolved.length){h+="<div class='dd' style='margin-top:6px'>&#9888; not written (repo not found under your home folder): "+esc(r.unresolved.map(function(u){return u.name;}).join(", "))+"</div>";}
1298
- if(r.handoff&&r.written&&r.written.length){r.written.forEach(function(w){api('/api/open',{path:w.path});});h+="<div class='dd' style='margin-top:8px'>&#129302; Handing "+n+" repo(s) to your agent (<b>"+esc(r.handoff)+"</b>)&hellip;</div>";}
1299
- else{h+="<div class='dd' style='margin-top:8px'>Set an <b>agent command</b> in Settings to auto-run it on send. For now, tell your agent: <b>“Read .symbiot/TASKS.md and implement the unchecked items.”</b></div>";}
1534
+ if(r.handoff&&r.written&&r.written.length){r.written.forEach(function(w){api('/api/open',{path:w.path});});h+="<div class='dd' style='margin-top:8px'>&#129302; Handed "+n+" repo(s) to your agent &mdash; opening the <b>Agents</b> tab to watch it work&hellip;</div>";setTimeout(function(){setTab('agents');},500);}
1535
+ else{h+="<div class='dd' style='margin-top:8px'>Set an <b>agent command</b> in Settings to auto-run it on send (and watch it in the Agents tab). For now, tell your agent: <b>“Read .symbiot/TASKS.md and implement the unchecked items.”</b></div>";}
1300
1536
  h+="</div>";
1301
1537
  o.innerHTML=h;}).catch(function(e){btn.disabled=false;o.innerHTML="<div class='err' style='margin-top:10px'>Couldn&#39;t write tasks: "+esc(String((e&&e.message)||e))+"</div>";});}
1302
1538
  function loadAgentCfg(){api('/api/agentcfg').then(function(d){document.getElementById('agentcmd').value=d.cmd||'';
@@ -1306,6 +1542,14 @@ if(!chips.length){box.innerHTML="<span class='muted' style='font-size:12px'>Noth
1306
1542
  box.innerHTML="<span class='muted' style='font-size:12px'>Detected &mdash; click to use:</span><br>"+chips.map(function(c,i){return "<button class='ghost preset' data-i='"+i+"' style='padding:4px 10px;font-size:12px;margin:5px 5px 0 0'>"+esc(c.label)+"</button>";}).join("");
1307
1543
  box.querySelectorAll('.preset').forEach(function(btn){btn.addEventListener('click',function(){document.getElementById('agentcmd').value=chips[+btn.getAttribute('data-i')].tmpl;saveAgent();});});});}
1308
1544
  function saveAgent(){api('/api/agentcmd',{cmd:document.getElementById('agentcmd').value});}
1545
+ function loadScanRoots(){api('/api/scanroots').then(function(d){var box=document.getElementById('scanroots');var roots=d.effective||[];
1546
+ box.innerHTML=roots.map(function(r){var custom=(d.roots||[]).indexOf(r)>=0;return "<div class='task' data-p='"+esc(r)+"'><span class='t' style='font-family:ui-monospace,monospace;font-size:12px'>"+esc(r)+"</span>"+(r===d.home?"<span class='rp'>home</span>":"")+(custom?"<button class='rm rmroot' title='remove'>&times;</button>":"")+"</div>";}).join("");
1547
+ box.querySelectorAll('.rmroot').forEach(function(btn){btn.addEventListener('click',function(){api('/api/scanroots/remove',{path:btn.closest('.task').getAttribute('data-p')}).then(function(){loadScanRoots();mapLoaded=false;driftLoaded=false;});});});});}
1548
+ function addRootUI(){var i=document.getElementById('newroot');var v=(i.value||'').trim();if(!v)return;var n=document.getElementById('scanrootnote');
1549
+ api('/api/scanroots/add',{path:v}).then(function(r){if(r.error){n.innerHTML="<span class='err'>"+esc(r.error)+"</span>";return;}i.value='';n.textContent='Added — the Map/Drift will rescan.';loadScanRoots();mapLoaded=false;driftLoaded=false;});}
1550
+ function setupLocalUI(){var o=document.getElementById('setupout');o.innerHTML="Setting up a local model&hellip;";
1551
+ api('/api/setup-local',{}).then(function(r){if(r.error==='not-installed'){var i=r.install||{};o.innerHTML="Ollama isn't installed. Run this once, then click again:<div class='out2'>"+esc(i.cmd||'')+"</div><div class='muted' style='font-size:11px'>or download: "+esc(i.alt||'https://ollama.com/download')+"</div>";return;}
1552
+ o.innerHTML="Pulling <b>"+esc(r.model)+"</b> &mdash; watch it in the <b>Agents</b> tab. Symbiot switches to it automatically when the download finishes.";setTimeout(function(){setTab('agents');},700);}).catch(function(e){o.innerHTML="<span class='err'>Setup failed: "+esc(String((e&&e.message)||e))+"</span>";});}
1309
1553
  function selectNode(id){sel=id;render();updateBar(id);var n=nodeById(id);api('/api/node?id='+encodeURIComponent(id)).then(showDetail);
1310
1554
  if(n&&n.type==='repo'&&n.meta&&n.meta.path){loadReview(n.label,n.meta.path);}else{hideReview();}}
1311
1555
  function screenToGraph(el,ev){var rc=el.getBoundingClientRect();var mx=(ev.clientX-rc.left)/rc.width*GW;var my=(ev.clientY-rc.top)/rc.height*GH;return {x:(mx-view.x)/view.k,y:(my-view.y)/view.k};}
@@ -1334,12 +1578,16 @@ rec.paid.forEach(function(p){h+="<div class='idea'><span><b>"+esc(p.provider)+"<
1334
1578
  h+="</div>";out.innerHTML=h;});}
1335
1579
  document.getElementById('remap').addEventListener('click',loadMap);
1336
1580
  document.getElementById('recbtn').addEventListener('click',loadRec);
1581
+ document.getElementById('setuplocal').addEventListener('click',setupLocalUI);
1582
+ document.getElementById('addroot').addEventListener('click',addRootUI);
1583
+ document.getElementById('newroot').addEventListener('keydown',function(e){if(e.key==='Enter')addRootUI();});
1337
1584
  document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
1585
+ document.getElementById('agentsrefresh').addEventListener('click',loadAgents);
1338
1586
  document.getElementById('addtask').addEventListener('click',addTaskUI);
1339
1587
  document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
1340
1588
  document.getElementById('agentcmd').addEventListener('change',saveAgent);
1341
1589
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
1342
- initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();
1590
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();
1343
1591
  </script></body></html>`;
1344
1592
 
1345
1593
  function readBody(req) {
@@ -1398,14 +1646,28 @@ async function cmdApp() {
1398
1646
  if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
1399
1647
  if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
1400
1648
  if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
1401
- if (u.pathname === "/api/tasks" && req.method !== "POST") return json(res, loadTasks().map((t) => ({ ...t, type: taskType(t.text) })));
1649
+ if (u.pathname === "/api/tasks" && req.method !== "POST") { const arch = u.searchParams.get("archived") === "1"; return json(res, loadTasks().filter((x) => !!x.archived === arch).map((t) => ({ ...t, type: taskType(t.text) }))); }
1402
1650
  if (u.pathname === "/api/tasks/add" && req.method === "POST") { const b = await readBody(req); return json(res, addTask(b.text, b.repo)); }
1403
1651
  if (u.pathname === "/api/tasks/toggle" && req.method === "POST") { const b = await readBody(req); return json(res, toggleTask(String(b.id || ""))); }
1404
1652
  if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
1405
- if (u.pathname === "/api/tasks/push" && req.method === "POST") return json(res, pushTasks());
1653
+ if (u.pathname === "/api/tasks/restore" && req.method === "POST") { const b = await readBody(req); return json(res, restoreTask(String(b.id || ""))); }
1654
+ if (u.pathname === "/api/tasks/sync" && req.method === "POST") return json(res, syncTasks());
1655
+ if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
1656
+ if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: homedir() });
1657
+ if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
1658
+ if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
1406
1659
  if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
1407
1660
  if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); const cfg = loadConfig(); const v = String(b.cmd || "").trim(); if (v) cfg.agentCmd = v; else delete cfg.agentCmd; delete cfg.ide; saveConfig(cfg); return json(res, { ok: true, cmd: cfg.agentCmd || "" }); }
1408
- if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); return json(res, { opened: runHandoff(String(b.path || "")) }); }
1661
+ if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || "")); return json(res, { opened: !!e, id: e ? e.id : "" }); }
1662
+ if (u.pathname === "/api/setup-local" && req.method === "POST") {
1663
+ const b = await readBody(req);
1664
+ if (!hasOllama()) return json(res, { error: "not-installed", install: ollamaInstall() });
1665
+ const model = String(b.model || "") || recommendModels(detectHardware()).best;
1666
+ await ensureOllama();
1667
+ const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
1668
+ return json(res, { started: true, model, id: e ? e.id : "" });
1669
+ }
1670
+ if (u.pathname === "/api/agents") return json(res, HANDOFFS.map((e) => { let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {} return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail }; }));
1409
1671
  if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, await produce(cmd)); }
1410
1672
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
1411
1673
  if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
@@ -1431,6 +1693,7 @@ ${c.b("Usage")}
1431
1693
  symbiot drift what's out of sync / at risk across repos
1432
1694
  symbiot push [--open] write tasks into each repo (and open your IDE)
1433
1695
  symbiot models recommend AI models for your hardware
1696
+ symbiot setup-local [--model X] install/run a free local model (Ollama)
1434
1697
  symbiot login connect it to an AI (once)
1435
1698
  symbiot whoami show how it's connected
1436
1699
  symbiot logout forget saved credentials
@@ -1460,6 +1723,7 @@ async function main() {
1460
1723
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
1461
1724
  if (cmd === "app" || cmd === "ui") return cmdApp();
1462
1725
  if (cmd === "models" || cmd === "hardware") return cmdModels();
1726
+ if (cmd === "setup-local" || cmd === "setup-ollama") return cmdSetupLocal();
1463
1727
  if (cmd === "drift") return cmdDrift();
1464
1728
  if (cmd === "push") return cmdPush();
1465
1729
  if (cmd === "week") return cmdRun("week");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.13.0",
3
+ "version": "0.20.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 — Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {