symbiot 0.17.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 +98 -16
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -112,7 +112,7 @@ function completedInRepo(repoPath) {
112
112
  }
113
113
  function repoPathMap() {
114
114
  const byName = {};
115
- 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);
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
116
  for (const r of src) if (r.path && !byName[r.name]) byName[r.name] = r.path;
117
117
  return byName;
118
118
  }
@@ -169,7 +169,7 @@ function pushTasks(filter) {
169
169
  // Resolve repo name -> path from the already-scanned map when we have it
170
170
  // (avoids a fresh full scan); fall back to a scan only if needed.
171
171
  const byName = {};
172
- 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();
173
173
  for (const r of src) if (r.path && !byName[r.name]) byName[r.name] = r.path;
174
174
  const groups = {}; for (const t of tasks) { const k = t.repo || ""; (groups[k] = groups[k] || []).push(t); }
175
175
  const written = [], unresolved = [];
@@ -260,8 +260,8 @@ function sh(cmd) {
260
260
  // crawling into them — this is the big speedup for the map scan.
261
261
  function findGitDirs(base, limit) {
262
262
  return sh(
263
- `find ${JSON.stringify(base)} -maxdepth 6 ` +
264
- `\\( -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 ` +
265
265
  `-o -name .git -print 2>/dev/null | head -${limit}`,
266
266
  ).split("\n").filter(Boolean);
267
267
  }
@@ -372,10 +372,30 @@ function reportFooter(repoPath, auth, state, rd) {
372
372
  if (state.behind) b.push(`${state.behind} behind upstream`);
373
373
  return b.join(" · ");
374
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
+ }
375
395
  function findRepos(base, sinceDays) {
376
- const repos = [];
377
- for (const g of findGitDirs(base, 200)) {
378
- 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);
379
399
  const n = Number(sh(`git -C ${JSON.stringify(repo)} log --since="${sinceDays} days ago" --oneline 2>/dev/null | wc -l`).trim());
380
400
  const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
381
401
  if (n > 0) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
@@ -574,7 +594,12 @@ function recommendModels(hw) {
574
594
  // gnome-terminal --working-directory={dir} -- claude "{prompt}"
575
595
  // Not tied to any one tool — you decide what runs.
576
596
  const IDE_LIST = [["code", "VS Code"], ["cursor", "Cursor"], ["windsurf", "Windsurf"], ["zed", "Zed"], ["subl", "Sublime Text"], ["idea", "IntelliJ IDEA"], ["nvim", "Neovim"]];
577
- 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
+ ];
578
603
  function detectHandoffs() {
579
604
  const editors = IDE_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label]) => ({ label, tmpl: `${cmd} {dir}`, kind: "editor" }));
580
605
  const agents = AGENT_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label, tmpl]) => ({ label, tmpl, kind: "agent" }));
@@ -779,7 +804,7 @@ function driftRepo(p, opts = {}) {
779
804
  }
780
805
  function computeDrift(opts = {}) {
781
806
  const deploys = loadDeploys();
782
- 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 }));
783
808
  return { repos, ci: !!opts.ci };
784
809
  }
785
810
  function cmdDrift() {
@@ -812,9 +837,10 @@ const MANIFEST_TOOL = {
812
837
  "terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
813
838
  };
814
839
  function findAllRepos(base) {
815
- const repos = [];
816
- for (const g of findGitDirs(base, 120)) {
817
- 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);
818
844
  const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
819
845
  if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
820
846
  }
@@ -825,7 +851,7 @@ function findAllRepos(base) {
825
851
  const key = cd.startsWith("/") ? cd : join(r.path, cd);
826
852
  if (!byCommon[key] || byCommon[key].recency < r.recency) byCommon[key] = r;
827
853
  }
828
- 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);
829
855
  }
830
856
  function detectRepo(r) {
831
857
  const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -3000`).split("\n").filter(Boolean);
@@ -843,9 +869,31 @@ function detectRepo(r) {
843
869
  const last = sh(`git -C ${JSON.stringify(r.path)} log -1 --format=%cd --date=short 2>/dev/null`).trim();
844
870
  return { ...r, langs, tools: [...tools], mine, files: files.length, branch, last };
845
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
+ }
846
894
  function buildMap() {
847
895
  const who = me();
848
- const repos = findAllRepos(BASE).map(detectRepo);
896
+ const repos = findAllRepos().map(detectRepo);
849
897
  const nodes = []; const edges = []; const have = new Set();
850
898
  const add = (n) => { if (!have.has(n.id)) { have.add(n.id); nodes.push(n); } };
851
899
  add({ id: "me", type: "person", label: who.name || "You", weight: 22 });
@@ -857,6 +905,17 @@ function buildMap() {
857
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 }); }
858
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 }); }
859
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 {}
860
919
  // What you build WITH (person-level, not per-repo): detected agents/editors
861
920
  // and the AI currently powering Symbiot — so the initial scan shows the whole
862
921
  // setup, not just code. All local detection, nothing invasive.
@@ -872,6 +931,7 @@ function buildMap() {
872
931
  } catch {}
873
932
  const out = { nodes, edges, stats: {
874
933
  repos: repos.length,
934
+ folders: nodes.filter((n) => n.type === "folder").length,
875
935
  languages: nodes.filter((n) => n.type === "lang").length,
876
936
  tools: nodes.filter((n) => n.type === "tool").length,
877
937
  agents: nodes.filter((n) => n.type === "agent").length,
@@ -905,6 +965,10 @@ function nodeDetail(id) {
905
965
  const n = map.nodes.find((x) => x.id === id);
906
966
  return { type: id.startsWith("agent:") ? "agent" : "ai", label: n ? n.label : id.split(":")[1], meta: (n && n.meta) || {} };
907
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
+ }
908
972
  return { error: "unknown node" };
909
973
  }
910
974
  // AI review of one repo: what it does, what it's for, possible upgrades.
@@ -1250,6 +1314,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1250
1314
  <span class="lg"><i style="background:#6bb3ff"></i>tools</span>
1251
1315
  <span class="lg"><i style="background:#c58af9"></i>agents</span>
1252
1316
  <span class="lg"><i style="background:#5fe3b0"></i>AI</span>
1317
+ <span class="lg"><i style="background:#b7a98c"></i>folders (no git)</span>
1253
1318
  <span class="muted" style="margin-left:8px">scroll to zoom &middot; drag to pan &middot; click a node</span>
1254
1319
  <button class="ghost" id="remap" style="margin-left:auto">Rescan</button>
1255
1320
  </div>
@@ -1297,6 +1362,12 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1297
1362
  <div class="row" style="margin-top:16px"><button class="act" id="save">Save &amp; connect</button>
1298
1363
  <span class="note" id="saveMsg"></span></div>
1299
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">
1300
1371
  <label>Hand off to your agent when you "Send to repos"</label>
1301
1372
  <input id="agentcmd" type="text" placeholder="e.g. claude -p &quot;{prompt}&quot; · code {dir} · leave blank to just write the file">
1302
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>
@@ -1318,7 +1389,7 @@ var KEYURL={anthropic:'https://console.anthropic.com/settings/keys',openai:'http
1318
1389
  var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
1319
1390
  function $(id){return document.getElementById(id);}
1320
1391
  var current='map';var mapLoaded=false;var driftLoaded=false;
1321
- var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff',agent:'#c58af9',ai:'#5fe3b0'};
1392
+ var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff',agent:'#c58af9',ai:'#5fe3b0',folder:'#b7a98c'};
1322
1393
  function tabs(){return document.querySelectorAll('.tab');}
1323
1394
  function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
1324
1395
  var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isRun=(tab==='week'||tab==='standup'||tab==='todo');
@@ -1374,6 +1445,7 @@ document.getElementById('suggest').addEventListener('click',function(){var o=doc
1374
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;}
1375
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;}
1376
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;}
1377
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;}}
1378
1450
  var reviewCache={};var IDEAS=[];var IREPO="";
1379
1451
  function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
@@ -1470,6 +1542,11 @@ if(!chips.length){box.innerHTML="<span class='muted' style='font-size:12px'>Noth
1470
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("");
1471
1543
  box.querySelectorAll('.preset').forEach(function(btn){btn.addEventListener('click',function(){document.getElementById('agentcmd').value=chips[+btn.getAttribute('data-i')].tmpl;saveAgent();});});});}
1472
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;});}
1473
1550
  function setupLocalUI(){var o=document.getElementById('setupout');o.innerHTML="Setting up a local model&hellip;";
1474
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;}
1475
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>";});}
@@ -1502,13 +1579,15 @@ h+="</div>";out.innerHTML=h;});}
1502
1579
  document.getElementById('remap').addEventListener('click',loadMap);
1503
1580
  document.getElementById('recbtn').addEventListener('click',loadRec);
1504
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();});
1505
1584
  document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
1506
1585
  document.getElementById('agentsrefresh').addEventListener('click',loadAgents);
1507
1586
  document.getElementById('addtask').addEventListener('click',addTaskUI);
1508
1587
  document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
1509
1588
  document.getElementById('agentcmd').addEventListener('change',saveAgent);
1510
1589
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
1511
- initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();
1590
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();
1512
1591
  </script></body></html>`;
1513
1592
 
1514
1593
  function readBody(req) {
@@ -1574,6 +1653,9 @@ async function cmdApp() {
1574
1653
  if (u.pathname === "/api/tasks/restore" && req.method === "POST") { const b = await readBody(req); return json(res, restoreTask(String(b.id || ""))); }
1575
1654
  if (u.pathname === "/api/tasks/sync" && req.method === "POST") return json(res, syncTasks());
1576
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 || ""))); }
1577
1659
  if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
1578
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 || "" }); }
1579
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 : "" }); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.17.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": {