symbiot 0.34.0 → 0.35.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.
package/index.mjs CHANGED
@@ -19,16 +19,19 @@
19
19
  // --plain no colour, no spinner (for piping)
20
20
 
21
21
  import Anthropic from "@anthropic-ai/sdk";
22
- import { execSync, spawn, spawnSync } from "node:child_process";
22
+ import { spawn, spawnSync } from "node:child_process";
23
23
  import { homedir, totalmem, cpus as oscpus } from "node:os";
24
24
  import { join } from "node:path";
25
- import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, statSync, realpathSync, openSync, writeSync } from "node:fs";
25
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, realpathSync } from "node:fs";
26
26
  import { createInterface } from "node:readline";
27
27
  import { createServer } from "node:http";
28
28
  import { randomBytes } from "node:crypto";
29
29
  import { fileURLToPath, pathToFileURL } from "node:url";
30
30
  import { EMBEDDED_UI } from "./ui.mjs";
31
31
  import { detectMailSources, mailActivity } from "./mail.mjs";
32
+ import { CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, repoState } from "./core.mjs";
33
+ import { HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, fillHandoff, runHandoff, writeTasks, releaseHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, parseQuestions, agentQuestions, answerQuestions, agentsList } from "./agents.mjs";
34
+ import { gitDefaultBranch, loadDeploys, driftRepo } from "./drift.mjs";
32
35
 
33
36
  const HERE = fileURLToPath(new URL(".", import.meta.url));
34
37
  let VERSION = "0"; try { VERSION = JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version; } catch {}
@@ -36,8 +39,6 @@ let VERSION = "0"; try { VERSION = JSON.parse(readFileSync(join(HERE, "package.j
36
39
  const MAX_REPOS = 14;
37
40
  const MAX_COMMITS = 140;
38
41
  const MAX_TOKENS = 1600;
39
- const CONFIG_DIR = join(homedir(), ".config", "symbiot");
40
- const CONFIG_PATH = join(CONFIG_DIR, "config.json");
41
42
 
42
43
  // The providers Symbiot can write with. Models are sensible defaults; override
43
44
  // per provider at login, or globally with SYMBIOT_MODEL.
@@ -59,7 +60,6 @@ const has = (name) => argv.includes(`--${name}`);
59
60
  const PLAIN = has("plain") || !process.stdout.isTTY;
60
61
  let SERVING = false; // set while `symbiot app` runs — silences the CLI spinner
61
62
  let LAST_MAP = null; // cached graph so node clicks don't rescan
62
- const HANDOFFS = []; // live registry of agents Symbiot has handed work to
63
63
  let LATEST_VERSION = ""; // newest symbiot on npm, checked in the background
64
64
  // a.b.c numeric compare: only a HIGHER npm version is an update (a local build
65
65
  // ahead of npm must not be offered a "newer" older one)
@@ -94,25 +94,11 @@ function spinner(label) {
94
94
  return () => { clearInterval(t); process.stderr.write("\r\x1b[K"); };
95
95
  }
96
96
 
97
- // ---- config + provider resolution -----------------------------------------
98
- function loadConfig() {
99
- try { return JSON.parse(readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
100
- }
101
- function saveConfig(cfg) {
102
- try {
103
- mkdirSync(CONFIG_DIR, { recursive: true });
104
- writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
105
- try { chmodSync(CONFIG_PATH, 0o600); } catch {}
106
- return true;
107
- } catch { return false; }
108
- }
97
+ // ---- config + provider resolution (config files: core.mjs) -----------------
109
98
  function antProfileExists() {
110
99
  try { return existsSync(join(homedir(), ".config", "anthropic")); } catch { return false; }
111
100
  }
112
- // ---- tasks: a persistent checklist (~/.config/symbiot/tasks.json) ---------
113
- const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
114
- function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
115
- function saveTasks(t) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(TASKS_PATH, JSON.stringify(t, null, 2)); return true; } catch { return false; } }
101
+ // ---- tasks: a persistent checklist (stored by core.mjs) -------------------
116
102
  function addTask(text, repo) {
117
103
  text = String(text || "").trim().slice(0, 300);
118
104
  if (!text) return { error: "empty" };
@@ -143,7 +129,7 @@ function syncTasks() {
143
129
  const t = loadTasks(); const map = repoPathMap(); let review = 0, archived = 0; const checkedByRepo = {};
144
130
  for (const x of t) {
145
131
  if (x.archived || x.done || x.review || !x.repo) continue;
146
- if (!(x.repo in checkedByRepo)) checkedByRepo[x.repo] = map[x.repo] ? completedInRepo(map[x.repo]) : [];
132
+ if (!(x.repo in checkedByRepo)) { const p = map[x.repo]; if (p) releaseHeldTasks(p); checkedByRepo[x.repo] = p ? completedInRepo(p) : []; }
147
133
  if (checkedByRepo[x.repo].includes(x.text.toLowerCase())) { x.review = true; x.reviewAt = Date.now(); review++; }
148
134
  }
149
135
  for (const x of t) { if (x.done && !x.archived) { x.archived = true; x.archivedAt = Date.now(); archived++; } }
@@ -173,11 +159,19 @@ function workingDiff(path, cap = 400000) {
173
159
  }
174
160
  return d.length > cap ? d.slice(0, cap) + "\n… (diff truncated)" : d;
175
161
  }
162
+ // Repos with tasks awaiting review, plus repos Symbiot sent tasks to that have
163
+ // uncommitted changes no ticked task covers (untasked: approve them as-is).
176
164
  function pendingReview() {
177
- const by = {}; for (const x of loadTasks()) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
178
- const map = Object.keys(by).length ? repoPathMap() : {};
165
+ const t = loadTasks(), by = {}; for (const x of t) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
166
+ const sent = [...new Set(t.filter((x) => x.repo && !x.archived && !by[x.repo]).map((x) => x.repo))];
167
+ const map = Object.keys(by).length || sent.length ? repoPathMap() : {};
179
168
  const am = autoMergeRepos();
180
- return Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], autoMerge: am.includes(repo), ...(path ? workingChanges(path) : { branch: "", files: [], stat: "" }) }; });
169
+ const out = Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], autoMerge: am.includes(repo), ...(path ? workingChanges(path) : { branch: "", files: [], stat: "" }) }; });
170
+ for (const repo of sent.sort()) {
171
+ const path = map[repo]; if (!path || !existsSync(join(path, ".symbiot", "TASKS.md"))) continue;
172
+ const wc = workingChanges(path); if (wc.files.length) out.push({ repo, path, tasks: [], untasked: true, autoMerge: am.includes(repo), ...wc });
173
+ }
174
+ return out;
181
175
  }
182
176
  const branchSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "") || "tasks";
183
177
  // Sync approved work: off the default branch onto symbiot/<task>, commit the
@@ -189,9 +183,9 @@ function shipChanges(path, texts, opts = {}) {
189
183
  const ch = workingChanges(path);
190
184
  if (!ch.files.length) return { ok: true, nothing: true, note: "No uncommitted changes — approved without a commit." };
191
185
  if (!ch.branch || ch.branch === "HEAD") return { error: "Detached HEAD — check out a branch first." };
192
- const base = gitDefaultBranch(path); let branch = ch.branch;
186
+ const base = gitDefaultBranch(path), day = new Date().toISOString().slice(0, 10); let branch = ch.branch;
193
187
  if (branch === base) {
194
- const stem = "symbiot/" + branchSlug(texts.length === 1 ? texts[0] : `${texts.length}-tasks-${new Date().toISOString().slice(0, 10)}`);
188
+ const stem = "symbiot/" + branchSlug(!texts.length ? `changes-${day}` : texts.length === 1 ? texts[0] : `${texts.length}-tasks-${day}`);
195
189
  branch = stem; for (let i = 2; git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + branch]).ok; i++) branch = `${stem}-${i}`;
196
190
  const sw = git(path, ["switch", "-c", branch]); if (!sw.ok) return { error: "Could not create branch: " + sw.err };
197
191
  }
@@ -201,8 +195,8 @@ function shipChanges(path, texts, opts = {}) {
201
195
  // the reset also covers repos where .symbiot isn't ignored.
202
196
  const add = git(path, ["add", "-A"]); if (!add.ok) return { error: "git add failed: " + add.err, branch };
203
197
  git(path, ["reset", "-q", "--", ".symbiot"]); // never ship Symbiot's own scratch
204
- const subject = texts.length === 1 ? (texts[0].length > 72 ? texts[0].slice(0, 71).replace(/\s+\S*$/, "") + "…" : texts[0]) : `symbiot: ${texts.length} approved tasks`;
205
- const body = texts.map((x) => "- " + x).join("\n");
198
+ const subject = !texts.length ? "symbiot: changes approved without a task" : texts.length === 1 ? (texts[0].length > 72 ? texts[0].slice(0, 71).replace(/\s+\S*$/, "") + "…" : texts[0]) : `symbiot: ${texts.length} approved tasks`;
199
+ const body = texts.length ? texts.map((x) => "- " + x).join("\n") : "No ticked task covers these changes.";
206
200
  const cm = git(path, ["commit", "-m", subject, "-m", body]); if (!cm.ok) return { error: "Commit failed: " + (cm.err || cm.out), branch };
207
201
  const out = { ok: true, branch, base, commit: git(path, ["rev-parse", "--short", "HEAD"]).out, subject };
208
202
  if (opts.push === false) return out;
@@ -241,6 +235,16 @@ function approveRepo(repo, opts = {}) {
241
235
  saveTasks(t);
242
236
  return { ...r, approved: items.length };
243
237
  }
238
+ // "Approve changes without a task": ship the uncommitted changes even though no
239
+ // ticked task is behind them (a fix the agent made but didn't tick). If tasks are
240
+ // awaiting review in the repo, this is just Approve.
241
+ function approveChanges(repo, opts = {}) {
242
+ if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return approveRepo(repo, opts);
243
+ const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
244
+ if (!workingChanges(path).files.length) return { error: "No uncommitted changes in " + repo + "." };
245
+ const r = shipChanges(path, [], { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
246
+ return r.error ? r : { ...r, approved: 0 };
247
+ }
244
248
  // Not right: reopen it and untick it in TASKS.md so the agent picks it up again.
245
249
  function sendBack(id) {
246
250
  const t = loadTasks(); const it = t.find((x) => x.id === id); if (!it) return { error: "not found" };
@@ -309,10 +313,9 @@ function pushTasks(filter) {
309
313
  if (st.stale) risk.push("stale checkout — working tree is an old snapshot, not new work");
310
314
  if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
311
315
  if (st.dirty && !st.stale) risk.push(`${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)`);
312
- const dir = join(path, ".symbiot"); mkdirSync(dir, { recursive: true });
313
- const file = join(dir, "TASKS.md");
314
- writeFileSync(file, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack }, list));
315
- written.push({ name, file, path, count: list.length });
316
+ // held: an agent is still running there, so it lands when that one exits
317
+ const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack }, list));
318
+ written.push({ name, file: join(path, ".symbiot", "TASKS.md"), path, count: list.length, held });
316
319
  } catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
317
320
  }
318
321
  return { empty: false, written, unresolved, handoff: handoffCmd() };
@@ -320,11 +323,16 @@ function pushTasks(filter) {
320
323
  function cmdPush() {
321
324
  const r = pushTasks();
322
325
  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; }
323
- 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" : "") + ")")}`); }
326
+ 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" : "") + ")")}` + (w.held ? c.y(" held: an agent is still running there, so it lands when that one finishes") : "")); }
324
327
  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})`); }
325
328
  if (has("open")) {
326
329
  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})."));
327
- 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}).`); }
330
+ else {
331
+ const busy = r.written.filter((w) => { const e = runHandoff(w.path); return e && e.busy; });
332
+ const n = r.written.length - busy.length;
333
+ if (n) console.log("\n" + c.g("→ ") + `Handed ${n} repo(s) to your agent (${r.handoff}).`);
334
+ for (const w of busy) console.log("\n" + c.y("Not started: ") + `${w.name} already has an agent running.` + c.d(" Its new tasks are held until it finishes; send again then to start one on them."));
335
+ }
328
336
  } else {
329
337
  console.log("\n" + c.d("Point your agent at .symbiot/TASKS.md in each repo. (add --open to run your configured agent command)"));
330
338
  }
@@ -372,11 +380,6 @@ function ask(question, { secret = false } = {}) {
372
380
  });
373
381
  }
374
382
 
375
- // ---- git ------------------------------------------------------------------
376
- function sh(cmd) {
377
- try { return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024, timeout: 6000, killSignal: "SIGKILL" }); }
378
- catch { return ""; } // timeout or error -> empty, never hang the scan
379
- }
380
383
  // ---- scan deadline + progress ---------------------------------------------
381
384
  // One slow disk or giant tree must never hang a scan (the 0.7.1 map hang): each
382
385
  // scan gets an overall deadline (SYMBIOT_SCAN_TIMEOUT seconds), after which it
@@ -462,24 +465,6 @@ function authorship(repoPath) {
462
465
  }
463
466
  function authorArgs(emails) { return (emails || []).filter(Boolean).map((e) => `--author=${JSON.stringify(e)}`).join(" "); }
464
467
 
465
- // Working-tree state, incl. detecting a stale/old checkout (not new work).
466
- function repoState(repoPath) {
467
- const branch = sh(`git -C ${JSON.stringify(repoPath)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
468
- const porcelain = sh(`git -C ${JSON.stringify(repoPath)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean);
469
- let del = 0, mod = 0, add = 0;
470
- for (const l of porcelain) { const x = l.slice(0, 2); if (/\?\?/.test(x)) add++; else if (x.includes("D")) del++; else if (x.includes("A")) add++; else mod++; }
471
- const dirty = porcelain.length;
472
- let stale = false, staleBy = 0;
473
- if (dirty) {
474
- for (const k of [3, 5, 10, 20, 40, 80, 160, 320]) {
475
- if (!sh(`git -C ${JSON.stringify(repoPath)} rev-parse HEAD~${k} 2>/dev/null`).trim()) break;
476
- if (sh(`git -C ${JSON.stringify(repoPath)} diff --quiet HEAD~${k} 2>/dev/null && echo EQ`).trim() === "EQ") { stale = true; staleBy = k; break; }
477
- }
478
- if (!stale && del >= 20 && del > mod && add === 0) stale = true; // mostly deletions = an old snapshot
479
- }
480
- const behind = Number(sh(`git -C ${JSON.stringify(repoPath)} rev-list --count HEAD..@{u} 2>/dev/null`).trim()) || 0;
481
- return { branch, dirty, del, mod, add, stale, staleBy, behind };
482
- }
483
468
  function readmeInfo(repoPath) {
484
469
  for (const f of ["README.md", "README.MD", "Readme.md", "readme.md", "README.txt", "README"]) {
485
470
  try {
@@ -747,249 +732,6 @@ function recommendModels(hw) {
747
732
  const best = (local.slice().reverse().find((m) => m.fits) || local[0]).model;
748
733
  return { local, paid, best };
749
734
  }
750
- // ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
751
- // ONE code path, whatever the agent. The handoff is a command TEMPLATE the user
752
- // saves (`agentCmd`), with {dir} (repo path) and {prompt} (the task
753
- // instruction), and it is the only thing that ever runs:
754
- // handoffCmd() -> fillHandoff() -> runHandoff() -> track()
755
- // Everything else in this section only helps pick a template: one-click presets
756
- // for what's installed (detectHandoffs), the Orca preset (a template too, kept
757
- // current by migrateOrcaCmd) and the cross-platform lookups behind them.
758
- // claude -p "{prompt}" · aider --message "{prompt}" · code {dir}
759
- // gnome-terminal --working-directory={dir} -- claude "{prompt}"
760
- // Not tied to any one tool — you decide what runs.
761
- const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
762
- const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
763
- // Plain words only: in the Orca preset this passes through two shells, so no
764
- // backticks or $ (escDq covers one level).
765
- 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. If you need a decision, or have ideas or options for the user, write them to .symbiot/QUESTIONS.md as TASKS.md explains, and read .symbiot/ANSWERS.md first if it exists. Confirm before anything destructive.";
766
- function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(migrateClaudeCmd(cfg.agentCmd)) || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
767
- // Save the template ("" clears it). Either way the legacy `ide` key goes.
768
- function setHandoffCmd(cmd) {
769
- const cfg = loadConfig(); const v = String(cmd || "").trim();
770
- if (v) cfg.agentCmd = v; else delete cfg.agentCmd;
771
- delete cfg.ide; saveConfig(cfg);
772
- return { ok: true, cmd: cfg.agentCmd || "" };
773
- }
774
- const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
775
- function runHandoff(repoPath) {
776
- const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
777
- return track(repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath);
778
- }
779
- // Presets. [cmd, label, macAppName] — macApp used to launch GUI editors on macOS
780
- // where the CLI isn't on PATH (they're .app bundles).
781
- 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", ""]];
782
- // [cmd, label, template]. Only agents that leave changes to review: a handoff
783
- // runs unattended, so a "plan only" run can't ask anything and leaves nothing to
784
- // approve — that preset was dropped (a saved one still runs as-is).
785
- // Claude: acceptEdits alone still blocks every shell command, so a run could
786
- // never run its own tests and had to leave test work unticked. Allow just the
787
- // test runner and node.
788
- const CLAUDE_CMD = 'claude -p "{prompt}" --permission-mode acceptEdits --allowedTools "Bash(npm test:*)" "Bash(node:*)"';
789
- const CLAUDE_CMD_OLD = 'claude -p "{prompt}" --permission-mode acceptEdits'; // ≤0.33
790
- const AGENT_LIST = [
791
- ["claude", "Claude Code — make changes", CLAUDE_CMD],
792
- ["codex", "Codex (OpenAI/GPT) — make changes", 'codex exec --full-auto "{prompt}"'],
793
- ["aider", "Aider — make changes", 'aider --message "{prompt}" --yes'],
794
- ["gemini", "Gemini — make changes", 'gemini --yolo -p "{prompt}"'],
795
- ["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
796
- ];
797
- function detectHandoffs() {
798
- const editors = [];
799
- for (const [cmd, label, app] of IDE_LIST) {
800
- if (hasCmd(cmd)) editors.push({ label, tmpl: `${cmd} {dir}`, kind: "editor" });
801
- else { const a = macApp(app); if (a) editors.push({ label, tmpl: `open -a ${JSON.stringify(a)} {dir}`, kind: "editor" }); }
802
- }
803
- const agents = AGENT_LIST.filter(([cmd]) => hasCmd(cmd)).map(([cmd, label, tmpl]) => ({ label, tmpl, kind: "agent" }));
804
- // Orca IDE (any OS): register the repo + open a terminal tab. Two variants —
805
- // one that just opens the repo (use Orca's own agent, e.g. GPT), and one that
806
- // runs Claude in the tab for Claude users. Not Claude-only.
807
- // `<orca> open` first: it launches Orca AND blocks until the runtime is
808
- // reachable, so this works even when Orca is closed (its CLI can't talk to a
809
- // dead app). Use the full orca-ide path, never bare `orca` (that's a different
810
- // tool on PATH). If Orca is already up, `open` returns fast.
811
- const orca = findOrcaCli();
812
- if (orca) {
813
- const q = JSON.stringify(orca);
814
- agents.unshift(
815
- { label: "Orca IDE — open repo (use your Orca agent)", tmpl: orcaHandoffCmd(q, ""), kind: "agent" },
816
- { label: "Orca IDE — run Claude in a tab", tmpl: orcaHandoffCmd(q, ORCA_CLAUDE_CMD), kind: "agent" },
817
- );
818
- }
819
- return { agents, editors };
820
- }
821
- // The Orca preset. Build the full Orca handoff, cold-start safe. `bin` is the
822
- // quoted orca-ide path; `commandPart` is e.g. ORCA_CLAUDE_CMD or "" (open only).
823
- // open launches Orca & waits for the runtime to be REACHABLE, but on a cold
824
- // start the workspace graph isn't ready yet (runtime.state=graph_not_ready) and
825
- // `terminal create` times out — so we poll `status` until state=ready, then add
826
- // the repo and create the terminal (retry: the worktree can lag a beat behind).
827
- // {prompt} needs its own (escaped) quotes: the outer shell strips the --command
828
- // quotes and Orca re-runs the string in the tab, so an unquoted prompt reached
829
- // the agent as just its first word ("Read").
830
- const ORCA_CLAUDE_CMD = ` --command "claude \\"{prompt}\\""`;
831
- function orcaHandoffCmd(bin, commandPart) {
832
- const waitReady = `for i in $(seq 1 40); do ${bin} status --json 2>/dev/null | grep -q '"state": *"ready"' && break; sleep 1; done`;
833
- const mkTerm = `for j in 1 2 3; do ${bin} terminal create --worktree path:{dir}${commandPart} --focus && break; sleep 2; done`;
834
- return `${bin} open; ${waitReady}; ${bin} repo add --path {dir}; ${mkTerm}`;
835
- }
836
- // Normalise an Orca command saved by an older version (no launch / no wait-for-
837
- // ready) to the current cold-start-safe form, preserving its binary path and any
838
- // custom `--command`. Idempotent: already-current commands are left untouched.
839
- function migrateOrcaCmd(cmd) {
840
- if (!cmd || typeof cmd !== "string") return cmd;
841
- if (!/orca-ide/.test(cmd) || !/\brepo add\b/.test(cmd)) return cmd;
842
- const cm = cmd.match(/--command\s+"((?:[^"\\]|\\.)*)"/); // preserve a custom agent command
843
- // ≤0.26 saved a bare {prompt} here (truncated to one word) — quote it
844
- const inner = cm ? cm[1].replace(/(^|\s)\{prompt\}(?=\s|$)/g, '$1\\"{prompt}\\"') : "";
845
- if (/status --json/.test(cmd) && /grep -q/.test(cmd) && (!cm || inner === cm[1])) return cmd; // already current
846
- const bm = cmd.match(/^\s*("[^"]*"|'[^']*'|\S+)/); // leading orca-ide binary token
847
- const bin = bm ? bm[1] : "";
848
- const commandPart = cm ? ` --command "${inner}"` : "";
849
- const rebuilt = bin ? orcaHandoffCmd(bin, commandPart) : cmd;
850
- if (rebuilt !== cmd) { try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = rebuilt; saveConfig(cfg); } } catch {} }
851
- return rebuilt;
852
- }
853
- // Upgrade a saved Claude preset from ≤0.33 to the current one. Exact match only:
854
- // a command the user edited is theirs and runs as-is.
855
- function migrateClaudeCmd(cmd) {
856
- if (typeof cmd !== "string" || cmd.trim() !== CLAUDE_CMD_OLD) return cmd;
857
- try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = CLAUDE_CMD; saveConfig(cfg); } } catch {}
858
- return CLAUDE_CMD;
859
- }
860
- // Cross-platform detection for the presets (and gh / the app's browser).
861
- // "Is this command available?": command -v on posix, where on win.
862
- function hasCmd(cmd) { try { return !!sh(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`).trim(); } catch { return false; } }
863
- 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 ""; }
864
- // Find the Orca IDE CLI across OSes (known locations, then a bounded search).
865
- let ORCA_CLI; // cached per process: undefined=unchecked, ""=none, string=path
866
- function findOrcaCli() {
867
- if (ORCA_CLI !== undefined) return ORCA_CLI;
868
- ORCA_CLI = _findOrcaCli();
869
- return ORCA_CLI;
870
- }
871
- function _findOrcaCli() {
872
- const home = homedir(); const cands = [];
873
- if (process.platform === "linux") cands.push(join(home, ".local/share/orca-ide/app/resources/bin/orca-ide"));
874
- 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")); }
875
- 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")); }
876
- for (const c of cands) { try { if (existsSync(c)) return c; } catch {} }
877
- const roots = process.platform === "darwin" ? [join(home, "Library/Application Support"), "/Applications", join(home, "Applications")]
878
- : process.platform === "win32" ? [process.env.LOCALAPPDATA || "", process.env.PROGRAMFILES || ""]
879
- : [join(home, ".local/share"), "/opt", join(home, ".config")];
880
- 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; }
881
- return "";
882
- }
883
- // ---- background jobs --------------------------------------------------------
884
- // Run a shell command as a tracked, logged background job that shows up live in
885
- // the Agents tab. Shared by the agent handoff and the local-model setup.
886
- function track(name, cmd, cwd, onExit) {
887
- try {
888
- const dir = join(cwd, ".symbiot"); mkdirSync(dir, { recursive: true });
889
- const logp = join(dir, "agent.log");
890
- let fd = "ignore"; try { fd = openSync(logp, "a"); writeSync(fd, `\n=== ${name} ${new Date().toISOString()} ===\n$ ${cmd}\n`); } catch {}
891
- const entry = { id: randomBytes(4).toString("hex"), name, path: cwd, log: logp, startedAt: Date.now(), status: "running", exitCode: null, endedAt: null };
892
- const child = spawn(cmd, { shell: true, cwd, detached: true, stdio: ["ignore", fd === "ignore" ? "ignore" : fd, fd === "ignore" ? "ignore" : fd] });
893
- entry.pid = child.pid;
894
- child.on("exit", (code) => { entry.status = code === 0 ? "done" : "failed"; entry.exitCode = code; entry.endedAt = Date.now(); if (onExit) try { onExit(code); } catch {} });
895
- child.on("error", () => { entry.status = "failed"; entry.endedAt = Date.now(); });
896
- child.unref();
897
- HANDOFFS.unshift(entry);
898
- if (HANDOFFS.length > 30) HANDOFFS.length = 30;
899
- return entry;
900
- } catch { return null; }
901
- }
902
- // Agent-agnostic "what did it do": read it straight from git, so it works the
903
- // same whoever the agent was (Claude, Codex/GPT, Aider, Gemini, Cursor…). Shows
904
- // current working-tree changes + any commits the agent made since it started.
905
- function agentChanges(path, startedAt) {
906
- try {
907
- const q = JSON.stringify(path);
908
- const porcelain = sh(`git -C ${q} status --porcelain`).trim();
909
- // Don't count Symbiot's own .symbiot/ dir (agent.log, TASKS.md) as the agent's work.
910
- const dirty = porcelain ? porcelain.split("\n").filter((l) => { const p = l.slice(3); return p !== ".symbiot" && p !== ".symbiot/" && p.indexOf(".symbiot/") !== 0; }).length : 0;
911
- const stat = (sh(`git -C ${q} diff --shortstat`).trim() || sh(`git -C ${q} diff --cached --shortstat`).trim()).replace(/^\s+/, "");
912
- const since = new Date(startedAt || Date.now()).toISOString();
913
- const raw = sh(`git -C ${q} log --since=${JSON.stringify(since)} --pretty=%h\u0001%s`).trim();
914
- const commits = raw ? raw.split("\n").slice(0, 8).map((l) => { const i = l.indexOf("\u0001"); return { hash: l.slice(0, i), msg: l.slice(i + 1) }; }) : [];
915
- return { dirty, stat, commits };
916
- } catch { return { dirty: 0, stat: "", commits: [] }; }
917
- }
918
- // ---- agent questions: decisions, options and ideas, from ANY agent ---------
919
- // A handoff runs unattended (claude -p, codex exec, aider --message, …), so the
920
- // agent can't stop and ask in chat. TASKS.md tells it to write
921
- // .symbiot/QUESTIONS.md instead — questions with options, plus ideas — which
922
- // the Agents tab shows on that agent's block. The user's answers are appended to
923
- // .symbiot/ANSWERS.md for the next run to read. Plain files, so it works the
924
- // same whichever model or tool the agent is.
925
- const QUESTIONS_MAX = 5;
926
- const qKey = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
927
- const readSymbiot = (path, f) => { try { return readFileSync(join(path, ".symbiot", f), "utf8"); } catch { return ""; } };
928
- // "## Questions" → "### question", context lines, "- option" bullets;
929
- // "## Suggestions" (or Ideas / Follow-ups) → "- idea" bullets. Forgiving: a bare
930
- // bullet under Questions is a question with no options.
931
- function parseQuestions(md) {
932
- const questions = [], suggestions = []; let sec = "q", cur = null;
933
- const clip = (s) => String(s).trim().slice(0, 300);
934
- for (const raw of String(md || "").split(/\r?\n/)) {
935
- const l = raw.trim(); if (!l) continue;
936
- let m;
937
- if ((m = l.match(/^###\s+(.+)$/))) {
938
- if (sec === "s") { suggestions.push(clip(m[1])); cur = null; }
939
- else { cur = { q: clip(m[1]), context: "", options: [] }; questions.push(cur); }
940
- continue;
941
- }
942
- if ((m = l.match(/^##\s+(.+)$/))) { sec = /suggest|idea|follow|option/i.test(m[1]) ? "s" : "q"; cur = null; continue; }
943
- if (/^#\s/.test(l)) continue;
944
- const b = l.match(/^(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+)$/);
945
- if (sec === "s") { if (b) suggestions.push(clip(b[1])); continue; }
946
- if (b) { if (cur) cur.options.push(clip(b[1])); else questions.push({ q: clip(b[1]), context: "", options: [] }); continue; }
947
- if (cur) cur.context = clip((cur.context ? cur.context + " " : "") + l);
948
- }
949
- return { questions: questions.filter((x) => x.q).slice(0, 20).map((x) => ({ ...x, options: x.options.slice(0, 6) })), suggestions: suggestions.filter(Boolean).slice(0, 10) };
950
- }
951
- // The OPEN questions (not yet in ANSWERS.md) and the agent's ideas, each marked
952
- // if it's already on the task list for this repo.
953
- function agentQuestions(path, repo) {
954
- const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
955
- const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
956
- const open = p.questions.filter((x) => !done.has(qKey(x.q)));
957
- const tasks = p.suggestions.length ? loadTasks() : [];
958
- return {
959
- questions: open.slice(0, QUESTIONS_MAX), answered: p.questions.length - open.length,
960
- suggestions: p.suggestions.map((text) => ({ text, added: tasks.some((t) => t.repo === repo && qKey(t.text) === qKey(text)) })),
961
- };
962
- }
963
- // Save answers to .symbiot/ANSWERS.md; opts.rerun hands the repo back to the
964
- // agent (same saved command) so it carries on with them.
965
- function answerQuestions(path, answers, opts = {}) {
966
- path = String(path || "");
967
- if (!path || !HANDOFFS.some((e) => e.path === path)) return { error: "No agent has run in that folder." };
968
- const open = new Map(agentQuestions(path, "").questions.map((x) => [qKey(x.q), x.q]));
969
- const rows = (Array.isArray(answers) ? answers : [])
970
- .map((x) => ({ q: open.get(qKey(x && x.q)), a: String((x && x.a) || "").replace(/^\s*#+/gm, "").trim().slice(0, 2000) }))
971
- .filter((x) => x.q && x.a);
972
- if (!rows.length) return { error: "Pick or type at least one answer." };
973
- const prev = readSymbiot(path, "ANSWERS.md") || "# Answers from the user\nAnswers to the questions in QUESTIONS.md, newest last. Follow them; ask again in QUESTIONS.md if one is unclear.\n";
974
- const day = new Date().toISOString().slice(0, 10);
975
- try {
976
- mkdirSync(join(path, ".symbiot"), { recursive: true });
977
- writeFileSync(join(path, ".symbiot", "ANSWERS.md"), prev.replace(/\s*$/, "\n") + rows.map((x) => `\n### ${x.q}\n${x.a}\n_answered ${day}_\n`).join(""));
978
- } catch (e) { return { error: "Couldn't write ANSWERS.md: " + ((e && e.message) || e) }; }
979
- const out = { ok: true, saved: rows.length };
980
- if (opts.rerun) { const e = runHandoff(path); if (e) out.rerun = e.id; else out.note = "Answers saved. Set an agent command in Settings to have the agent pick them up automatically."; }
981
- return out;
982
- }
983
- // The Agents tab: every tracked job, its log tail, what it changed, and — on the
984
- // newest job per folder — the questions/ideas it left.
985
- function agentsList() {
986
- const seen = new Set();
987
- return HANDOFFS.map((e) => {
988
- let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {}
989
- const first = !seen.has(e.path); seen.add(e.path);
990
- return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null };
991
- });
992
- }
993
735
  // ---- local model one-command setup (Ollama), OS-aware ---------------------
994
736
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
995
737
  function hasOllama() { return !!sh("ollama --version").trim(); } // cross-platform (not POSIX command -v)
@@ -1039,122 +781,7 @@ function cmdModels() {
1039
781
  console.log("");
1040
782
  }
1041
783
 
1042
- // ---- drift: what's out of sync / stuck / at risk (deterministic git facts) -
1043
- function gitDefaultBranch(repo) {
1044
- const d = sh(`git -C ${JSON.stringify(repo)} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`).trim();
1045
- if (d) return d.replace(/^origin\//, "");
1046
- for (const b of ["main", "master", "develop"]) if (sh(`git -C ${JSON.stringify(repo)} rev-parse --verify -q refs/remotes/origin/${b} 2>/dev/null`).trim()) return b;
1047
- return sh(`git -C ${JSON.stringify(repo)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim() || "main";
1048
- }
1049
- function fetchAgeDays(repo) {
1050
- const gd = sh(`git -C ${JSON.stringify(repo)} rev-parse --git-common-dir 2>/dev/null`).trim();
1051
- if (!gd) return null;
1052
- const p = gd.startsWith("/") ? join(gd, "FETCH_HEAD") : join(repo, gd, "FETCH_HEAD");
1053
- try { return Math.floor((Date.now() - statSync(p).mtimeMs) / 86400000); } catch { return null; }
1054
- }
1055
- function worktreeCount(repo) { return sh(`git -C ${JSON.stringify(repo)} worktree list 2>/dev/null`).split("\n").filter(Boolean).length; }
1056
- // Deployed-sha commands are read ONLY from the user's own config (never from a
1057
- // repo file, which could be attacker-controlled): ~/.config/symbiot/deploys.json
1058
- function loadDeploys() { try { return JSON.parse(readFileSync(join(CONFIG_DIR, "deploys.json"), "utf8")); } catch { return {}; } }
1059
- function ciState(repo, def) {
1060
- if (!hasCmd("gh")) return null;
1061
- if (!/github\.com/i.test(sh(`git -C ${JSON.stringify(repo)} remote get-url origin 2>/dev/null`))) return null;
1062
- const j = sh(`cd ${JSON.stringify(repo)} && gh run list --branch ${def} --limit 1 --json databaseId,conclusion,status 2>/dev/null`).trim();
1063
- if (!j) return null;
1064
- try {
1065
- const a = JSON.parse(j); if (!a.length) return null; const r = a[0];
1066
- if (r.status && r.status !== "completed") return { level: "info", text: `CI on ${def}: ${r.status}` };
1067
- if (r.conclusion === "success") return { level: "info", text: `CI on ${def}: passing` };
1068
- if (r.conclusion === "failure") {
1069
- // Deterministic "not running" signal: every job has 0 steps (never started).
1070
- let notRun = false, reason = "";
1071
- try {
1072
- const jobs = (JSON.parse(sh(`cd ${JSON.stringify(repo)} && gh run view ${r.databaseId} --json jobs 2>/dev/null`)).jobs) || [];
1073
- notRun = jobs.length > 0 && jobs.every((x) => ((x.steps || []).length === 0));
1074
- if (notRun && jobs[0] && jobs[0].databaseId) {
1075
- // Only on the not-running path: fetch the one job's annotation to quote
1076
- // GitHub's exact reason (billing / spending limit / runner / disabled).
1077
- const cru = sh(`cd ${JSON.stringify(repo)} && gh api repos/{owner}/{repo}/actions/jobs/${jobs[0].databaseId} --jq .check_run_url 2>/dev/null`).trim();
1078
- const crid = (cru.match(/check-runs\/(\d+)/) || [])[1];
1079
- if (crid) { const m = sh(`cd ${JSON.stringify(repo)} && gh api repos/{owner}/{repo}/check-runs/${crid}/annotations --jq '.[0].message' 2>/dev/null`).trim(); if (m) reason = m.slice(0, 240); }
1080
- }
1081
- } catch {}
1082
- if (notRun) return { level: "warn", text: reason ? `CI is NOT running on ${def}: "${reason}"` : `CI is NOT running on ${def} — jobs never started (reason unavailable; usually a billing/spending-limit stop), not failing tests` };
1083
- return { level: "warn", text: `CI's last run on ${def} failed (a real failure — jobs ran)` };
1084
- }
1085
- if (r.conclusion) return { level: "warn", text: `CI on ${def}: ${r.conclusion}` };
1086
- return null;
1087
- } catch { return null; }
1088
- }
1089
- // For merges that aren't on the default branch, tell "likely never landed"
1090
- // (added files missing from default) from "merged another way" (all present).
1091
- function mergedOffDefault(p, defRef) {
1092
- const raw = sh(`git -C ${JSON.stringify(p)} log --merges --all --not ${defRef} --format='%H|%s' 2>/dev/null`)
1093
- .split("\n").filter((l) => /merge pull request/i.test(l));
1094
- if (!raw.length) return [];
1095
- const defFiles = new Set(sh(`git -C ${JSON.stringify(p)} ls-tree -r --name-only ${defRef} 2>/dev/null`).split("\n").filter(Boolean));
1096
- const hits = [];
1097
- for (const line of raw.slice(0, 12)) {
1098
- const [sha, subj] = line.split("|");
1099
- const prNum = (subj.match(/#(\d+)/) || [])[1] || "?";
1100
- const parents = sh(`git -C ${JSON.stringify(p)} rev-list --parents -n1 ${sha} 2>/dev/null`).trim().split(/\s+/);
1101
- const head = parents[2]; // 2nd parent = the merged branch head
1102
- let added = 0, missing = 0;
1103
- if (head) {
1104
- const mb = sh(`git -C ${JSON.stringify(p)} merge-base ${head} ${defRef} 2>/dev/null`).trim();
1105
- const files = sh(`git -C ${JSON.stringify(p)} diff --name-status ${mb || defRef} ${head} 2>/dev/null`)
1106
- .split("\n").map((l) => l.trim()).filter((l) => /^A\b|^A\t/.test(l)).map((l) => l.split(/\s+/).pop());
1107
- added = files.length;
1108
- for (const f of files) if (f && !defFiles.has(f)) missing++;
1109
- }
1110
- hits.push({ prNum, sha: sha.slice(0, 9), added, missing });
1111
- }
1112
- return hits;
1113
- }
1114
- // Drift facts for ONE repo. Compares against origin/<default> when a remote
1115
- // exists, else the local default branch (so it also works on local-only repos).
1116
- function driftRepo(p, opts = {}) {
1117
- const name = p.split("/").pop();
1118
- const def = gitDefaultBranch(p), st = repoState(p), flags = [];
1119
- const originDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/remotes/origin/${def} 2>/dev/null`).trim();
1120
- const localDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/heads/${def} 2>/dev/null`).trim();
1121
- const defRef = originDef ? `origin/${def}` : (localDef ? def : "");
1122
- const cmd = opts.deploys && (opts.deploys[p] || opts.deploys[name]);
1123
- // Fetch only when asked, or for a deploy-configured repo (comparing a live
1124
- // deployed sha against a stale origin gives a confidently wrong answer).
1125
- if (originDef && (opts.fetch || cmd)) sh(`git -C ${JSON.stringify(p)} fetch -q origin 2>/dev/null`);
1126
- const fa = fetchAgeDays(p);
1127
- const stale = fa != null && fa > 1 ? ` (as of ${fa}d ago)` : "";
1128
-
1129
- if (st.stale) flags.push({ level: "warn", text: `checkout is stale — working tree ≈ ${st.staleBy ? "HEAD~" + st.staleBy : "an older commit"}, not new work` });
1130
- else if (st.dirty) flags.push({ level: "info", text: `${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)` });
1131
- if (st.behind) flags.push({ level: "warn", text: `${st.behind} behind upstream on ${st.branch}${stale}` });
1132
- const wc = worktreeCount(p); if (wc > 1) flags.push({ level: "info", text: `${wc} checkouts of this repo` });
1133
- if (originDef) {
1134
- const unmerged = sh(`git -C ${JSON.stringify(p)} branch -r --no-merged origin/${def} 2>/dev/null`).split("\n").map((s) => s.trim()).filter((b) => b && !b.startsWith("origin/HEAD"));
1135
- if (unmerged.length) flags.push({ level: "info", text: `${unmerged.length} branch(es) with work not on ${def}` });
1136
- }
1137
- if (defRef) {
1138
- const hits = mergedOffDefault(p, defRef);
1139
- const gone = hits.filter((h) => h.added > 0 && h.missing > 0);
1140
- const other = hits.length - gone.length;
1141
- if (gone.length) flags.push({ level: "warn", text: `${gone.length} PR(s) merged off ${def} with added files MISSING from ${def} — likely never landed`, evidence: gone.slice(0, 5).map((h) => `#${h.prNum} (${h.missing}/${h.added} files missing)`).join(" | ") });
1142
- if (other > 0) flags.push({ level: "info", text: `${other} other PR merge(s) off ${def} (files present — probably re-done/squashed)` });
1143
- }
1144
- if (cmd && defRef) {
1145
- const sha = sh(cmd).trim().split(/\s+/)[0];
1146
- if (sha) {
1147
- const onDef = sh(`git -C ${JSON.stringify(p)} merge-base --is-ancestor ${sha} ${defRef} 2>/dev/null && echo Y`).trim() === "Y";
1148
- const behind = Number(sh(`git -C ${JSON.stringify(p)} rev-list --count ${sha}..${defRef} 2>/dev/null`).trim()) || 0;
1149
- const ahead = Number(sh(`git -C ${JSON.stringify(p)} rev-list --count ${defRef}..${sha} 2>/dev/null`).trim()) || 0;
1150
- if (!onDef) flags.push({ level: "warn", text: `production runs code NOT on ${def}${ahead ? ` (${ahead} commits ahead of it)` : ""}`, evidence: sha.slice(0, 9) });
1151
- else if (behind) flags.push({ level: "warn", text: `production is ${behind} behind ${def}`, evidence: sha.slice(0, 9) });
1152
- else flags.push({ level: "info", text: `production in sync with ${def}`, evidence: sha.slice(0, 9) });
1153
- }
1154
- }
1155
- if (opts.ci) { const ci = ciState(p, def); if (ci) flags.push(ci); }
1156
- return { name, path: p, def, flags, fetchAgeDays: fa };
1157
- }
784
+ // ---- drift across every repo (per-repo facts: drift.mjs) -------------------
1158
785
  function computeDrift(opts = {}) {
1159
786
  const own = scanBegin();
1160
787
  try {
@@ -1848,6 +1475,7 @@ async function cmdApp() {
1848
1475
  if (u.pathname === "/api/pending") return json(res, pendingReview()); // ticked by the agent, awaiting approval
1849
1476
  if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
1850
1477
  if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approveRepo(String(b.repo || ""))); }
1478
+ if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req); return json(res, approveChanges(String(b.repo || ""))); }
1851
1479
  if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
1852
1480
  if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
1853
1481
  if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
@@ -1856,7 +1484,7 @@ async function cmdApp() {
1856
1484
  if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
1857
1485
  if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
1858
1486
  if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
1859
- 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 : "" }); }
1487
+ if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || "")); return json(res, { opened: !!e && !e.busy, busy: !!(e && e.busy), id: e ? e.id : "" }); }
1860
1488
  if (u.pathname === "/api/setup-local" && req.method === "POST") {
1861
1489
  const b = await readBody(req);
1862
1490
  if (!hasOllama()) return json(res, { error: "not-installed", install: ollamaInstall() });
@@ -1990,4 +1618,4 @@ const isMain = (() => {
1990
1618
  })();
1991
1619
  if (isMain) main();
1992
1620
 
1993
- export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, syncTasks, pendingReview, approveRepo, sendBack, pushTasks, semverGt, updateCmd, parseQuestions, agentQuestions };
1621
+ export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, syncTasks, pendingReview, approveRepo, approveChanges, sendBack, pushTasks, semverGt, updateCmd, parseQuestions, agentQuestions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.34.0",
3
+ "version": "0.35.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": {
@@ -8,6 +8,9 @@
8
8
  },
9
9
  "files": [
10
10
  "index.mjs",
11
+ "core.mjs",
12
+ "agents.mjs",
13
+ "drift.mjs",
11
14
  "ui.mjs",
12
15
  "mail.mjs",
13
16
  "README.md",