symbiot 0.28.2 → 0.28.3

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 (4) hide show
  1. package/README.md +45 -40
  2. package/index.mjs +69 -53
  3. package/package.json +2 -2
  4. package/ui.mjs +1 -1
package/README.md CHANGED
@@ -41,49 +41,42 @@ no install — it's the same CLI. Press Ctrl+C (or click Quit) to stop.
41
41
 
42
42
  Tabs:
43
43
 
44
- - **Map** — a live, interactive node graph of your work, built from your local git:
45
- you at the centre, your repos, and the languages and tools they share (so related
46
- projects cluster), plus the coding **agents and editors** you have installed and
47
- the **AI** currently powering Symbiot. Project folders that **aren't git repos**
48
- show up too (see [Where it looks](#where-it-looks)). **Scroll to zoom, drag to
49
- pan, click a node.** Clicking a repo shows its branch/commits/uncommitted/stack, a
44
+ - **Map** (the landing view) — a live node graph of your work from your local git:
45
+ you at the centre, your repos, the languages and tools they share (so related
46
+ projects cluster), the coding **agents and editors** you have installed, and the
47
+ **AI** powering Symbiot. Project folders that **aren't git repos** show up too
48
+ (see [Where it looks](#where-it-looks)). **Scroll to zoom, drag to pan, click a
49
+ node.** A repo's panel shows its branch, commits, uncommitted work and stack, a
50
50
  **Suggest next steps** button, and an **AI review**: what the project does, a
51
- one-line verdict on whether it needs new work at all (it's told to prefer
52
- stabilising over piling on features), and a short list of ideas you can tick
53
- straight into **Tasks**. The graph itself **needs no AI key** (pure local data);
54
- reviews and suggestions use your chosen model (free with local Ollama). While
55
- the scan runs, the Map shows live progress (which phase, how many repos, which
56
- one). This is the landing view.
51
+ one-line verdict on whether it needs new work at all (it prefers stabilising over
52
+ new features), and ideas you can tick straight into **Tasks**. The graph needs
53
+ **no AI key**; reviews and suggestions use your chosen model. While the scan runs
54
+ the Map shows its progress.
57
55
  - **Drift** — the [`symbiot drift`](#whats-out-of-sync-symbiot-drift) report, with
58
56
  "fetch latest" and "check CI" toggles.
59
57
  - **Week / Standup / Todo** — the write-ups (these use your chosen AI).
60
58
  - **Tasks** — a checklist, grouped by kind (Fixes, Tests & CI, Docs, …). Filter by
61
59
  type or repo, then **Send to repos** to hand just those to your agent (see
62
60
  [`symbiot push`](#hand-tasks-to-your-coding-agent-symbiot-push)). What your agent
63
- finishes lands in **Awaiting your review**, where you approve or send it back (see
64
- [Review and approve](#review-and-approve-the-agents-work)). Ticking a task yourself
65
- marks it done and it **auto-archives**. An **archived** view lets you restore any
66
- of them.
67
- - **Agents** — every agent run Symbiot has started (and local-model downloads),
68
- with live status, elapsed time, exit code and the tail of its output.
69
- - **Settings** — pick your AI, the folders to scan, your agent command, and get
70
- model recommendations or a one-click local model.
61
+ finishes lands in **Awaiting your review** (see
62
+ [Review and approve](#review-and-approve-the-agents-work)). A task you tick
63
+ yourself is done and **auto-archives**; the archived view can restore it.
64
+ - **Agents** — every agent run Symbiot has started (and local-model downloads):
65
+ live status, elapsed time, exit code, the tail of its output, and **what it
66
+ did** — files changed and commits made, read from git, whichever agent it was.
67
+ - **Settings** — your AI, the folders to scan, your agent command, model
68
+ recommendations and a one-click local model.
71
69
 
72
70
  **Stays current by itself.** The app checks npm for a newer Symbiot every couple of
73
- minutes (and whenever you come back to the window). When there is one, a banner
74
- offers **Update & restart**: it runs `npm install -g symbiot@latest`, relaunches the
75
- app on the same address, and the open window reloads itself onto the new version —
76
- no terminal, no reopening. The version you're on is shown next to the name. The
77
- same mechanism means that if you restart `symbiot app` yourself, the open window
78
- reconnects and reloads on its own.
71
+ minutes (and whenever you come back to the window); the version you're on is shown
72
+ next to the name. When there's a newer one, **Update & restart** installs that exact
73
+ version, relaunches the app on the same address, and the open window reloads itself
74
+ onto it. Restart `symbiot app` yourself and the open window reconnects the same way.
79
75
 
80
76
  The app listens on port **7391** so its address survives restarts; set
81
77
  `SYMBIOT_PORT` to use another (it falls back to a free port if that one is busy).
82
-
83
- **Cross-platform:** the app works on **Linux, macOS, and Windows** — the server
84
- and UI are just a local web page. The chrome-less window is detected per-OS
85
- (PATH on Linux, the `/Applications` bundle on macOS, `Program Files` on Windows);
86
- where no Chromium-family browser is found it opens your default browser instead.
78
+ It works on **Linux, macOS and Windows**; with no Chromium-family browser to open
79
+ the chrome-less window, it uses your default browser.
87
80
 
88
81
  ## Hand tasks to your coding agent: `symbiot push`
89
82
 
@@ -114,7 +107,8 @@ size of the uncommitted change and a **Show diff** button. For each repo:
114
107
  on a feature branch it commits and opens the PR from there. Each step that can't
115
108
  happen stops there and says why: no `origin` remote means a local commit only, and
116
109
  without `gh` it pushes and stops. Your commit is never lost. Then the tasks are
117
- archived with their commit and PR link.
110
+ archived with their commit and PR link. With no changes to commit the button
111
+ reads **Approve → archive** and just archives them.
118
112
  - **↩ (send back)** is for one that isn't right: it reopens the task and unticks it in
119
113
  `TASKS.md`, so the next **Send to repos** hands it to the agent again.
120
114
 
@@ -133,9 +127,10 @@ aider --message "{prompt}" --yes
133
127
  code {dir}
134
128
  ```
135
129
 
136
- Settings shows **one-click presets** for what's installed on your machine:
130
+ Settings shows **one-click presets** for what's installed on your machine. A
131
+ preset only fills in the command box; the saved command is what runs.
137
132
 
138
- - **Agents:** Claude Code (*make changes*, or *plan only* which asks first), Codex
133
+ - **Agents** (all make changes, ready for your review): Claude Code, Codex
139
134
  (OpenAI), Aider, Cursor agent, Gemini CLI.
140
135
  - **Orca IDE** (any OS): opens the repo in Orca — either just the repo, to use
141
136
  Orca's own agent, or with Claude running in a new tab. It launches Orca if it's
@@ -146,7 +141,7 @@ Settings shows **one-click presets** for what's installed on your machine:
146
141
  Each run is logged to `.symbiot/agent.log` in the repo and shown live in the
147
142
  **Agents** tab. The command is saved as `agentCmd` in
148
143
  `~/.config/symbiot/config.json` — it's your command, Symbiot only fills in
149
- `{dir}` and `{prompt}`.
144
+ `{dir}` and `{prompt}`. A command saved from an older preset keeps working as-is.
150
145
 
151
146
  ## What's out of sync? `symbiot drift`
152
147
 
@@ -300,16 +295,26 @@ conventions every repo review must respect, alongside each repo's own
300
295
  npm test
301
296
  ```
302
297
 
303
- Runs three suites, all against throwaway repos and an isolated `HOME`:
298
+ Runs four suites, all against throwaway repos and an isolated `HOME`:
304
299
 
300
+ - `test/load.mjs` — the shipped files parse and load: `node --check` on each
301
+ module, `ui.mjs` imported on its own with its page's JavaScript parsed, every
302
+ local import listed in package.json `"files"`, and the `bin` entry point shipped.
305
303
  - `test/fixtures.mjs` — accuracy fixtures: the facts Symbiot collects (identity
306
304
  matching, stale checkouts, worktrees, drift) and the `symbiot drift` report as
307
- printed, plus the scan time limit.
305
+ printed, the scan time limit, the agent handoff, and the review → approve cycle.
308
306
  - `test/smoke.mjs` — boots `symbiot app`, runs the page's own JavaScript against
309
- a fake DOM (every tab, every button), and hits every endpoint the UI calls.
307
+ a fake DOM (every tab, every button), hits every endpoint the UI calls with the
308
+ method the UI uses, and checks POST-only endpoints refuse GET and `/api` needs
309
+ the token.
310
310
  - `test/install.mjs` — runs the CLI through a bin symlink, then `npm pack` +
311
- global install into a temp prefix and runs `symbiot help`. Needs npm registry
312
- access for dependencies; `SYMBIOT_SKIP_INSTALL_TEST=1` skips that part.
311
+ global install into a temp prefix: `symbiot help`, every shipped file present,
312
+ and the installed `symbiot app` serving its page. Needs npm registry access for
313
+ dependencies; `SYMBIOT_SKIP_INSTALL_TEST=1` skips that part.
314
+
315
+ `SYMBIOT_NO_OPEN=1` stops `symbiot app` opening a window (the tests set it).
316
+ Releases publish from a version tag via `.github/workflows/publish.yml`, and only
317
+ after the full suite passes in CI.
313
318
 
314
319
  ---
315
320
 
package/index.mjs CHANGED
@@ -182,7 +182,12 @@ function shipChanges(path, texts, opts = {}) {
182
182
  branch = stem; for (let i = 2; git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + branch]).ok; i++) branch = `${stem}-${i}`;
183
183
  const sw = git(path, ["switch", "-c", branch]); if (!sw.ok) return { error: "Could not create branch: " + sw.err };
184
184
  }
185
- const add = git(path, ["add", "-A", ...NOT_SYMBIOT]); if (!add.ok) return { error: "git add failed: " + add.err, branch };
185
+ // Stage everything, then drop .symbiot. A `. :(exclude).symbiot` pathspec
186
+ // warns+exits-1 once .symbiot is gitignored ("paths are ignored, use -f"),
187
+ // which falsely aborted the ship. `add -A` skips gitignored paths silently;
188
+ // the reset also covers repos where .symbiot isn't ignored.
189
+ const add = git(path, ["add", "-A"]); if (!add.ok) return { error: "git add failed: " + add.err, branch };
190
+ git(path, ["reset", "-q", "--", ".symbiot"]); // never ship Symbiot's own scratch
186
191
  const subject = texts.length === 1 ? (texts[0].length > 72 ? texts[0].slice(0, 71).replace(/\s+\S*$/, "") + "…" : texts[0]) : `symbiot: ${texts.length} approved tasks`;
187
192
  const body = texts.map((x) => "- " + x).join("\n");
188
193
  const cm = git(path, ["commit", "-m", subject, "-m", body]); if (!cm.ok) return { error: "Commit failed: " + (cm.err || cm.out), branch };
@@ -256,11 +261,7 @@ function pushTasks(filter) {
256
261
  if (filter && filter.type) tasks = tasks.filter((t) => taskType(t.text) === filter.type);
257
262
  if (filter && filter.repo) tasks = tasks.filter((t) => t.repo === filter.repo);
258
263
  if (!tasks.length) return { empty: true, written: [], unresolved: [] };
259
- // Resolve repo name -> path from the already-scanned map when we have it
260
- // (avoids a fresh full scan); fall back to a scan only if needed.
261
- const byName = {};
262
- 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();
263
- for (const r of src) if (r.path && !byName[r.name]) byName[r.name] = r.path;
264
+ const byName = repoPathMap(); // from the already-scanned map when there is one
264
265
  const groups = {}; for (const t of tasks) { const k = t.repo || ""; (groups[k] = groups[k] || []).push(t); }
265
266
  const written = [], unresolved = [];
266
267
  for (const name of Object.keys(groups)) {
@@ -717,44 +718,47 @@ function recommendModels(hw) {
717
718
  return { local, paid, best };
718
719
  }
719
720
  // ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
720
- // The handoff is a command TEMPLATE the user configures, with {dir} (repo path)
721
- // and {prompt} (the task instruction). Works for any agent/editor:
721
+ // ONE code path, whatever the agent. The handoff is a command TEMPLATE the user
722
+ // saves (`agentCmd`), with {dir} (repo path) and {prompt} (the task
723
+ // instruction), and it is the only thing that ever runs:
724
+ // handoffCmd() -> fillHandoff() -> runHandoff() -> track()
725
+ // Everything else in this section only helps pick a template: one-click presets
726
+ // for what's installed (detectHandoffs), the Orca preset (a template too, kept
727
+ // current by migrateOrcaCmd) and the cross-platform lookups behind them.
722
728
  // claude -p "{prompt}" · aider --message "{prompt}" · code {dir}
723
729
  // gnome-terminal --working-directory={dir} -- claude "{prompt}"
724
730
  // Not tied to any one tool — you decide what runs.
725
- // [cmd, label, macAppName] — macApp used to launch GUI editors on macOS where
726
- // the CLI isn't on PATH (they're .app bundles).
731
+ const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
732
+ const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
733
+ // Plain words only: in the Orca preset this passes through two shells, so no
734
+ // backticks or $ (escDq covers one level).
735
+ const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Tick each item [x] in that file as you finish it and leave your changes uncommitted, so they can be reviewed and approved. Confirm before anything destructive.";
736
+ function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(cfg.agentCmd) || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
737
+ // Save the template ("" clears it). Either way the legacy `ide` key goes.
738
+ function setHandoffCmd(cmd) {
739
+ const cfg = loadConfig(); const v = String(cmd || "").trim();
740
+ if (v) cfg.agentCmd = v; else delete cfg.agentCmd;
741
+ delete cfg.ide; saveConfig(cfg);
742
+ return { ok: true, cmd: cfg.agentCmd || "" };
743
+ }
744
+ const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
745
+ function runHandoff(repoPath) {
746
+ const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
747
+ return track(repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath);
748
+ }
749
+ // Presets. [cmd, label, macAppName] — macApp used to launch GUI editors on macOS
750
+ // where the CLI isn't on PATH (they're .app bundles).
727
751
  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", ""]];
752
+ // [cmd, label, template]. Only agents that leave changes to review: a handoff
753
+ // runs unattended, so a "plan only" run can't ask anything and leaves nothing to
754
+ // approve — that preset was dropped (a saved one still runs as-is).
728
755
  const AGENT_LIST = [
729
756
  ["claude", "Claude Code — make changes", 'claude -p "{prompt}" --permission-mode acceptEdits'],
730
- ["claude", "Claude Code — plan only (asks first)", 'claude -p "{prompt}"'],
731
757
  ["codex", "Codex (OpenAI/GPT) — make changes", 'codex exec --full-auto "{prompt}"'],
732
758
  ["aider", "Aider — make changes", 'aider --message "{prompt}" --yes'],
733
759
  ["gemini", "Gemini — make changes", 'gemini --yolo -p "{prompt}"'],
734
760
  ["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
735
761
  ];
736
- // Cross-platform "is this command available?" (command -v on posix, where on win).
737
- function hasCmd(cmd) { try { return !!sh(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`).trim(); } catch { return false; } }
738
- 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 ""; }
739
- // Find the Orca IDE CLI across OSes (known locations, then a bounded search).
740
- let ORCA_CLI; // cached per process: undefined=unchecked, ""=none, string=path
741
- function findOrcaCli() {
742
- if (ORCA_CLI !== undefined) return ORCA_CLI;
743
- ORCA_CLI = _findOrcaCli();
744
- return ORCA_CLI;
745
- }
746
- function _findOrcaCli() {
747
- const home = homedir(); const cands = [];
748
- if (process.platform === "linux") cands.push(join(home, ".local/share/orca-ide/app/resources/bin/orca-ide"));
749
- 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")); }
750
- 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")); }
751
- for (const c of cands) { try { if (existsSync(c)) return c; } catch {} }
752
- const roots = process.platform === "darwin" ? [join(home, "Library/Application Support"), "/Applications", join(home, "Applications")]
753
- : process.platform === "win32" ? [process.env.LOCALAPPDATA || "", process.env.PROGRAMFILES || ""]
754
- : [join(home, ".local/share"), "/opt", join(home, ".config")];
755
- 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; }
756
- return "";
757
- }
758
762
  function detectHandoffs() {
759
763
  const editors = [];
760
764
  for (const [cmd, label, app] of IDE_LIST) {
@@ -779,14 +783,8 @@ function detectHandoffs() {
779
783
  }
780
784
  return { agents, editors };
781
785
  }
782
- const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
783
- const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
784
- // Plain words only: in the Orca preset this passes through two shells, so no
785
- // backticks or $ (escDq covers one level).
786
- const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Tick each item [x] in that file as you finish it and leave your changes uncommitted, so they can be reviewed and approved. Confirm before anything destructive.";
787
- function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(cfg.agentCmd) || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
788
- // Build the full Orca handoff, cold-start safe. `bin` is the quoted orca-ide
789
- // path; `commandPart` is e.g. ORCA_CLAUDE_CMD or "" (open only).
786
+ // The Orca preset. Build the full Orca handoff, cold-start safe. `bin` is the
787
+ // quoted orca-ide path; `commandPart` is e.g. ORCA_CLAUDE_CMD or "" (open only).
790
788
  // open launches Orca & waits for the runtime to be REACHABLE, but on a cold
791
789
  // start the workspace graph isn't ready yet (runtime.state=graph_not_ready) and
792
790
  // `terminal create` times out — so we poll `status` until state=ready, then add
@@ -817,6 +815,30 @@ function migrateOrcaCmd(cmd) {
817
815
  if (rebuilt !== cmd) { try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = rebuilt; saveConfig(cfg); } } catch {} }
818
816
  return rebuilt;
819
817
  }
818
+ // Cross-platform detection for the presets (and gh / the app's browser).
819
+ // "Is this command available?": command -v on posix, where on win.
820
+ function hasCmd(cmd) { try { return !!sh(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`).trim(); } catch { return false; } }
821
+ 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 ""; }
822
+ // Find the Orca IDE CLI across OSes (known locations, then a bounded search).
823
+ let ORCA_CLI; // cached per process: undefined=unchecked, ""=none, string=path
824
+ function findOrcaCli() {
825
+ if (ORCA_CLI !== undefined) return ORCA_CLI;
826
+ ORCA_CLI = _findOrcaCli();
827
+ return ORCA_CLI;
828
+ }
829
+ function _findOrcaCli() {
830
+ const home = homedir(); const cands = [];
831
+ if (process.platform === "linux") cands.push(join(home, ".local/share/orca-ide/app/resources/bin/orca-ide"));
832
+ 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")); }
833
+ 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")); }
834
+ for (const c of cands) { try { if (existsSync(c)) return c; } catch {} }
835
+ const roots = process.platform === "darwin" ? [join(home, "Library/Application Support"), "/Applications", join(home, "Applications")]
836
+ : process.platform === "win32" ? [process.env.LOCALAPPDATA || "", process.env.PROGRAMFILES || ""]
837
+ : [join(home, ".local/share"), "/opt", join(home, ".config")];
838
+ 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; }
839
+ return "";
840
+ }
841
+ // ---- background jobs --------------------------------------------------------
820
842
  // Run a shell command as a tracked, logged background job that shows up live in
821
843
  // the Agents tab. Shared by the agent handoff and the local-model setup.
822
844
  function track(name, cmd, cwd, onExit) {
@@ -851,11 +873,6 @@ function agentChanges(path, startedAt) {
851
873
  return { dirty, stat, commits };
852
874
  } catch { return { dirty: 0, stat: "", commits: [] }; }
853
875
  }
854
- const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
855
- function runHandoff(repoPath) {
856
- const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
857
- return track(repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath);
858
- }
859
876
  // ---- local model one-command setup (Ollama), OS-aware ---------------------
860
877
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
861
878
  function hasOllama() { return !!sh("ollama --version").trim(); } // cross-platform (not POSIX command -v)
@@ -923,7 +940,7 @@ function worktreeCount(repo) { return sh(`git -C ${JSON.stringify(repo)} worktre
923
940
  // repo file, which could be attacker-controlled): ~/.config/symbiot/deploys.json
924
941
  function loadDeploys() { try { return JSON.parse(readFileSync(join(CONFIG_DIR, "deploys.json"), "utf8")); } catch { return {}; } }
925
942
  function ciState(repo, def) {
926
- if (!sh("command -v gh 2>/dev/null").trim()) return null;
943
+ if (!hasCmd("gh")) return null;
927
944
  if (!/github\.com/i.test(sh(`git -C ${JSON.stringify(repo)} remote get-url origin 2>/dev/null`))) return null;
928
945
  const j = sh(`cd ${JSON.stringify(repo)} && gh run list --branch ${def} --limit 1 --json databaseId,conclusion,status 2>/dev/null`).trim();
929
946
  if (!j) return null;
@@ -1472,7 +1489,6 @@ function readBody(req) {
1472
1489
  req.on("end", () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({}); } });
1473
1490
  });
1474
1491
  }
1475
- function onPath(cands) { for (const cc of cands) { if (sh(`command -v ${cc} 2>/dev/null`).trim()) return cc; } return null; }
1476
1492
  // Find a Chromium-family browser for the chrome-less --app window, per OS.
1477
1493
  function chromeBinary() {
1478
1494
  const p = process.platform;
@@ -1491,7 +1507,7 @@ function chromeBinary() {
1491
1507
  const w = sh("where chrome 2>NUL").split(/\r?\n/).map((s) => s.trim()).find(Boolean);
1492
1508
  return w || null;
1493
1509
  }
1494
- return onPath(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "brave-browser", "microsoft-edge"]);
1510
+ return ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "brave-browser", "microsoft-edge"].find(hasCmd) || null;
1495
1511
  }
1496
1512
  function openApp(url) {
1497
1513
  try {
@@ -1544,7 +1560,7 @@ async function cmdApp() {
1544
1560
  if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
1545
1561
  if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
1546
1562
  if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
1547
- 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 || "" }); }
1563
+ if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
1548
1564
  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 : "" }); }
1549
1565
  if (u.pathname === "/api/setup-local" && req.method === "POST") {
1550
1566
  const b = await readBody(req);
@@ -1582,7 +1598,7 @@ async function cmdApp() {
1582
1598
  server.on("listening", () => {
1583
1599
  if (announced) return; announced = true;
1584
1600
  const url = `http://127.0.0.1:${server.address().port}/?t=${TOKEN}`;
1585
- const how = opened ? "" : openApp(url); opened = true; // only pop a window the first time
1601
+ const how = opened || process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url); opened = true; // only pop a window the first time (never in tests)
1586
1602
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} is running at ${c.b(url)}`);
1587
1603
  console.log(how ? c.d(` Opened in a ${how}.`) : c.d(" Open that URL in your browser."));
1588
1604
  console.log(c.d(" Leave this running; press Ctrl+C to stop (or click Quit in the window)."));
@@ -1605,7 +1621,7 @@ ${c.b("Usage")}
1605
1621
  symbiot todo what's still on your plate
1606
1622
  symbiot app open the visual app in your browser
1607
1623
  symbiot drift what's out of sync / at risk across repos
1608
- symbiot push [--open] write tasks into each repo (and open your IDE)
1624
+ symbiot push [--open] write tasks into each repo (and run your agent)
1609
1625
  symbiot models recommend AI models for your hardware
1610
1626
  symbiot setup-local [--model X] install/run a free local model (Ollama)
1611
1627
  symbiot login connect it to an AI (once)
@@ -1655,4 +1671,4 @@ const isMain = (() => {
1655
1671
  })();
1656
1672
  if (isMain) main();
1657
1673
 
1658
- export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, fillHandoff, ORCA_CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, syncTasks, pendingReview, approveRepo, sendBack, pushTasks, semverGt };
1674
+ export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, syncTasks, pendingReview, approveRepo, sendBack, pushTasks, semverGt };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.28.2",
3
+ "version": "0.28.3",
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": {
@@ -43,6 +43,6 @@
43
43
  "@anthropic-ai/sdk": "^0.128.0"
44
44
  },
45
45
  "scripts": {
46
- "test": "node test/fixtures.mjs && node test/smoke.mjs && node test/install.mjs"
46
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/smoke.mjs && node test/install.mjs"
47
47
  }
48
48
  }
package/ui.mjs CHANGED
@@ -192,7 +192,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
192
192
  </div>
193
193
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
194
194
  <label>Hand off to your agent when you "Send to repos"</label>
195
- <input id="agentcmd" type="text" placeholder="e.g. claude -p &quot;{prompt}&quot; · code {dir} · leave blank to just write the file">
195
+ <input id="agentcmd" type="text" placeholder="e.g. aider --message &quot;{prompt}&quot; --yes · code {dir} · leave blank to just write the file">
196
196
  <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>
197
197
  <div id="agentpresets" style="margin-top:8px"></div>
198
198
  </div>