symbiot 0.22.0 → 0.24.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 +77 -6
  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
@@ -636,12 +638,16 @@ function detectHandoffs() {
636
638
  // Orca IDE (any OS): register the repo + open a terminal tab. Two variants —
637
639
  // one that just opens the repo (use Orca's own agent, e.g. GPT), and one that
638
640
  // runs Claude in the tab for Claude users. Not Claude-only.
641
+ // `<orca> open` first: it launches Orca AND blocks until the runtime is
642
+ // reachable, so this works even when Orca is closed (its CLI can't talk to a
643
+ // dead app). Use the full orca-ide path, never bare `orca` (that's a different
644
+ // tool on PATH). If Orca is already up, `open` returns fast.
639
645
  const orca = findOrcaCli();
640
646
  if (orca) {
641
647
  const q = JSON.stringify(orca);
642
648
  agents.unshift(
643
- { label: "Orca IDE — open repo (use your Orca agent)", tmpl: `${q} repo add --path {dir}; ${q} terminal create --worktree path:{dir} --focus`, kind: "agent" },
644
- { 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" },
649
+ { label: "Orca IDE — open repo (use your Orca agent)", tmpl: `${q} open; ${q} repo add --path {dir}; ${q} terminal create --worktree path:{dir} --focus`, kind: "agent" },
650
+ { label: "Orca IDE — run Claude in a tab", tmpl: `${q} open; ${q} repo add --path {dir}; ${q} terminal create --worktree path:{dir} --command "claude {prompt}" --focus`, kind: "agent" },
645
651
  );
646
652
  }
647
653
  return { agents, editors };
@@ -649,7 +655,20 @@ function detectHandoffs() {
649
655
  const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
650
656
  const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
651
657
  const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Confirm before anything destructive.";
652
- function handoffCmd() { const cfg = loadConfig(); return cfg.agentCmd || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
658
+ function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(cfg.agentCmd) || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
659
+ // An Orca command saved before we learned its CLI needs a running app lacks the
660
+ // `open` step and silently fails when Orca is closed. Patch it in place: insert
661
+ // `<orca> open; ` before the first `repo add`. Idempotent (skips if already there).
662
+ function migrateOrcaCmd(cmd) {
663
+ if (!cmd || typeof cmd !== "string") return cmd;
664
+ if (!/orca-ide/.test(cmd) || !/\brepo add\b/.test(cmd)) return cmd;
665
+ if (/orca-ide("|')?\s+open\b|\bopen;\s/.test(cmd)) return cmd; // already migrated
666
+ const m = cmd.match(/^(\S+|"[^"]*"|'[^']*')\s+repo add/); // the orca binary token
667
+ const bin = m ? m[1] : "";
668
+ const patched = bin ? cmd.replace(/(\S+|"[^"]*"|'[^']*')\s+repo add/, `${bin} open; $1 repo add`) : cmd;
669
+ if (patched !== cmd) { try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = patched; saveConfig(cfg); } } catch {} }
670
+ return patched;
671
+ }
653
672
  // Run a shell command as a tracked, logged background job that shows up live in
654
673
  // the Agents tab. Shared by the agent handoff and the local-model setup.
655
674
  function track(name, cmd, cwd, onExit) {
@@ -1268,6 +1287,10 @@ select,input{width:100%;background:var(--ink);border:1px solid var(--line);borde
1268
1287
  select:focus,input:focus{outline:none;border-color:var(--green)}
1269
1288
  a{color:var(--green);cursor:pointer}.hidden{display:none}
1270
1289
  .note{font-size:12px;margin-top:10px}.ok{color:var(--green)}.err{color:var(--amber)}
1290
+ .updatebar{display:none;align-items:center;gap:12px;padding:9px 16px;font-size:13px;font-weight:600;background:var(--green);color:var(--ink)}
1291
+ .updatebar.show{display:flex}
1292
+ .updatebar.reconnect{background:var(--amber)}
1293
+ .updatebar button{font:inherit;font-weight:700;border:0;border-radius:8px;padding:5px 12px;background:var(--ink);color:var(--bone);cursor:pointer}
1271
1294
  footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1272
1295
  .profile{font-size:13px;margin-bottom:8px;line-height:1.5}
1273
1296
  .profile b{color:var(--bone)}
@@ -1332,6 +1355,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1332
1355
  .lg i{width:10px;height:10px;border-radius:50%;display:inline-block}
1333
1356
  @media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
1334
1357
  </style></head><body>
1358
+ <div id="updatebar" class="updatebar"></div>
1335
1359
  <header><span class="dot"></span><span class="brand">Symbiot</span><span class="status" id="status">...</span></header>
1336
1360
  <div class="tabs">
1337
1361
  <button class="tab active" data-tab="map">Map</button>
@@ -1631,6 +1655,24 @@ document.getElementById('addtask').addEventListener('click',addTaskUI);
1631
1655
  document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
1632
1656
  document.getElementById('agentcmd').addEventListener('change',saveAgent);
1633
1657
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
1658
+ var SRV_STARTED=null,srvDown=false,updBusy=false;
1659
+ function ubar(){return document.getElementById('updatebar');}
1660
+ function heartbeat(){
1661
+ api('/api/ping').then(function(p){
1662
+ var b=ubar();
1663
+ if(SRV_STARTED===null){SRV_STARTED=p.started;}
1664
+ else if(p.started!==SRV_STARTED){location.reload();return;}
1665
+ if(srvDown){location.reload();return;}
1666
+ if(updBusy)return;
1667
+ if(p.latest&&p.version&&p.latest!==p.version){
1668
+ b.className='updatebar show';
1669
+ b.innerHTML="A new Symbiot ("+esc(p.latest)+") is available &mdash; you're on "+esc(p.version)+". <button id='doupd'>Update &amp; restart</button>";
1670
+ var btn=document.getElementById('doupd');if(btn)btn.onclick=doUpdate;
1671
+ }else{b.className='updatebar';}
1672
+ }).catch(function(){srvDown=true;var b=ubar();b.className='updatebar reconnect show';b.textContent='Reconnecting to Symbiot…';});
1673
+ }
1674
+ 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',{});}
1675
+ setInterval(heartbeat,4000);heartbeat();
1634
1676
  initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();
1635
1677
  </script></body></html>`;
1636
1678
 
@@ -1673,7 +1715,13 @@ function openApp(url) {
1673
1715
  }
1674
1716
  async function cmdApp() {
1675
1717
  SERVING = true;
1676
- const TOKEN = randomBytes(16).toString("hex");
1718
+ const SERVER_STARTED = Date.now();
1719
+ // Stable token + port so the URL survives a restart — the open tab can
1720
+ // reconnect and auto-reload itself instead of you closing and reopening it.
1721
+ const cfg0 = loadConfig();
1722
+ let TOKEN = cfg0.appToken;
1723
+ if (!TOKEN) { TOKEN = randomBytes(16).toString("hex"); try { saveConfig({ ...loadConfig(), appToken: TOKEN }); } catch {} }
1724
+ const PORT = Number(process.env.SYMBIOT_PORT || cfg0.appPort) || 7391;
1677
1725
  const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
1678
1726
  const server = createServer(async (req, res) => {
1679
1727
  const u = new URL(req.url, "http://127.0.0.1");
@@ -1714,17 +1762,40 @@ async function cmdApp() {
1714
1762
  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 }; }));
1715
1763
  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)); }
1716
1764
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
1765
+ if (u.pathname === "/api/ping") return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION });
1766
+ if (u.pathname === "/api/update" && req.method === "POST") {
1767
+ // Install the newest symbiot, then relaunch this same app (same port +
1768
+ // token => same URL) and exit. The open page's heartbeat reconnects and
1769
+ // reloads to the new version. No terminal, no reopening the window.
1770
+ const inst = process.platform === "win32" ? "npm i -g symbiot@latest" : "npm install -g symbiot@latest";
1771
+ const e = track("symbiot update", inst, homedir(), (code) => {
1772
+ if (code !== 0) return;
1773
+ try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: process.env }); ch.unref(); } catch {}
1774
+ setTimeout(() => process.exit(0), 1200);
1775
+ });
1776
+ return json(res, { started: true, id: e ? e.id : "" });
1777
+ }
1717
1778
  if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
1718
1779
  } catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String((e && e.message) || e) })); return; }
1719
1780
  res.writeHead(404); res.end("not found");
1720
1781
  });
1721
- server.listen(0, "127.0.0.1", () => {
1782
+ let announced = false, tries = 0, opened = false;
1783
+ server.on("listening", () => {
1784
+ if (announced) return; announced = true;
1722
1785
  const url = `http://127.0.0.1:${server.address().port}/?t=${TOKEN}`;
1723
- const how = openApp(url);
1786
+ const how = opened ? "" : openApp(url); opened = true; // only pop a window the first time
1724
1787
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} is running at ${c.b(url)}`);
1725
1788
  console.log(how ? c.d(` Opened in a ${how}.`) : c.d(" Open that URL in your browser."));
1726
1789
  console.log(c.d(" Leave this running; press Ctrl+C to stop (or click Quit in the window)."));
1727
1790
  });
1791
+ server.on("error", (e) => {
1792
+ // Stable port busy (an older instance still exiting during an update, or a
1793
+ // second app): retry briefly, then fall back to a random port.
1794
+ if (e && e.code === "EADDRINUSE" && tries < 8) { tries++; setTimeout(() => { try { server.listen(PORT, "127.0.0.1"); } catch {} }, 500); }
1795
+ else { try { server.listen(0, "127.0.0.1"); } catch {} }
1796
+ });
1797
+ server.listen(PORT, "127.0.0.1");
1798
+ checkLatest(); setInterval(checkLatest, 10 * 60 * 1000).unref(); // background update check
1728
1799
  }
1729
1800
 
1730
1801
  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.22.0",
3
+ "version": "0.24.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": {