symbiot 0.11.0 → 0.17.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 +280 -27
  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,45 @@ 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").map((n) => ({ name: n.label, path: n.meta && n.meta.path })) : findAllRepos(BASE);
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
+ }
132
+ // Deterministic task classification (no model) so tasks batch by kind instead
133
+ // of arriving as a flat, mixed pile. Order matters (most specific first).
134
+ const TASK_ORDER = ["Fixes", "Tests & CI", "Security", "Performance", "Refactor", "UI/UX", "Docs", "Features & other"];
135
+ function taskType(text) {
136
+ const s = String(text || "").toLowerCase();
137
+ if (/\b(test|tests|spec|coverage|smoke|fixture|ci\b)/.test(s)) return "Tests & CI";
138
+ if (/\b(secur|auth|token|secret|vulnerab|permission|sanitiz|escap|injection)/.test(s)) return "Security";
139
+ if (/\b(readme|docs?|document|changelog|comment|guide)/.test(s)) return "Docs";
140
+ if (/\b(perf|performance|slow|optimi|latency|throughput|speed up|memory leak)/.test(s)) return "Performance";
141
+ if (/\b(refactor|simplify|split|extract|clean ?up|consolidat|dedupe|modular|rename)/.test(s)) return "Refactor";
142
+ if (/\b(bug|fix|broken|error|crash|regress|hang|race|leak)/.test(s)) return "Fixes";
143
+ if (/\b(ui|ux|design|layout|style|button|screen|view\b|page\b|responsive)/.test(s)) return "UI/UX";
144
+ return "Features & other";
145
+ }
106
146
  // Push tasks into the repos as an agent-readable brief (.symbiot/TASKS.md):
107
147
  // a checklist plus the deterministic context an agent needs to get oriented.
108
148
  function buildTasksMd(name, ctx, list) {
@@ -115,12 +155,16 @@ function buildTasksMd(name, ctx, list) {
115
155
  if (ctx.open && ctx.open.length) { L.push("- **Open markers (TODO/FIXME + uncommitted):**"); for (const o of ctx.open) L.push(` - ${o}`); }
116
156
  if (ctx.drift && ctx.drift.length) { L.push("- **Current drift / risk:**"); for (const d of ctx.drift) L.push(` - ${d}`); }
117
157
  L.push("", "## Tasks");
118
- for (const t of list) L.push(`- [ ] ${t.text}`);
119
- 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."');
158
+ const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
159
+ const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
160
+ for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
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."');
120
162
  return L.join("\n") + "\n";
121
163
  }
122
- function pushTasks() {
123
- 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);
124
168
  if (!tasks.length) return { empty: true, written: [], unresolved: [] };
125
169
  // Resolve repo name -> path from the already-scanned map when we have it
126
170
  // (avoids a fresh full scan); fall back to a scan only if needed.
@@ -147,17 +191,22 @@ function pushTasks() {
147
191
  const dir = join(path, ".symbiot"); mkdirSync(dir, { recursive: true });
148
192
  const file = join(dir, "TASKS.md");
149
193
  writeFileSync(file, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack }, list));
150
- written.push({ name, file, count: list.length });
194
+ written.push({ name, file, path, count: list.length });
151
195
  } catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
152
196
  }
153
- return { empty: false, written, unresolved };
197
+ return { empty: false, written, unresolved, handoff: handoffCmd() };
154
198
  }
155
199
  function cmdPush() {
156
200
  const r = pushTasks();
157
201
  if (r.empty) { console.log(c.y("No open tasks to push.") + c.d(" Add some in `symbiot app` — a repo review's ideas, or the Tasks tab.")); return; }
158
202
  if (r.written.length) { console.log("\n" + c.g("●") + " " + c.b("Pushed tasks into repos:")); for (const w of r.written) console.log(` ${c.g("✓")} ${w.name} ${c.d(w.file + " (" + w.count + " task" + (w.count > 1 ? "s" : "") + ")")}`); }
159
203
  if (r.unresolved.length) { console.log("\n" + c.y(`Not written (repo not found under ${BASE}):`)); for (const u of r.unresolved) console.log(` · ${u.name} (${u.count})`); }
160
- console.log("\n" + c.d("Open each repo in your IDE/agent and point it at .symbiot/TASKS.md."));
204
+ if (has("open")) {
205
+ if (!r.handoff) console.log("\n" + c.y("No agent command set.") + c.d(" Set one in `symbiot app` Settings, or `agentCmd` in ~/.config/symbiot/config.json (use {dir} and {prompt})."));
206
+ else { for (const w of r.written) runHandoff(w.path); console.log("\n" + c.g("→ ") + `Handed ${r.written.length} repo(s) to your agent (${r.handoff}).`); }
207
+ } else {
208
+ console.log("\n" + c.d("Point your agent at .symbiot/TASKS.md in each repo. (add --open to run your configured agent command)"));
209
+ }
161
210
  }
162
211
  function envKey(provider) {
163
212
  for (const e of (PROVIDERS[provider].env || [])) if (process.env[e]) return process.env[e];
@@ -432,7 +481,10 @@ async function callOllama(r, system, prompt) {
432
481
  body: JSON.stringify({ model: r.model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
433
482
  });
434
483
  const text = await res.text();
435
- if (!res.ok) throw new Error(`Ollama ${res.status}: ${text.slice(0, 200)}`);
484
+ if (!res.ok) {
485
+ 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)`);
486
+ throw new Error(`Ollama ${res.status}: ${text.slice(0, 200)}`);
487
+ }
436
488
  return (JSON.parse(text).message?.content || "").trim();
437
489
  }
438
490
 
@@ -515,6 +567,86 @@ function recommendModels(hw) {
515
567
  const best = (local.slice().reverse().find((m) => m.fits) || local[0]).model;
516
568
  return { local, paid, best };
517
569
  }
570
+ // ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
571
+ // The handoff is a command TEMPLATE the user configures, with {dir} (repo path)
572
+ // and {prompt} (the task instruction). Works for any agent/editor:
573
+ // claude -p "{prompt}" · aider --message "{prompt}" · code {dir}
574
+ // gnome-terminal --working-directory={dir} -- claude "{prompt}"
575
+ // Not tied to any one tool — you decide what runs.
576
+ 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}"']];
578
+ function detectHandoffs() {
579
+ const editors = IDE_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label]) => ({ label, tmpl: `${cmd} {dir}`, kind: "editor" }));
580
+ const agents = AGENT_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label, tmpl]) => ({ label, tmpl, kind: "agent" }));
581
+ // Orca IDE: its own CLI (not the ~/.local/bin GPU wrapper, which ignores args).
582
+ // Registers the repo, then opens a terminal tab running the agent — Orca shows
583
+ // the live session itself.
584
+ const orca = join(homedir(), ".local/share/orca-ide/app/resources/bin/orca-ide");
585
+ 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" });
586
+ return { agents, editors };
587
+ }
588
+ const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
589
+ const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
590
+ const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Confirm before anything destructive.";
591
+ function handoffCmd() { const cfg = loadConfig(); return cfg.agentCmd || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
592
+ // Run a shell command as a tracked, logged background job that shows up live in
593
+ // the Agents tab. Shared by the agent handoff and the local-model setup.
594
+ function track(name, cmd, cwd, onExit) {
595
+ try {
596
+ const dir = join(cwd, ".symbiot"); mkdirSync(dir, { recursive: true });
597
+ const logp = join(dir, "agent.log");
598
+ let fd = "ignore"; try { fd = openSync(logp, "a"); writeSync(fd, `\n=== ${name} ${new Date().toISOString()} ===\n$ ${cmd}\n`); } catch {}
599
+ const entry = { id: randomBytes(4).toString("hex"), name, path: cwd, log: logp, startedAt: Date.now(), status: "running", exitCode: null, endedAt: null };
600
+ const child = spawn(cmd, { shell: true, cwd, detached: true, stdio: ["ignore", fd === "ignore" ? "ignore" : fd, fd === "ignore" ? "ignore" : fd] });
601
+ entry.pid = child.pid;
602
+ child.on("exit", (code) => { entry.status = code === 0 ? "done" : "failed"; entry.exitCode = code; entry.endedAt = Date.now(); if (onExit) try { onExit(code); } catch {} });
603
+ child.on("error", () => { entry.status = "failed"; entry.endedAt = Date.now(); });
604
+ child.unref();
605
+ HANDOFFS.unshift(entry);
606
+ if (HANDOFFS.length > 30) HANDOFFS.length = 30;
607
+ return entry;
608
+ } catch { return null; }
609
+ }
610
+ function runHandoff(repoPath) {
611
+ const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
612
+ const cmd = tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
613
+ return track(repoPath.split("/").pop(), cmd, repoPath);
614
+ }
615
+ // ---- local model one-command setup (Ollama), OS-aware ---------------------
616
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
617
+ function hasOllama() { return !!sh("ollama --version").trim(); } // cross-platform (not POSIX command -v)
618
+ function ollamaInstall() {
619
+ if (process.platform === "darwin") return { cmd: "brew install ollama", alt: "https://ollama.com/download" };
620
+ if (process.platform === "win32") return { cmd: "winget install Ollama.Ollama", alt: "https://ollama.com/download" };
621
+ return { cmd: "curl -fsSL https://ollama.com/install.sh | sh", alt: "https://ollama.com/download" };
622
+ }
623
+ async function ollamaUp() { try { const r = await fetch("http://127.0.0.1:11434/api/tags"); return r.ok; } catch { return false; } }
624
+ async function ensureOllama() {
625
+ if (await ollamaUp()) return true;
626
+ try { spawn("ollama", ["serve"], { detached: true, stdio: "ignore" }).unref(); } catch {}
627
+ for (let i = 0; i < 16; i++) { await sleep(500); if (await ollamaUp()) return true; }
628
+ return false;
629
+ }
630
+ function useOllamaModel(model) { const cfg = loadConfig(); cfg.provider = "ollama"; cfg.ollama = { baseUrl: "http://localhost:11434", model }; delete cfg.apiKey; saveConfig(cfg); }
631
+ async function cmdSetupLocal() {
632
+ const model = flag("model", null) || recommendModels(detectHardware()).best;
633
+ if (!hasOllama()) {
634
+ const inst = ollamaInstall();
635
+ console.log("\n" + c.y("Ollama isn't installed") + c.d(" — the free, private local-model runner.") + "\n");
636
+ console.log(` On ${process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux"}, install it then re-run ${c.b("symbiot setup-local")}:`);
637
+ console.log(" " + c.b(inst.cmd));
638
+ console.log(c.d(" or download: " + inst.alt));
639
+ return;
640
+ }
641
+ process.stdout.write(c.d("Starting Ollama… "));
642
+ if (!(await ensureOllama())) { console.log(c.y("couldn't reach it — start it with ") + c.b("ollama serve")); return; }
643
+ console.log(c.g("ready"));
644
+ console.log("\n" + c.b("Pulling " + model) + c.d(" (first time downloads a few GB — progress below)\n"));
645
+ const code = await new Promise((res) => { const ch = spawn("ollama", ["pull", model], { stdio: "inherit" }); ch.on("close", res); ch.on("error", () => res(1)); });
646
+ 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; }
647
+ useOllamaModel(model);
648
+ console.log("\n" + c.g("✓ ") + `Symbiot now runs on ${model} locally — free & private. Try: ` + c.b("symbiot week"));
649
+ }
518
650
  function cmdModels() {
519
651
  const hw = detectHardware(); const rec = recommendModels(hw);
520
652
  const pad = (s, n) => String(s).padEnd(n);
@@ -725,10 +857,24 @@ function buildMap() {
725
857
  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 }); }
726
858
  for (const T of r.tools) { const id = "tool:" + T; add({ id, type: "tool", label: T, weight: 12 }); edges.push({ source: rid, target: id }); }
727
859
  }
860
+ // What you build WITH (person-level, not per-repo): detected agents/editors
861
+ // and the AI currently powering Symbiot — so the initial scan shows the whole
862
+ // setup, not just code. All local detection, nothing invasive.
863
+ try {
864
+ const hands = detectHandoffs();
865
+ for (const a of [...hands.agents, ...hands.editors]) {
866
+ const id = "agent:" + a.label;
867
+ add({ id, type: "agent", label: a.label.replace(/\s*\(.*\)$/, ""), weight: 11, meta: { cmd: a.tmpl, kind: a.kind } });
868
+ edges.push({ source: "me", target: id });
869
+ }
870
+ const prov = resolveProvider();
871
+ 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 }); }
872
+ } catch {}
728
873
  const out = { nodes, edges, stats: {
729
874
  repos: repos.length,
730
875
  languages: nodes.filter((n) => n.type === "lang").length,
731
876
  tools: nodes.filter((n) => n.type === "tool").length,
877
+ agents: nodes.filter((n) => n.type === "agent").length,
732
878
  commits: repos.reduce((s, r) => s + r.mine, 0),
733
879
  files: repos.reduce((s, r) => s + (r.files || 0), 0),
734
880
  base: BASE,
@@ -755,6 +901,10 @@ function nodeDetail(id) {
755
901
  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);
756
902
  return { type: id.startsWith("lang:") ? "lang" : "tool", label: n ? n.label : id.split(":")[1], repos };
757
903
  }
904
+ if (id.startsWith("agent:") || id.startsWith("ai:")) {
905
+ const n = map.nodes.find((x) => x.id === id);
906
+ return { type: id.startsWith("agent:") ? "agent" : "ai", label: n ? n.label : id.split(":")[1], meta: (n && n.meta) || {} };
907
+ }
758
908
  return { error: "unknown node" };
759
909
  }
760
910
  // AI review of one repo: what it does, what it's for, possible upgrades.
@@ -1042,6 +1192,13 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1042
1192
  .task .rp{font-size:11px;color:var(--faint);background:var(--ink3);border:1px solid var(--line);border-radius:999px;padding:2px 8px}
1043
1193
  .task .rm{background:none;border:0;color:var(--faint);cursor:pointer;font-size:18px;line-height:1}
1044
1194
  .task .rm:hover{color:var(--amber)}
1195
+ .tgroup{margin:14px 0 4px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--green);font-weight:700}
1196
+ .tcount{color:var(--faint);font-weight:400}
1197
+ .taskfilter{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:10px 0 2px}
1198
+ .fl{font-size:10px;color:var(--faint);text-transform:uppercase;letter-spacing:.12em;margin:0 2px 0 8px}
1199
+ .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}
1200
+ .fchip.on{background:var(--green-dim);border-color:#2a6b52;color:var(--green)}
1201
+ .task .rm.restore{color:var(--green);font-size:15px}
1045
1202
  .drift{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
1046
1203
  .drift .dh{display:flex;gap:8px;align-items:center}
1047
1204
  .drift .dn{color:var(--bone);font-weight:600}
@@ -1052,6 +1209,15 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1052
1209
  .drift .ev{color:var(--faint);font-family:ui-monospace,Menlo,monospace;font-size:11px}
1053
1210
  .dot-w{width:9px;height:9px;border-radius:50%;background:var(--amber);display:inline-block}
1054
1211
  .dot-c{width:9px;height:9px;border-radius:50%;background:var(--green);display:inline-block}
1212
+ .agent{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
1213
+ .adot{width:10px;height:10px;border-radius:50%;display:inline-block;flex:none}
1214
+ .adot.run{background:var(--amber);animation:pulse 1s ease-in-out infinite}
1215
+ .adot.ok{background:var(--green)} .adot.fail{background:#E5695B}
1216
+ @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)}}
1217
+ .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}
1218
+ .bar{height:3px;background:var(--green-dim);border-radius:2px;overflow:hidden;margin-top:8px}
1219
+ .bar > i{display:block;height:100%;width:35%;background:var(--green);border-radius:2px;animation:slide 1.3s ease-in-out infinite}
1220
+ @keyframes slide{0%{margin-left:-35%}100%{margin-left:100%}}
1055
1221
  .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)}
1056
1222
  .legend{display:flex;gap:14px;align-items:center;margin-top:10px;font-size:12px;color:var(--faint);flex-wrap:wrap}
1057
1223
  .lg{display:inline-flex;gap:6px;align-items:center}
@@ -1066,6 +1232,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1066
1232
  <button class="tab" data-tab="standup">Standup</button>
1067
1233
  <button class="tab" data-tab="todo">Todo</button>
1068
1234
  <button class="tab" data-tab="tasks">Tasks</button>
1235
+ <button class="tab" data-tab="agents">Agents</button>
1069
1236
  <button class="tab" data-tab="settings">Settings</button>
1070
1237
  </div>
1071
1238
  <main>
@@ -1081,6 +1248,8 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1081
1248
  <span class="lg"><i style="background:var(--bone)"></i>repos</span>
1082
1249
  <span class="lg"><i style="background:var(--amber)"></i>languages</span>
1083
1250
  <span class="lg"><i style="background:#6bb3ff"></i>tools</span>
1251
+ <span class="lg"><i style="background:#c58af9"></i>agents</span>
1252
+ <span class="lg"><i style="background:#5fe3b0"></i>AI</span>
1084
1253
  <span class="muted" style="margin-left:8px">scroll to zoom &middot; drag to pan &middot; click a node</span>
1085
1254
  <button class="ghost" id="remap" style="margin-left:auto">Rescan</button>
1086
1255
  </div>
@@ -1095,9 +1264,15 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1095
1264
  </section>
1096
1265
  <section id="panel-tasks" class="hidden">
1097
1266
  <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>
1267
+ <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>
1098
1268
  <div id="pushout"></div>
1269
+ <div id="taskfilter" class="taskfilter"></div>
1099
1270
  <div id="tasklist"></div>
1100
1271
  </section>
1272
+ <section id="panel-agents" class="hidden">
1273
+ <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>
1274
+ <div id="agentslist"></div>
1275
+ </section>
1101
1276
  <section id="panel-drift" class="hidden">
1102
1277
  <div class="row"><span class="muted">What's out of sync, stuck or at risk across your repos — local git facts.</span>
1103
1278
  <label class="muted" style="margin-left:auto"><input type="checkbox" id="driftfetch"> fetch latest</label>
@@ -1122,7 +1297,15 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1122
1297
  <div class="row" style="margin-top:16px"><button class="act" id="save">Save &amp; connect</button>
1123
1298
  <span class="note" id="saveMsg"></span></div>
1124
1299
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
1300
+ <label>Hand off to your agent when you "Send to repos"</label>
1301
+ <input id="agentcmd" type="text" placeholder="e.g. claude -p &quot;{prompt}&quot; · code {dir} · leave blank to just write the file">
1302
+ <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>
1303
+ <div id="agentpresets" style="margin-top:8px"></div>
1304
+ </div>
1305
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
1125
1306
  <button class="ghost" id="recbtn">Recommend models for my machine</button>
1307
+ <button class="ghost" id="setuplocal" style="margin-left:8px">Set up a free local model</button>
1308
+ <div id="setupout" class="note muted" style="margin-top:10px"></div>
1126
1309
  <div id="recout" style="margin-top:12px"></div>
1127
1310
  </div>
1128
1311
  </section>
@@ -1135,19 +1318,21 @@ var KEYURL={anthropic:'https://console.anthropic.com/settings/keys',openai:'http
1135
1318
  var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
1136
1319
  function $(id){return document.getElementById(id);}
1137
1320
  var current='map';var mapLoaded=false;var driftLoaded=false;
1138
- var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff'};
1321
+ var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff',agent:'#c58af9',ai:'#5fe3b0'};
1139
1322
  function tabs(){return document.querySelectorAll('.tab');}
1140
1323
  function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
1141
- var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isRun=(tab==='week'||tab==='standup'||tab==='todo');
1324
+ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isRun=(tab==='week'||tab==='standup'||tab==='todo');
1142
1325
  $('panel-map').classList.toggle('hidden',!isMap);
1143
1326
  $('panel-run').classList.toggle('hidden',!isRun);
1144
1327
  $('panel-settings').classList.toggle('hidden',!isSet);
1145
1328
  $('panel-tasks').classList.toggle('hidden',!isTasks);
1146
1329
  $('panel-drift').classList.toggle('hidden',!isDrift);
1330
+ $('panel-agents').classList.toggle('hidden',!isAgents);
1147
1331
  if(isRun){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');}
1148
1332
  if(isMap&&!mapLoaded)loadMap();
1149
1333
  if(isTasks)loadTasks();
1150
- if(isDrift&&!driftLoaded)loadDrift();}
1334
+ if(isDrift&&!driftLoaded)loadDrift();
1335
+ if(isAgents)loadAgents(); else stopAgentsPoll();}
1151
1336
  tabs().forEach(function(t){t.addEventListener('click',function(){setTab(t.dataset.tab);});});
1152
1337
  function refresh(){api('/api/status').then(function(s){$('status').textContent=s.connected?s.line:'Not connected - open Settings';});}
1153
1338
  $('write').addEventListener('click',function(){$('out').textContent='Writing...';$('out').classList.add('muted');$('copy').classList.add('hidden');
@@ -1187,7 +1372,9 @@ if(d.type==='repo'){var chips="";['branch: '+(d.branch||'?'),d.commits+' commits
1187
1372
  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>";
1188
1373
  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;}
1189
1374
  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;}
1190
- 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;}}
1375
+ 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
+ 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;}
1377
+ 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;}}
1191
1378
  var reviewCache={};var IDEAS=[];var IREPO="";
1192
1379
  function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
1193
1380
  function reviewHtml(name,body,verdict,ideas,tasks,footer){IDEAS=ideas||[];IREPO=name;
@@ -1210,13 +1397,48 @@ el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h
1210
1397
  Promise.all([api('/api/review',{path:path}),api('/api/tasks')]).then(function(res){var r=res[0]||{},tasks=res[1]||[];
1211
1398
  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;}
1212
1399
  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();});}
1213
- 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;}
1214
- var h="";list.forEach(function(t){h+="<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>";});
1215
- el.innerHTML=h;
1216
- el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
1217
- row.querySelector('.taskchk').addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
1218
- row.querySelector('.rm').addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();if(!el.querySelector('.task'))renderTasks([]);});});});}
1219
- function loadTasks(){api('/api/tasks').then(renderTasks);}
1400
+ var ALLTASKS=[];var TFILTER={type:'',repo:''};var TARCH=false;
1401
+ var TORDER=['Fixes','Tests & CI','Security','Performance','Refactor','UI/UX','Docs','Features & other'];
1402
+ 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>";}
1403
+ function wireTaskRows(el){el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
1404
+ var cb=row.querySelector('.taskchk');if(cb)cb.addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
1405
+ var rm=row.querySelector('.rm');if(rm&&rm.classList.contains('restore'))rm.addEventListener('click',function(){api('/api/tasks/restore',{id:id}).then(loadArchived);});
1406
+ else if(rm)rm.addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();});});});}
1407
+ 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>";}
1408
+ function renderFilter(){var box=document.getElementById('taskfilter');
1409
+ if(TARCH){box.innerHTML="<button class='fchip on' id='archtoggle'>&#8617; back to active</button>";document.getElementById('archtoggle').addEventListener('click',function(){loadTasks();});return;}
1410
+ var types={},repos={};ALLTASKS.forEach(function(t){types[t.type||'Features & other']=1;if(t.repo)repos[t.repo]=1;});
1411
+ 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('');
1412
+ fb+="<span class='fl'>Repo</span>"+fchip('repo','','All')+Object.keys(repos).sort().map(function(r){return fchip('repo',r,r);}).join('');
1413
+ fb+="<button class='fchip' id='archtoggle' style='margin-left:auto'>&#128451; archived</button>";
1414
+ box.innerHTML=fb;
1415
+ box.querySelectorAll('.fchip[data-dim]').forEach(function(c){c.addEventListener('click',function(){TFILTER[c.getAttribute('data-dim')]=c.getAttribute('data-val');renderTasks();});});
1416
+ document.getElementById('archtoggle').addEventListener('click',loadArchived);}
1417
+ function renderTasks(){var el=document.getElementById('tasklist');TARCH=false;renderFilter();
1418
+ var list=ALLTASKS.filter(function(t){return (!TFILTER.type||(t.type||'Features & other')===TFILTER.type)&&(!TFILTER.repo||t.repo===TFILTER.repo);});
1419
+ 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;}
1420
+ var groups={};list.forEach(function(t){var ty=t.type||'Features & other';(groups[ty]=groups[ty]||[]).push(t);});
1421
+ 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);});
1422
+ 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);});});
1423
+ el.innerHTML=h;wireTaskRows(el);}
1424
+ function loadTasks(){TARCH=false;api('/api/tasks/sync',{}).then(function(){api('/api/tasks').then(function(list){ALLTASKS=list;renderTasks();});});}
1425
+ function loadArchived(){TARCH=true;api('/api/tasks?archived=1').then(function(list){ALLTASKS=list;renderFilter();var el=document.getElementById('tasklist');
1426
+ if(!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>Nothing archived yet. Completed tasks land here.</div>";return;}
1427
+ 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);});}
1428
+ var agentsTimer=null;
1429
+ function stopAgentsPoll(){if(agentsTimer){clearTimeout(agentsTimer);agentsTimer=null;}}
1430
+ 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';}
1431
+ function loadAgents(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
1432
+ 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;}
1433
+ el.innerHTML=list.map(function(a){var cls=a.status==='running'?'run':(a.status==='done'?'ok':'fail');
1434
+ var st=a.status==='running'?('working &middot; '+fmtE(a.elapsed)):(esc(a.status)+' &middot; '+fmtE(a.elapsed)+(a.exitCode!=null?' &middot; exit '+a.exitCode:''));
1435
+ var b="<div class='dh'><span class='adot "+cls+"'></span><span class='dn'>"+esc(a.name)+"</span><span class='dd'>"+st+"</span></div>";
1436
+ if(a.status==='running')b+="<div class='bar'><i></i></div>";
1437
+ b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
1438
+ return "<div class='agent'>"+b+"</div>";}).join("");
1439
+ el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
1440
+ var anyRunning=list.some(function(a){return a.status==='running';});
1441
+ stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=setTimeout(loadAgents,2000);});}
1220
1442
  function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
1221
1443
  var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
1222
1444
  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';});});
@@ -1232,13 +1454,25 @@ out.innerHTML=h;});}
1232
1454
  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();});}
1233
1455
  function pushTasksUI(){var o=document.getElementById('pushout');var btn=document.getElementById('pushtasks');btn.disabled=true;
1234
1456
  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>";
1235
- api('/api/tasks/push',{}).then(function(r){btn.disabled=false;
1457
+ api('/api/tasks/push',{type:TFILTER.type||'',repo:TFILTER.repo||''}).then(function(r){btn.disabled=false;
1236
1458
  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;}
1237
1459
  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>";
1238
1460
  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>";}
1239
1461
  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>";}
1240
- h+="<div class='dd' style='margin-top:8px'>Now tell your coding agent, in each repo: <b>“Read .symbiot/TASKS.md and implement the unchecked items.”</b></div></div>";
1462
+ 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);}
1463
+ 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>";}
1464
+ h+="</div>";
1241
1465
  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>";});}
1466
+ function loadAgentCfg(){api('/api/agentcfg').then(function(d){document.getElementById('agentcmd').value=d.cmd||'';
1467
+ var chips=[];(d.agents||[]).forEach(function(a){chips.push(a);});(d.editors||[]).forEach(function(e){chips.push(e);});
1468
+ var box=document.getElementById('agentpresets');
1469
+ if(!chips.length){box.innerHTML="<span class='muted' style='font-size:12px'>Nothing detected on PATH &mdash; type your own command above.</span>";return;}
1470
+ 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
+ box.querySelectorAll('.preset').forEach(function(btn){btn.addEventListener('click',function(){document.getElementById('agentcmd').value=chips[+btn.getAttribute('data-i')].tmpl;saveAgent();});});});}
1472
+ function saveAgent(){api('/api/agentcmd',{cmd:document.getElementById('agentcmd').value});}
1473
+ function setupLocalUI(){var o=document.getElementById('setupout');o.innerHTML="Setting up a local model&hellip;";
1474
+ 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
+ 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>";});}
1242
1476
  function selectNode(id){sel=id;render();updateBar(id);var n=nodeById(id);api('/api/node?id='+encodeURIComponent(id)).then(showDetail);
1243
1477
  if(n&&n.type==='repo'&&n.meta&&n.meta.path){loadReview(n.label,n.meta.path);}else{hideReview();}}
1244
1478
  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};}
@@ -1267,11 +1501,14 @@ rec.paid.forEach(function(p){h+="<div class='idea'><span><b>"+esc(p.provider)+"<
1267
1501
  h+="</div>";out.innerHTML=h;});}
1268
1502
  document.getElementById('remap').addEventListener('click',loadMap);
1269
1503
  document.getElementById('recbtn').addEventListener('click',loadRec);
1504
+ document.getElementById('setuplocal').addEventListener('click',setupLocalUI);
1270
1505
  document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
1506
+ document.getElementById('agentsrefresh').addEventListener('click',loadAgents);
1271
1507
  document.getElementById('addtask').addEventListener('click',addTaskUI);
1272
1508
  document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
1509
+ document.getElementById('agentcmd').addEventListener('change',saveAgent);
1273
1510
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
1274
- initGraphEvents();syncP();refresh();loadMap();
1511
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();
1275
1512
  </script></body></html>`;
1276
1513
 
1277
1514
  function readBody(req) {
@@ -1330,11 +1567,25 @@ async function cmdApp() {
1330
1567
  if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
1331
1568
  if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
1332
1569
  if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
1333
- if (u.pathname === "/api/tasks" && req.method !== "POST") return json(res, loadTasks());
1570
+ 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) }))); }
1334
1571
  if (u.pathname === "/api/tasks/add" && req.method === "POST") { const b = await readBody(req); return json(res, addTask(b.text, b.repo)); }
1335
1572
  if (u.pathname === "/api/tasks/toggle" && req.method === "POST") { const b = await readBody(req); return json(res, toggleTask(String(b.id || ""))); }
1336
1573
  if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
1337
- if (u.pathname === "/api/tasks/push" && req.method === "POST") return json(res, pushTasks());
1574
+ if (u.pathname === "/api/tasks/restore" && req.method === "POST") { const b = await readBody(req); return json(res, restoreTask(String(b.id || ""))); }
1575
+ if (u.pathname === "/api/tasks/sync" && req.method === "POST") return json(res, syncTasks());
1576
+ if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
1577
+ if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
1578
+ 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
+ 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 : "" }); }
1580
+ if (u.pathname === "/api/setup-local" && req.method === "POST") {
1581
+ const b = await readBody(req);
1582
+ if (!hasOllama()) return json(res, { error: "not-installed", install: ollamaInstall() });
1583
+ const model = String(b.model || "") || recommendModels(detectHardware()).best;
1584
+ await ensureOllama();
1585
+ const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
1586
+ return json(res, { started: true, model, id: e ? e.id : "" });
1587
+ }
1588
+ 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 }; }));
1338
1589
  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)); }
1339
1590
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
1340
1591
  if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
@@ -1358,8 +1609,9 @@ ${c.b("Usage")}
1358
1609
  symbiot todo what's still on your plate
1359
1610
  symbiot app open the visual app in your browser
1360
1611
  symbiot drift what's out of sync / at risk across repos
1361
- symbiot push write your tasks into each repo for your agent
1612
+ symbiot push [--open] write tasks into each repo (and open your IDE)
1362
1613
  symbiot models recommend AI models for your hardware
1614
+ symbiot setup-local [--model X] install/run a free local model (Ollama)
1363
1615
  symbiot login connect it to an AI (once)
1364
1616
  symbiot whoami show how it's connected
1365
1617
  symbiot logout forget saved credentials
@@ -1389,6 +1641,7 @@ async function main() {
1389
1641
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
1390
1642
  if (cmd === "app" || cmd === "ui") return cmdApp();
1391
1643
  if (cmd === "models" || cmd === "hardware") return cmdModels();
1644
+ if (cmd === "setup-local" || cmd === "setup-ollama") return cmdSetupLocal();
1392
1645
  if (cmd === "drift") return cmdDrift();
1393
1646
  if (cmd === "push") return cmdPush();
1394
1647
  if (cmd === "week") return cmdRun("week");
@@ -1406,4 +1659,4 @@ const isMain = (() => {
1406
1659
  })();
1407
1660
  if (isMain) main();
1408
1661
 
1409
- export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, EMBEDDED_UI };
1662
+ export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.11.0",
3
+ "version": "0.17.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": {