symbiot 0.20.0 → 0.23.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 +113 -15
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -57,6 +57,8 @@ 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
59
  const HANDOFFS = []; // live registry of agents Symbiot has handed work to
60
+ let LATEST_VERSION = ""; // newest symbiot on npm, checked in the background
61
+ async function checkLatest() { try { const r = await fetch("https://registry.npmjs.org/symbiot"); if (!r.ok) return; const j = await r.json(); LATEST_VERSION = (j["dist-tags"] && j["dist-tags"].latest) || ""; } catch {} }
60
62
 
61
63
  // ---- colour + spinner ------------------------------------------------------
62
64
  const c = PLAIN
@@ -593,21 +595,57 @@ function recommendModels(hw) {
593
595
  // claude -p "{prompt}" · aider --message "{prompt}" · code {dir}
594
596
  // gnome-terminal --working-directory={dir} -- claude "{prompt}"
595
597
  // Not tied to any one tool — you decide what runs.
596
- const IDE_LIST = [["code", "VS Code"], ["cursor", "Cursor"], ["windsurf", "Windsurf"], ["zed", "Zed"], ["subl", "Sublime Text"], ["idea", "IntelliJ IDEA"], ["nvim", "Neovim"]];
598
+ // [cmd, label, macAppName] — macApp used to launch GUI editors on macOS where
599
+ // the CLI isn't on PATH (they're .app bundles).
600
+ const IDE_LIST = [["code", "VS Code", "Visual Studio Code"], ["cursor", "Cursor", "Cursor"], ["windsurf", "Windsurf", "Windsurf"], ["zed", "Zed", "Zed"], ["subl", "Sublime Text", "Sublime Text"], ["idea", "IntelliJ IDEA", "IntelliJ IDEA"], ["nvim", "Neovim", ""]];
597
601
  const AGENT_LIST = [
598
602
  ["claude", "Claude Code — make changes", 'claude -p "{prompt}" --permission-mode acceptEdits'],
599
603
  ["claude", "Claude Code — plan only (asks first)", 'claude -p "{prompt}"'],
604
+ ["codex", "Codex (OpenAI) — make changes", 'codex exec --full-auto "{prompt}"'],
600
605
  ["aider", "Aider — make changes", 'aider --message "{prompt}" --yes'],
601
606
  ["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
607
+ ["gemini", "Gemini CLI", 'gemini -p "{prompt}"'],
602
608
  ];
609
+ // Cross-platform "is this command available?" (command -v on posix, where on win).
610
+ function hasCmd(cmd) { try { return !!sh(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`).trim(); } catch { return false; } }
611
+ function macApp(name) { if (process.platform !== "darwin" || !name) return ""; for (const base of ["/Applications", join(homedir(), "Applications")]) { try { if (existsSync(join(base, name + ".app"))) return name; } catch {} } return ""; }
612
+ // Find the Orca IDE CLI across OSes (known locations, then a bounded search).
613
+ let ORCA_CLI; // cached per process: undefined=unchecked, ""=none, string=path
614
+ function findOrcaCli() {
615
+ if (ORCA_CLI !== undefined) return ORCA_CLI;
616
+ ORCA_CLI = _findOrcaCli();
617
+ return ORCA_CLI;
618
+ }
619
+ function _findOrcaCli() {
620
+ const home = homedir(); const cands = [];
621
+ if (process.platform === "linux") cands.push(join(home, ".local/share/orca-ide/app/resources/bin/orca-ide"));
622
+ if (process.platform === "darwin") { cands.push(join(home, "Library/Application Support/orca-ide/app/resources/bin/orca-ide"), "/Applications/Orca.app/Contents/Resources/app/resources/bin/orca-ide", join(home, "Applications/Orca.app/Contents/Resources/app/resources/bin/orca-ide")); }
623
+ if (process.platform === "win32") { const la = process.env.LOCALAPPDATA || ""; cands.push(join(la, "orca-ide", "app", "resources", "bin", "orca-ide"), join(la, "Programs", "orca-ide", "resources", "app", "resources", "bin", "orca-ide")); }
624
+ for (const c of cands) { try { if (existsSync(c)) return c; } catch {} }
625
+ const roots = process.platform === "darwin" ? [join(home, "Library/Application Support"), "/Applications", join(home, "Applications")]
626
+ : process.platform === "win32" ? [process.env.LOCALAPPDATA || "", process.env.PROGRAMFILES || ""]
627
+ : [join(home, ".local/share"), "/opt", join(home, ".config")];
628
+ for (const r of roots) { if (!r) continue; const hit = sh(`find ${JSON.stringify(r)} -maxdepth 6 -name orca-ide -type f 2>/dev/null | head -1`).trim(); if (hit) return hit; }
629
+ return "";
630
+ }
603
631
  function detectHandoffs() {
604
- const editors = IDE_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label]) => ({ label, tmpl: `${cmd} {dir}`, kind: "editor" }));
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" });
632
+ const editors = [];
633
+ for (const [cmd, label, app] of IDE_LIST) {
634
+ if (hasCmd(cmd)) editors.push({ label, tmpl: `${cmd} {dir}`, kind: "editor" });
635
+ else { const a = macApp(app); if (a) editors.push({ label, tmpl: `open -a ${JSON.stringify(a)} {dir}`, kind: "editor" }); }
636
+ }
637
+ const agents = AGENT_LIST.filter(([cmd]) => hasCmd(cmd)).map(([cmd, label, tmpl]) => ({ label, tmpl, kind: "agent" }));
638
+ // Orca IDE (any OS): register the repo + open a terminal tab. Two variants —
639
+ // one that just opens the repo (use Orca's own agent, e.g. GPT), and one that
640
+ // runs Claude in the tab for Claude users. Not Claude-only.
641
+ const orca = findOrcaCli();
642
+ if (orca) {
643
+ const q = JSON.stringify(orca);
644
+ agents.unshift(
645
+ { label: "Orca IDE — open repo (use your Orca agent)", tmpl: `${q} repo add --path {dir}; ${q} terminal create --worktree path:{dir} --focus`, kind: "agent" },
646
+ { label: "Orca IDE — run Claude in a tab", tmpl: `${q} repo add --path {dir}; ${q} terminal create --worktree path:{dir} --command "claude {prompt}" --focus`, kind: "agent" },
647
+ );
648
+ }
611
649
  return { agents, editors };
612
650
  }
613
651
  const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
@@ -1049,10 +1087,16 @@ async function repoSuggest(path) {
1049
1087
 
1050
1088
  // Build a write-up for a command; returns { text, sub, error? } without printing.
1051
1089
  // Shared by the CLI (cmdRun) and the web UI (symbiot app).
1090
+ // The SAME repo set the Map uses — reuse its cached scan when present, else do
1091
+ // the identical discovery. So week/standup/todo/drift all agree with the Map.
1092
+ function discoveredRepos() {
1093
+ if (LAST_MAP && LAST_MAP.nodes) return LAST_MAP.nodes.filter((n) => n.type === "repo" && n.meta && n.meta.path).map((n) => ({ path: n.meta.path, name: n.label, recency: 0 }));
1094
+ return findAllRepos();
1095
+ }
1052
1096
  async function produce(cmd) {
1053
1097
  if (!resolveProvider()) return { error: "not-connected" };
1054
1098
  if (cmd === "todo") {
1055
- const repos = findRepos(BASE, 60);
1099
+ const repos = discoveredRepos();
1056
1100
  const open = openWork(repos);
1057
1101
  if (!open.length) return { text: "Nothing outstanding found (no TODOs or uncommitted work).", sub: "0 open items · todo" };
1058
1102
  const system =
@@ -1065,8 +1109,10 @@ async function produce(cmd) {
1065
1109
  const label = cmd === "standup" ? "standup" : "week";
1066
1110
  const days = cmd === "standup" ? 2 : SINCE_WEEK;
1067
1111
  const who = me();
1068
- const repos = findRepos(BASE, days);
1069
- if (!repos.length) return { text: `No git activity in the last ${days} days under ${BASE}.\nPoint it at your repos with --dir.`, sub: "no activity" };
1112
+ // Discover the Map's repo set, then keep only those with commits in the window.
1113
+ const all = discoveredRepos();
1114
+ const repos = all.filter((r) => sh(`git -C ${JSON.stringify(r.path)} log --since="${days} days ago" --oneline -1 2>/dev/null`).trim());
1115
+ if (!repos.length) return { text: `No commits in the last ${days} days across your ${all.length} repos.\nAdd folders to scan in Settings, or check your git identity.`, sub: "no activity" };
1070
1116
  let cs = commits(repos, `${days} days ago`, !has("all"));
1071
1117
  if (!cs.length) cs = commits(repos, `${days} days ago`, false); // fall back to all if none matched you
1072
1118
  const open = label === "week" ? openWork(repos) : [];
@@ -1085,7 +1131,7 @@ async function produce(cmd) {
1085
1131
  `Write the ${label === "standup" ? "standup" : "update"}.`;
1086
1132
 
1087
1133
  const text = await write(system, prompt);
1088
- return { text: text || "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}`, footer: `symbiot ${VERSION} · ${repos.length} repos scanned · ${cs.length} commits (yours) in last ${days}d` };
1134
+ return { text: text || "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}`, footer: `symbiot ${VERSION} · ${all.length} repos (same as the Map) · ${repos.length} active · ${cs.length} commits in last ${days}d` };
1089
1135
  }
1090
1136
 
1091
1137
  async function cmdRun(cmd) {
@@ -1224,6 +1270,10 @@ select,input{width:100%;background:var(--ink);border:1px solid var(--line);borde
1224
1270
  select:focus,input:focus{outline:none;border-color:var(--green)}
1225
1271
  a{color:var(--green);cursor:pointer}.hidden{display:none}
1226
1272
  .note{font-size:12px;margin-top:10px}.ok{color:var(--green)}.err{color:var(--amber)}
1273
+ .updatebar{display:none;align-items:center;gap:12px;padding:9px 16px;font-size:13px;font-weight:600;background:var(--green);color:var(--ink)}
1274
+ .updatebar.show{display:flex}
1275
+ .updatebar.reconnect{background:var(--amber)}
1276
+ .updatebar button{font:inherit;font-weight:700;border:0;border-radius:8px;padding:5px 12px;background:var(--ink);color:var(--bone);cursor:pointer}
1227
1277
  footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1228
1278
  .profile{font-size:13px;margin-bottom:8px;line-height:1.5}
1229
1279
  .profile b{color:var(--bone)}
@@ -1288,6 +1338,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1288
1338
  .lg i{width:10px;height:10px;border-radius:50%;display:inline-block}
1289
1339
  @media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
1290
1340
  </style></head><body>
1341
+ <div id="updatebar" class="updatebar"></div>
1291
1342
  <header><span class="dot"></span><span class="brand">Symbiot</span><span class="status" id="status">...</span></header>
1292
1343
  <div class="tabs">
1293
1344
  <button class="tab active" data-tab="map">Map</button>
@@ -1587,6 +1638,24 @@ document.getElementById('addtask').addEventListener('click',addTaskUI);
1587
1638
  document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
1588
1639
  document.getElementById('agentcmd').addEventListener('change',saveAgent);
1589
1640
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
1641
+ var SRV_STARTED=null,srvDown=false,updBusy=false;
1642
+ function ubar(){return document.getElementById('updatebar');}
1643
+ function heartbeat(){
1644
+ api('/api/ping').then(function(p){
1645
+ var b=ubar();
1646
+ if(SRV_STARTED===null){SRV_STARTED=p.started;}
1647
+ else if(p.started!==SRV_STARTED){location.reload();return;}
1648
+ if(srvDown){location.reload();return;}
1649
+ if(updBusy)return;
1650
+ if(p.latest&&p.version&&p.latest!==p.version){
1651
+ b.className='updatebar show';
1652
+ b.innerHTML="A new Symbiot ("+esc(p.latest)+") is available &mdash; you're on "+esc(p.version)+". <button id='doupd'>Update &amp; restart</button>";
1653
+ var btn=document.getElementById('doupd');if(btn)btn.onclick=doUpdate;
1654
+ }else{b.className='updatebar';}
1655
+ }).catch(function(){srvDown=true;var b=ubar();b.className='updatebar reconnect show';b.textContent='Reconnecting to Symbiot…';});
1656
+ }
1657
+ function doUpdate(){updBusy=true;var b=ubar();b.className='updatebar show';b.textContent='Updating & restarting… this page will reload itself when it is back.';api('/api/update',{});}
1658
+ setInterval(heartbeat,4000);heartbeat();
1590
1659
  initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();
1591
1660
  </script></body></html>`;
1592
1661
 
@@ -1629,7 +1698,13 @@ function openApp(url) {
1629
1698
  }
1630
1699
  async function cmdApp() {
1631
1700
  SERVING = true;
1632
- const TOKEN = randomBytes(16).toString("hex");
1701
+ const SERVER_STARTED = Date.now();
1702
+ // Stable token + port so the URL survives a restart — the open tab can
1703
+ // reconnect and auto-reload itself instead of you closing and reopening it.
1704
+ const cfg0 = loadConfig();
1705
+ let TOKEN = cfg0.appToken;
1706
+ if (!TOKEN) { TOKEN = randomBytes(16).toString("hex"); try { saveConfig({ ...loadConfig(), appToken: TOKEN }); } catch {} }
1707
+ const PORT = Number(process.env.SYMBIOT_PORT || cfg0.appPort) || 7391;
1633
1708
  const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
1634
1709
  const server = createServer(async (req, res) => {
1635
1710
  const u = new URL(req.url, "http://127.0.0.1");
@@ -1670,17 +1745,40 @@ async function cmdApp() {
1670
1745
  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 }; }));
1671
1746
  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)); }
1672
1747
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
1748
+ if (u.pathname === "/api/ping") return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION });
1749
+ if (u.pathname === "/api/update" && req.method === "POST") {
1750
+ // Install the newest symbiot, then relaunch this same app (same port +
1751
+ // token => same URL) and exit. The open page's heartbeat reconnects and
1752
+ // reloads to the new version. No terminal, no reopening the window.
1753
+ const inst = process.platform === "win32" ? "npm i -g symbiot@latest" : "npm install -g symbiot@latest";
1754
+ const e = track("symbiot update", inst, homedir(), (code) => {
1755
+ if (code !== 0) return;
1756
+ try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: process.env }); ch.unref(); } catch {}
1757
+ setTimeout(() => process.exit(0), 1200);
1758
+ });
1759
+ return json(res, { started: true, id: e ? e.id : "" });
1760
+ }
1673
1761
  if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
1674
1762
  } catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String((e && e.message) || e) })); return; }
1675
1763
  res.writeHead(404); res.end("not found");
1676
1764
  });
1677
- server.listen(0, "127.0.0.1", () => {
1765
+ let announced = false, tries = 0, opened = false;
1766
+ server.on("listening", () => {
1767
+ if (announced) return; announced = true;
1678
1768
  const url = `http://127.0.0.1:${server.address().port}/?t=${TOKEN}`;
1679
- const how = openApp(url);
1769
+ const how = opened ? "" : openApp(url); opened = true; // only pop a window the first time
1680
1770
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} is running at ${c.b(url)}`);
1681
1771
  console.log(how ? c.d(` Opened in a ${how}.`) : c.d(" Open that URL in your browser."));
1682
1772
  console.log(c.d(" Leave this running; press Ctrl+C to stop (or click Quit in the window)."));
1683
1773
  });
1774
+ server.on("error", (e) => {
1775
+ // Stable port busy (an older instance still exiting during an update, or a
1776
+ // second app): retry briefly, then fall back to a random port.
1777
+ if (e && e.code === "EADDRINUSE" && tries < 8) { tries++; setTimeout(() => { try { server.listen(PORT, "127.0.0.1"); } catch {} }, 500); }
1778
+ else { try { server.listen(0, "127.0.0.1"); } catch {} }
1779
+ });
1780
+ server.listen(PORT, "127.0.0.1");
1781
+ checkLatest(); setInterval(checkLatest, 10 * 60 * 1000).unref(); // background update check
1684
1782
  }
1685
1783
 
1686
1784
  const HELP = `${c.b("symbiot")} — your week, written from your real work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.20.0",
3
+ "version": "0.23.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": {