symbiot 0.33.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/README.md +103 -108
- package/agents.mjs +308 -0
- package/core.mjs +53 -0
- package/drift.mjs +124 -0
- package/index.mjs +71 -422
- package/package.json +5 -2
- package/ui.mjs +22 -14
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 {
|
|
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,
|
|
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,12 +60,21 @@ 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)
|
|
66
66
|
function semverGt(a, b) { const p = (v) => String(v || "").replace(/^v/, "").split(/[.-]/).slice(0, 3).map((n) => parseInt(n, 10) || 0); const x = p(a), y = p(b); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; return false; }
|
|
67
|
-
|
|
67
|
+
// SYMBIOT_REGISTRY points the check at another registry (the tests use a fake one)
|
|
68
|
+
const REGISTRY = (process.env.SYMBIOT_REGISTRY || "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
69
|
+
async function checkLatest() { try { const r = await fetch(REGISTRY + "/symbiot"); if (!r.ok) return; const j = await r.json(); LATEST_VERSION = (j["dist-tags"] && j["dist-tags"].latest) || ""; } catch {} }
|
|
70
|
+
// The in-app update installs the EXACT newest version (not the `latest` tag, which
|
|
71
|
+
// npm's cache/propagation can resolve stale — that caused an update loop where the
|
|
72
|
+
// install kept re-fetching the same old version). --prefer-online skips a stale
|
|
73
|
+
// cached packument.
|
|
74
|
+
function updateCmd(latest, current, platform = process.platform) {
|
|
75
|
+
const target = latest && semverGt(latest, current) ? latest : "latest";
|
|
76
|
+
return { target, cmd: (platform === "win32" ? "npm i -g " : "npm install -g ") + "symbiot@" + target + " --prefer-online" };
|
|
77
|
+
}
|
|
68
78
|
|
|
69
79
|
// ---- colour + spinner ------------------------------------------------------
|
|
70
80
|
const c = PLAIN
|
|
@@ -84,25 +94,11 @@ function spinner(label) {
|
|
|
84
94
|
return () => { clearInterval(t); process.stderr.write("\r\x1b[K"); };
|
|
85
95
|
}
|
|
86
96
|
|
|
87
|
-
// ---- config + provider resolution
|
|
88
|
-
function loadConfig() {
|
|
89
|
-
try { return JSON.parse(readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
|
|
90
|
-
}
|
|
91
|
-
function saveConfig(cfg) {
|
|
92
|
-
try {
|
|
93
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
94
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
95
|
-
try { chmodSync(CONFIG_PATH, 0o600); } catch {}
|
|
96
|
-
return true;
|
|
97
|
-
} catch { return false; }
|
|
98
|
-
}
|
|
97
|
+
// ---- config + provider resolution (config files: core.mjs) -----------------
|
|
99
98
|
function antProfileExists() {
|
|
100
99
|
try { return existsSync(join(homedir(), ".config", "anthropic")); } catch { return false; }
|
|
101
100
|
}
|
|
102
|
-
// ---- tasks: a persistent checklist (
|
|
103
|
-
const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
|
|
104
|
-
function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
|
|
105
|
-
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) -------------------
|
|
106
102
|
function addTask(text, repo) {
|
|
107
103
|
text = String(text || "").trim().slice(0, 300);
|
|
108
104
|
if (!text) return { error: "empty" };
|
|
@@ -133,7 +129,7 @@ function syncTasks() {
|
|
|
133
129
|
const t = loadTasks(); const map = repoPathMap(); let review = 0, archived = 0; const checkedByRepo = {};
|
|
134
130
|
for (const x of t) {
|
|
135
131
|
if (x.archived || x.done || x.review || !x.repo) continue;
|
|
136
|
-
if (!(x.repo in checkedByRepo))
|
|
132
|
+
if (!(x.repo in checkedByRepo)) { const p = map[x.repo]; if (p) releaseHeldTasks(p); checkedByRepo[x.repo] = p ? completedInRepo(p) : []; }
|
|
137
133
|
if (checkedByRepo[x.repo].includes(x.text.toLowerCase())) { x.review = true; x.reviewAt = Date.now(); review++; }
|
|
138
134
|
}
|
|
139
135
|
for (const x of t) { if (x.done && !x.archived) { x.archived = true; x.archivedAt = Date.now(); archived++; } }
|
|
@@ -163,11 +159,19 @@ function workingDiff(path, cap = 400000) {
|
|
|
163
159
|
}
|
|
164
160
|
return d.length > cap ? d.slice(0, cap) + "\n… (diff truncated)" : d;
|
|
165
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).
|
|
166
164
|
function pendingReview() {
|
|
167
|
-
const by = {}; for (const x of
|
|
168
|
-
const
|
|
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() : {};
|
|
169
168
|
const am = autoMergeRepos();
|
|
170
|
-
|
|
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;
|
|
171
175
|
}
|
|
172
176
|
const branchSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "") || "tasks";
|
|
173
177
|
// Sync approved work: off the default branch onto symbiot/<task>, commit the
|
|
@@ -179,9 +183,9 @@ function shipChanges(path, texts, opts = {}) {
|
|
|
179
183
|
const ch = workingChanges(path);
|
|
180
184
|
if (!ch.files.length) return { ok: true, nothing: true, note: "No uncommitted changes — approved without a commit." };
|
|
181
185
|
if (!ch.branch || ch.branch === "HEAD") return { error: "Detached HEAD — check out a branch first." };
|
|
182
|
-
const base = gitDefaultBranch(path); let branch = ch.branch;
|
|
186
|
+
const base = gitDefaultBranch(path), day = new Date().toISOString().slice(0, 10); let branch = ch.branch;
|
|
183
187
|
if (branch === base) {
|
|
184
|
-
const stem = "symbiot/" + branchSlug(texts.length === 1 ? texts[0] : `${texts.length}-tasks-${
|
|
188
|
+
const stem = "symbiot/" + branchSlug(!texts.length ? `changes-${day}` : texts.length === 1 ? texts[0] : `${texts.length}-tasks-${day}`);
|
|
185
189
|
branch = stem; for (let i = 2; git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + branch]).ok; i++) branch = `${stem}-${i}`;
|
|
186
190
|
const sw = git(path, ["switch", "-c", branch]); if (!sw.ok) return { error: "Could not create branch: " + sw.err };
|
|
187
191
|
}
|
|
@@ -191,8 +195,8 @@ function shipChanges(path, texts, opts = {}) {
|
|
|
191
195
|
// the reset also covers repos where .symbiot isn't ignored.
|
|
192
196
|
const add = git(path, ["add", "-A"]); if (!add.ok) return { error: "git add failed: " + add.err, branch };
|
|
193
197
|
git(path, ["reset", "-q", "--", ".symbiot"]); // never ship Symbiot's own scratch
|
|
194
|
-
const subject = texts.length === 1 ? (texts[0].length > 72 ? texts[0].slice(0, 71).replace(/\s+\S*$/, "") + "…" : texts[0]) : `symbiot: ${texts.length} approved tasks`;
|
|
195
|
-
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.";
|
|
196
200
|
const cm = git(path, ["commit", "-m", subject, "-m", body]); if (!cm.ok) return { error: "Commit failed: " + (cm.err || cm.out), branch };
|
|
197
201
|
const out = { ok: true, branch, base, commit: git(path, ["rev-parse", "--short", "HEAD"]).out, subject };
|
|
198
202
|
if (opts.push === false) return out;
|
|
@@ -231,6 +235,16 @@ function approveRepo(repo, opts = {}) {
|
|
|
231
235
|
saveTasks(t);
|
|
232
236
|
return { ...r, approved: items.length };
|
|
233
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
|
+
}
|
|
234
248
|
// Not right: reopen it and untick it in TASKS.md so the agent picks it up again.
|
|
235
249
|
function sendBack(id) {
|
|
236
250
|
const t = loadTasks(); const it = t.find((x) => x.id === id); if (!it) return { error: "not found" };
|
|
@@ -299,10 +313,9 @@ function pushTasks(filter) {
|
|
|
299
313
|
if (st.stale) risk.push("stale checkout — working tree is an old snapshot, not new work");
|
|
300
314
|
if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
|
|
301
315
|
if (st.dirty && !st.stale) risk.push(`${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)`);
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
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 });
|
|
306
319
|
} catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
|
|
307
320
|
}
|
|
308
321
|
return { empty: false, written, unresolved, handoff: handoffCmd() };
|
|
@@ -310,11 +323,16 @@ function pushTasks(filter) {
|
|
|
310
323
|
function cmdPush() {
|
|
311
324
|
const r = pushTasks();
|
|
312
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; }
|
|
313
|
-
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") : "")); }
|
|
314
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})`); }
|
|
315
328
|
if (has("open")) {
|
|
316
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})."));
|
|
317
|
-
else {
|
|
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
|
+
}
|
|
318
336
|
} else {
|
|
319
337
|
console.log("\n" + c.d("Point your agent at .symbiot/TASKS.md in each repo. (add --open to run your configured agent command)"));
|
|
320
338
|
}
|
|
@@ -362,11 +380,6 @@ function ask(question, { secret = false } = {}) {
|
|
|
362
380
|
});
|
|
363
381
|
}
|
|
364
382
|
|
|
365
|
-
// ---- git ------------------------------------------------------------------
|
|
366
|
-
function sh(cmd) {
|
|
367
|
-
try { return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024, timeout: 6000, killSignal: "SIGKILL" }); }
|
|
368
|
-
catch { return ""; } // timeout or error -> empty, never hang the scan
|
|
369
|
-
}
|
|
370
383
|
// ---- scan deadline + progress ---------------------------------------------
|
|
371
384
|
// One slow disk or giant tree must never hang a scan (the 0.7.1 map hang): each
|
|
372
385
|
// scan gets an overall deadline (SYMBIOT_SCAN_TIMEOUT seconds), after which it
|
|
@@ -452,24 +465,6 @@ function authorship(repoPath) {
|
|
|
452
465
|
}
|
|
453
466
|
function authorArgs(emails) { return (emails || []).filter(Boolean).map((e) => `--author=${JSON.stringify(e)}`).join(" "); }
|
|
454
467
|
|
|
455
|
-
// Working-tree state, incl. detecting a stale/old checkout (not new work).
|
|
456
|
-
function repoState(repoPath) {
|
|
457
|
-
const branch = sh(`git -C ${JSON.stringify(repoPath)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
|
|
458
|
-
const porcelain = sh(`git -C ${JSON.stringify(repoPath)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean);
|
|
459
|
-
let del = 0, mod = 0, add = 0;
|
|
460
|
-
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++; }
|
|
461
|
-
const dirty = porcelain.length;
|
|
462
|
-
let stale = false, staleBy = 0;
|
|
463
|
-
if (dirty) {
|
|
464
|
-
for (const k of [3, 5, 10, 20, 40, 80, 160, 320]) {
|
|
465
|
-
if (!sh(`git -C ${JSON.stringify(repoPath)} rev-parse HEAD~${k} 2>/dev/null`).trim()) break;
|
|
466
|
-
if (sh(`git -C ${JSON.stringify(repoPath)} diff --quiet HEAD~${k} 2>/dev/null && echo EQ`).trim() === "EQ") { stale = true; staleBy = k; break; }
|
|
467
|
-
}
|
|
468
|
-
if (!stale && del >= 20 && del > mod && add === 0) stale = true; // mostly deletions = an old snapshot
|
|
469
|
-
}
|
|
470
|
-
const behind = Number(sh(`git -C ${JSON.stringify(repoPath)} rev-list --count HEAD..@{u} 2>/dev/null`).trim()) || 0;
|
|
471
|
-
return { branch, dirty, del, mod, add, stale, staleBy, behind };
|
|
472
|
-
}
|
|
473
468
|
function readmeInfo(repoPath) {
|
|
474
469
|
for (const f of ["README.md", "README.MD", "Readme.md", "readme.md", "README.txt", "README"]) {
|
|
475
470
|
try {
|
|
@@ -737,237 +732,6 @@ function recommendModels(hw) {
|
|
|
737
732
|
const best = (local.slice().reverse().find((m) => m.fits) || local[0]).model;
|
|
738
733
|
return { local, paid, best };
|
|
739
734
|
}
|
|
740
|
-
// ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
|
|
741
|
-
// ONE code path, whatever the agent. The handoff is a command TEMPLATE the user
|
|
742
|
-
// saves (`agentCmd`), with {dir} (repo path) and {prompt} (the task
|
|
743
|
-
// instruction), and it is the only thing that ever runs:
|
|
744
|
-
// handoffCmd() -> fillHandoff() -> runHandoff() -> track()
|
|
745
|
-
// Everything else in this section only helps pick a template: one-click presets
|
|
746
|
-
// for what's installed (detectHandoffs), the Orca preset (a template too, kept
|
|
747
|
-
// current by migrateOrcaCmd) and the cross-platform lookups behind them.
|
|
748
|
-
// claude -p "{prompt}" · aider --message "{prompt}" · code {dir}
|
|
749
|
-
// gnome-terminal --working-directory={dir} -- claude "{prompt}"
|
|
750
|
-
// Not tied to any one tool — you decide what runs.
|
|
751
|
-
const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
752
|
-
const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
|
|
753
|
-
// Plain words only: in the Orca preset this passes through two shells, so no
|
|
754
|
-
// backticks or $ (escDq covers one level).
|
|
755
|
-
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.";
|
|
756
|
-
function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(cfg.agentCmd) || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
|
|
757
|
-
// Save the template ("" clears it). Either way the legacy `ide` key goes.
|
|
758
|
-
function setHandoffCmd(cmd) {
|
|
759
|
-
const cfg = loadConfig(); const v = String(cmd || "").trim();
|
|
760
|
-
if (v) cfg.agentCmd = v; else delete cfg.agentCmd;
|
|
761
|
-
delete cfg.ide; saveConfig(cfg);
|
|
762
|
-
return { ok: true, cmd: cfg.agentCmd || "" };
|
|
763
|
-
}
|
|
764
|
-
const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
|
|
765
|
-
function runHandoff(repoPath) {
|
|
766
|
-
const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
|
|
767
|
-
return track(repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath);
|
|
768
|
-
}
|
|
769
|
-
// Presets. [cmd, label, macAppName] — macApp used to launch GUI editors on macOS
|
|
770
|
-
// where the CLI isn't on PATH (they're .app bundles).
|
|
771
|
-
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", ""]];
|
|
772
|
-
// [cmd, label, template]. Only agents that leave changes to review: a handoff
|
|
773
|
-
// runs unattended, so a "plan only" run can't ask anything and leaves nothing to
|
|
774
|
-
// approve — that preset was dropped (a saved one still runs as-is).
|
|
775
|
-
const AGENT_LIST = [
|
|
776
|
-
["claude", "Claude Code — make changes", 'claude -p "{prompt}" --permission-mode acceptEdits'],
|
|
777
|
-
["codex", "Codex (OpenAI/GPT) — make changes", 'codex exec --full-auto "{prompt}"'],
|
|
778
|
-
["aider", "Aider — make changes", 'aider --message "{prompt}" --yes'],
|
|
779
|
-
["gemini", "Gemini — make changes", 'gemini --yolo -p "{prompt}"'],
|
|
780
|
-
["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
|
|
781
|
-
];
|
|
782
|
-
function detectHandoffs() {
|
|
783
|
-
const editors = [];
|
|
784
|
-
for (const [cmd, label, app] of IDE_LIST) {
|
|
785
|
-
if (hasCmd(cmd)) editors.push({ label, tmpl: `${cmd} {dir}`, kind: "editor" });
|
|
786
|
-
else { const a = macApp(app); if (a) editors.push({ label, tmpl: `open -a ${JSON.stringify(a)} {dir}`, kind: "editor" }); }
|
|
787
|
-
}
|
|
788
|
-
const agents = AGENT_LIST.filter(([cmd]) => hasCmd(cmd)).map(([cmd, label, tmpl]) => ({ label, tmpl, kind: "agent" }));
|
|
789
|
-
// Orca IDE (any OS): register the repo + open a terminal tab. Two variants —
|
|
790
|
-
// one that just opens the repo (use Orca's own agent, e.g. GPT), and one that
|
|
791
|
-
// runs Claude in the tab for Claude users. Not Claude-only.
|
|
792
|
-
// `<orca> open` first: it launches Orca AND blocks until the runtime is
|
|
793
|
-
// reachable, so this works even when Orca is closed (its CLI can't talk to a
|
|
794
|
-
// dead app). Use the full orca-ide path, never bare `orca` (that's a different
|
|
795
|
-
// tool on PATH). If Orca is already up, `open` returns fast.
|
|
796
|
-
const orca = findOrcaCli();
|
|
797
|
-
if (orca) {
|
|
798
|
-
const q = JSON.stringify(orca);
|
|
799
|
-
agents.unshift(
|
|
800
|
-
{ label: "Orca IDE — open repo (use your Orca agent)", tmpl: orcaHandoffCmd(q, ""), kind: "agent" },
|
|
801
|
-
{ label: "Orca IDE — run Claude in a tab", tmpl: orcaHandoffCmd(q, ORCA_CLAUDE_CMD), kind: "agent" },
|
|
802
|
-
);
|
|
803
|
-
}
|
|
804
|
-
return { agents, editors };
|
|
805
|
-
}
|
|
806
|
-
// The Orca preset. Build the full Orca handoff, cold-start safe. `bin` is the
|
|
807
|
-
// quoted orca-ide path; `commandPart` is e.g. ORCA_CLAUDE_CMD or "" (open only).
|
|
808
|
-
// open launches Orca & waits for the runtime to be REACHABLE, but on a cold
|
|
809
|
-
// start the workspace graph isn't ready yet (runtime.state=graph_not_ready) and
|
|
810
|
-
// `terminal create` times out — so we poll `status` until state=ready, then add
|
|
811
|
-
// the repo and create the terminal (retry: the worktree can lag a beat behind).
|
|
812
|
-
// {prompt} needs its own (escaped) quotes: the outer shell strips the --command
|
|
813
|
-
// quotes and Orca re-runs the string in the tab, so an unquoted prompt reached
|
|
814
|
-
// the agent as just its first word ("Read").
|
|
815
|
-
const ORCA_CLAUDE_CMD = ` --command "claude \\"{prompt}\\""`;
|
|
816
|
-
function orcaHandoffCmd(bin, commandPart) {
|
|
817
|
-
const waitReady = `for i in $(seq 1 40); do ${bin} status --json 2>/dev/null | grep -q '"state": *"ready"' && break; sleep 1; done`;
|
|
818
|
-
const mkTerm = `for j in 1 2 3; do ${bin} terminal create --worktree path:{dir}${commandPart} --focus && break; sleep 2; done`;
|
|
819
|
-
return `${bin} open; ${waitReady}; ${bin} repo add --path {dir}; ${mkTerm}`;
|
|
820
|
-
}
|
|
821
|
-
// Normalise an Orca command saved by an older version (no launch / no wait-for-
|
|
822
|
-
// ready) to the current cold-start-safe form, preserving its binary path and any
|
|
823
|
-
// custom `--command`. Idempotent: already-current commands are left untouched.
|
|
824
|
-
function migrateOrcaCmd(cmd) {
|
|
825
|
-
if (!cmd || typeof cmd !== "string") return cmd;
|
|
826
|
-
if (!/orca-ide/.test(cmd) || !/\brepo add\b/.test(cmd)) return cmd;
|
|
827
|
-
const cm = cmd.match(/--command\s+"((?:[^"\\]|\\.)*)"/); // preserve a custom agent command
|
|
828
|
-
// ≤0.26 saved a bare {prompt} here (truncated to one word) — quote it
|
|
829
|
-
const inner = cm ? cm[1].replace(/(^|\s)\{prompt\}(?=\s|$)/g, '$1\\"{prompt}\\"') : "";
|
|
830
|
-
if (/status --json/.test(cmd) && /grep -q/.test(cmd) && (!cm || inner === cm[1])) return cmd; // already current
|
|
831
|
-
const bm = cmd.match(/^\s*("[^"]*"|'[^']*'|\S+)/); // leading orca-ide binary token
|
|
832
|
-
const bin = bm ? bm[1] : "";
|
|
833
|
-
const commandPart = cm ? ` --command "${inner}"` : "";
|
|
834
|
-
const rebuilt = bin ? orcaHandoffCmd(bin, commandPart) : cmd;
|
|
835
|
-
if (rebuilt !== cmd) { try { const cfg = loadConfig(); if (cfg.agentCmd === cmd) { cfg.agentCmd = rebuilt; saveConfig(cfg); } } catch {} }
|
|
836
|
-
return rebuilt;
|
|
837
|
-
}
|
|
838
|
-
// Cross-platform detection for the presets (and gh / the app's browser).
|
|
839
|
-
// "Is this command available?": command -v on posix, where on win.
|
|
840
|
-
function hasCmd(cmd) { try { return !!sh(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`).trim(); } catch { return false; } }
|
|
841
|
-
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 ""; }
|
|
842
|
-
// Find the Orca IDE CLI across OSes (known locations, then a bounded search).
|
|
843
|
-
let ORCA_CLI; // cached per process: undefined=unchecked, ""=none, string=path
|
|
844
|
-
function findOrcaCli() {
|
|
845
|
-
if (ORCA_CLI !== undefined) return ORCA_CLI;
|
|
846
|
-
ORCA_CLI = _findOrcaCli();
|
|
847
|
-
return ORCA_CLI;
|
|
848
|
-
}
|
|
849
|
-
function _findOrcaCli() {
|
|
850
|
-
const home = homedir(); const cands = [];
|
|
851
|
-
if (process.platform === "linux") cands.push(join(home, ".local/share/orca-ide/app/resources/bin/orca-ide"));
|
|
852
|
-
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")); }
|
|
853
|
-
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")); }
|
|
854
|
-
for (const c of cands) { try { if (existsSync(c)) return c; } catch {} }
|
|
855
|
-
const roots = process.platform === "darwin" ? [join(home, "Library/Application Support"), "/Applications", join(home, "Applications")]
|
|
856
|
-
: process.platform === "win32" ? [process.env.LOCALAPPDATA || "", process.env.PROGRAMFILES || ""]
|
|
857
|
-
: [join(home, ".local/share"), "/opt", join(home, ".config")];
|
|
858
|
-
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; }
|
|
859
|
-
return "";
|
|
860
|
-
}
|
|
861
|
-
// ---- background jobs --------------------------------------------------------
|
|
862
|
-
// Run a shell command as a tracked, logged background job that shows up live in
|
|
863
|
-
// the Agents tab. Shared by the agent handoff and the local-model setup.
|
|
864
|
-
function track(name, cmd, cwd, onExit) {
|
|
865
|
-
try {
|
|
866
|
-
const dir = join(cwd, ".symbiot"); mkdirSync(dir, { recursive: true });
|
|
867
|
-
const logp = join(dir, "agent.log");
|
|
868
|
-
let fd = "ignore"; try { fd = openSync(logp, "a"); writeSync(fd, `\n=== ${name} ${new Date().toISOString()} ===\n$ ${cmd}\n`); } catch {}
|
|
869
|
-
const entry = { id: randomBytes(4).toString("hex"), name, path: cwd, log: logp, startedAt: Date.now(), status: "running", exitCode: null, endedAt: null };
|
|
870
|
-
const child = spawn(cmd, { shell: true, cwd, detached: true, stdio: ["ignore", fd === "ignore" ? "ignore" : fd, fd === "ignore" ? "ignore" : fd] });
|
|
871
|
-
entry.pid = child.pid;
|
|
872
|
-
child.on("exit", (code) => { entry.status = code === 0 ? "done" : "failed"; entry.exitCode = code; entry.endedAt = Date.now(); if (onExit) try { onExit(code); } catch {} });
|
|
873
|
-
child.on("error", () => { entry.status = "failed"; entry.endedAt = Date.now(); });
|
|
874
|
-
child.unref();
|
|
875
|
-
HANDOFFS.unshift(entry);
|
|
876
|
-
if (HANDOFFS.length > 30) HANDOFFS.length = 30;
|
|
877
|
-
return entry;
|
|
878
|
-
} catch { return null; }
|
|
879
|
-
}
|
|
880
|
-
// Agent-agnostic "what did it do": read it straight from git, so it works the
|
|
881
|
-
// same whoever the agent was (Claude, Codex/GPT, Aider, Gemini, Cursor…). Shows
|
|
882
|
-
// current working-tree changes + any commits the agent made since it started.
|
|
883
|
-
function agentChanges(path, startedAt) {
|
|
884
|
-
try {
|
|
885
|
-
const q = JSON.stringify(path);
|
|
886
|
-
const porcelain = sh(`git -C ${q} status --porcelain`).trim();
|
|
887
|
-
// Don't count Symbiot's own .symbiot/ dir (agent.log, TASKS.md) as the agent's work.
|
|
888
|
-
const dirty = porcelain ? porcelain.split("\n").filter((l) => { const p = l.slice(3); return p !== ".symbiot" && p !== ".symbiot/" && p.indexOf(".symbiot/") !== 0; }).length : 0;
|
|
889
|
-
const stat = (sh(`git -C ${q} diff --shortstat`).trim() || sh(`git -C ${q} diff --cached --shortstat`).trim()).replace(/^\s+/, "");
|
|
890
|
-
const since = new Date(startedAt || Date.now()).toISOString();
|
|
891
|
-
const raw = sh(`git -C ${q} log --since=${JSON.stringify(since)} --pretty=%h\u0001%s`).trim();
|
|
892
|
-
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) }; }) : [];
|
|
893
|
-
return { dirty, stat, commits };
|
|
894
|
-
} catch { return { dirty: 0, stat: "", commits: [] }; }
|
|
895
|
-
}
|
|
896
|
-
// ---- agent questions: decisions, options and ideas, from ANY agent ---------
|
|
897
|
-
// A handoff runs unattended (claude -p, codex exec, aider --message, …), so the
|
|
898
|
-
// agent can't stop and ask in chat. TASKS.md tells it to write
|
|
899
|
-
// .symbiot/QUESTIONS.md instead — questions with options, plus ideas — which
|
|
900
|
-
// the Agents tab shows on that agent's block. The user's answers are appended to
|
|
901
|
-
// .symbiot/ANSWERS.md for the next run to read. Plain files, so it works the
|
|
902
|
-
// same whichever model or tool the agent is.
|
|
903
|
-
const QUESTIONS_MAX = 5;
|
|
904
|
-
const qKey = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
|
|
905
|
-
const readSymbiot = (path, f) => { try { return readFileSync(join(path, ".symbiot", f), "utf8"); } catch { return ""; } };
|
|
906
|
-
// "## Questions" → "### question", context lines, "- option" bullets;
|
|
907
|
-
// "## Suggestions" (or Ideas / Follow-ups) → "- idea" bullets. Forgiving: a bare
|
|
908
|
-
// bullet under Questions is a question with no options.
|
|
909
|
-
function parseQuestions(md) {
|
|
910
|
-
const questions = [], suggestions = []; let sec = "q", cur = null;
|
|
911
|
-
const clip = (s) => String(s).trim().slice(0, 300);
|
|
912
|
-
for (const raw of String(md || "").split(/\r?\n/)) {
|
|
913
|
-
const l = raw.trim(); if (!l) continue;
|
|
914
|
-
let m;
|
|
915
|
-
if ((m = l.match(/^###\s+(.+)$/))) {
|
|
916
|
-
if (sec === "s") { suggestions.push(clip(m[1])); cur = null; }
|
|
917
|
-
else { cur = { q: clip(m[1]), context: "", options: [] }; questions.push(cur); }
|
|
918
|
-
continue;
|
|
919
|
-
}
|
|
920
|
-
if ((m = l.match(/^##\s+(.+)$/))) { sec = /suggest|idea|follow|option/i.test(m[1]) ? "s" : "q"; cur = null; continue; }
|
|
921
|
-
if (/^#\s/.test(l)) continue;
|
|
922
|
-
const b = l.match(/^(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+)$/);
|
|
923
|
-
if (sec === "s") { if (b) suggestions.push(clip(b[1])); continue; }
|
|
924
|
-
if (b) { if (cur) cur.options.push(clip(b[1])); else questions.push({ q: clip(b[1]), context: "", options: [] }); continue; }
|
|
925
|
-
if (cur) cur.context = clip((cur.context ? cur.context + " " : "") + l);
|
|
926
|
-
}
|
|
927
|
-
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) };
|
|
928
|
-
}
|
|
929
|
-
// The OPEN questions (not yet in ANSWERS.md) and the agent's ideas, each marked
|
|
930
|
-
// if it's already on the task list for this repo.
|
|
931
|
-
function agentQuestions(path, repo) {
|
|
932
|
-
const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
|
|
933
|
-
const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
|
|
934
|
-
const open = p.questions.filter((x) => !done.has(qKey(x.q)));
|
|
935
|
-
const tasks = p.suggestions.length ? loadTasks() : [];
|
|
936
|
-
return {
|
|
937
|
-
questions: open.slice(0, QUESTIONS_MAX), answered: p.questions.length - open.length,
|
|
938
|
-
suggestions: p.suggestions.map((text) => ({ text, added: tasks.some((t) => t.repo === repo && qKey(t.text) === qKey(text)) })),
|
|
939
|
-
};
|
|
940
|
-
}
|
|
941
|
-
// Save answers to .symbiot/ANSWERS.md; opts.rerun hands the repo back to the
|
|
942
|
-
// agent (same saved command) so it carries on with them.
|
|
943
|
-
function answerQuestions(path, answers, opts = {}) {
|
|
944
|
-
path = String(path || "");
|
|
945
|
-
if (!path || !HANDOFFS.some((e) => e.path === path)) return { error: "No agent has run in that folder." };
|
|
946
|
-
const open = new Map(agentQuestions(path, "").questions.map((x) => [qKey(x.q), x.q]));
|
|
947
|
-
const rows = (Array.isArray(answers) ? answers : [])
|
|
948
|
-
.map((x) => ({ q: open.get(qKey(x && x.q)), a: String((x && x.a) || "").replace(/^\s*#+/gm, "").trim().slice(0, 2000) }))
|
|
949
|
-
.filter((x) => x.q && x.a);
|
|
950
|
-
if (!rows.length) return { error: "Pick or type at least one answer." };
|
|
951
|
-
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";
|
|
952
|
-
const day = new Date().toISOString().slice(0, 10);
|
|
953
|
-
try {
|
|
954
|
-
mkdirSync(join(path, ".symbiot"), { recursive: true });
|
|
955
|
-
writeFileSync(join(path, ".symbiot", "ANSWERS.md"), prev.replace(/\s*$/, "\n") + rows.map((x) => `\n### ${x.q}\n${x.a}\n_answered ${day}_\n`).join(""));
|
|
956
|
-
} catch (e) { return { error: "Couldn't write ANSWERS.md: " + ((e && e.message) || e) }; }
|
|
957
|
-
const out = { ok: true, saved: rows.length };
|
|
958
|
-
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."; }
|
|
959
|
-
return out;
|
|
960
|
-
}
|
|
961
|
-
// The Agents tab: every tracked job, its log tail, what it changed, and — on the
|
|
962
|
-
// newest job per folder — the questions/ideas it left.
|
|
963
|
-
function agentsList() {
|
|
964
|
-
const seen = new Set();
|
|
965
|
-
return HANDOFFS.map((e) => {
|
|
966
|
-
let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {}
|
|
967
|
-
const first = !seen.has(e.path); seen.add(e.path);
|
|
968
|
-
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 };
|
|
969
|
-
});
|
|
970
|
-
}
|
|
971
735
|
// ---- local model one-command setup (Ollama), OS-aware ---------------------
|
|
972
736
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
973
737
|
function hasOllama() { return !!sh("ollama --version").trim(); } // cross-platform (not POSIX command -v)
|
|
@@ -1017,122 +781,7 @@ function cmdModels() {
|
|
|
1017
781
|
console.log("");
|
|
1018
782
|
}
|
|
1019
783
|
|
|
1020
|
-
// ---- drift
|
|
1021
|
-
function gitDefaultBranch(repo) {
|
|
1022
|
-
const d = sh(`git -C ${JSON.stringify(repo)} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`).trim();
|
|
1023
|
-
if (d) return d.replace(/^origin\//, "");
|
|
1024
|
-
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;
|
|
1025
|
-
return sh(`git -C ${JSON.stringify(repo)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim() || "main";
|
|
1026
|
-
}
|
|
1027
|
-
function fetchAgeDays(repo) {
|
|
1028
|
-
const gd = sh(`git -C ${JSON.stringify(repo)} rev-parse --git-common-dir 2>/dev/null`).trim();
|
|
1029
|
-
if (!gd) return null;
|
|
1030
|
-
const p = gd.startsWith("/") ? join(gd, "FETCH_HEAD") : join(repo, gd, "FETCH_HEAD");
|
|
1031
|
-
try { return Math.floor((Date.now() - statSync(p).mtimeMs) / 86400000); } catch { return null; }
|
|
1032
|
-
}
|
|
1033
|
-
function worktreeCount(repo) { return sh(`git -C ${JSON.stringify(repo)} worktree list 2>/dev/null`).split("\n").filter(Boolean).length; }
|
|
1034
|
-
// Deployed-sha commands are read ONLY from the user's own config (never from a
|
|
1035
|
-
// repo file, which could be attacker-controlled): ~/.config/symbiot/deploys.json
|
|
1036
|
-
function loadDeploys() { try { return JSON.parse(readFileSync(join(CONFIG_DIR, "deploys.json"), "utf8")); } catch { return {}; } }
|
|
1037
|
-
function ciState(repo, def) {
|
|
1038
|
-
if (!hasCmd("gh")) return null;
|
|
1039
|
-
if (!/github\.com/i.test(sh(`git -C ${JSON.stringify(repo)} remote get-url origin 2>/dev/null`))) return null;
|
|
1040
|
-
const j = sh(`cd ${JSON.stringify(repo)} && gh run list --branch ${def} --limit 1 --json databaseId,conclusion,status 2>/dev/null`).trim();
|
|
1041
|
-
if (!j) return null;
|
|
1042
|
-
try {
|
|
1043
|
-
const a = JSON.parse(j); if (!a.length) return null; const r = a[0];
|
|
1044
|
-
if (r.status && r.status !== "completed") return { level: "info", text: `CI on ${def}: ${r.status}` };
|
|
1045
|
-
if (r.conclusion === "success") return { level: "info", text: `CI on ${def}: passing` };
|
|
1046
|
-
if (r.conclusion === "failure") {
|
|
1047
|
-
// Deterministic "not running" signal: every job has 0 steps (never started).
|
|
1048
|
-
let notRun = false, reason = "";
|
|
1049
|
-
try {
|
|
1050
|
-
const jobs = (JSON.parse(sh(`cd ${JSON.stringify(repo)} && gh run view ${r.databaseId} --json jobs 2>/dev/null`)).jobs) || [];
|
|
1051
|
-
notRun = jobs.length > 0 && jobs.every((x) => ((x.steps || []).length === 0));
|
|
1052
|
-
if (notRun && jobs[0] && jobs[0].databaseId) {
|
|
1053
|
-
// Only on the not-running path: fetch the one job's annotation to quote
|
|
1054
|
-
// GitHub's exact reason (billing / spending limit / runner / disabled).
|
|
1055
|
-
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();
|
|
1056
|
-
const crid = (cru.match(/check-runs\/(\d+)/) || [])[1];
|
|
1057
|
-
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); }
|
|
1058
|
-
}
|
|
1059
|
-
} catch {}
|
|
1060
|
-
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` };
|
|
1061
|
-
return { level: "warn", text: `CI's last run on ${def} failed (a real failure — jobs ran)` };
|
|
1062
|
-
}
|
|
1063
|
-
if (r.conclusion) return { level: "warn", text: `CI on ${def}: ${r.conclusion}` };
|
|
1064
|
-
return null;
|
|
1065
|
-
} catch { return null; }
|
|
1066
|
-
}
|
|
1067
|
-
// For merges that aren't on the default branch, tell "likely never landed"
|
|
1068
|
-
// (added files missing from default) from "merged another way" (all present).
|
|
1069
|
-
function mergedOffDefault(p, defRef) {
|
|
1070
|
-
const raw = sh(`git -C ${JSON.stringify(p)} log --merges --all --not ${defRef} --format='%H|%s' 2>/dev/null`)
|
|
1071
|
-
.split("\n").filter((l) => /merge pull request/i.test(l));
|
|
1072
|
-
if (!raw.length) return [];
|
|
1073
|
-
const defFiles = new Set(sh(`git -C ${JSON.stringify(p)} ls-tree -r --name-only ${defRef} 2>/dev/null`).split("\n").filter(Boolean));
|
|
1074
|
-
const hits = [];
|
|
1075
|
-
for (const line of raw.slice(0, 12)) {
|
|
1076
|
-
const [sha, subj] = line.split("|");
|
|
1077
|
-
const prNum = (subj.match(/#(\d+)/) || [])[1] || "?";
|
|
1078
|
-
const parents = sh(`git -C ${JSON.stringify(p)} rev-list --parents -n1 ${sha} 2>/dev/null`).trim().split(/\s+/);
|
|
1079
|
-
const head = parents[2]; // 2nd parent = the merged branch head
|
|
1080
|
-
let added = 0, missing = 0;
|
|
1081
|
-
if (head) {
|
|
1082
|
-
const mb = sh(`git -C ${JSON.stringify(p)} merge-base ${head} ${defRef} 2>/dev/null`).trim();
|
|
1083
|
-
const files = sh(`git -C ${JSON.stringify(p)} diff --name-status ${mb || defRef} ${head} 2>/dev/null`)
|
|
1084
|
-
.split("\n").map((l) => l.trim()).filter((l) => /^A\b|^A\t/.test(l)).map((l) => l.split(/\s+/).pop());
|
|
1085
|
-
added = files.length;
|
|
1086
|
-
for (const f of files) if (f && !defFiles.has(f)) missing++;
|
|
1087
|
-
}
|
|
1088
|
-
hits.push({ prNum, sha: sha.slice(0, 9), added, missing });
|
|
1089
|
-
}
|
|
1090
|
-
return hits;
|
|
1091
|
-
}
|
|
1092
|
-
// Drift facts for ONE repo. Compares against origin/<default> when a remote
|
|
1093
|
-
// exists, else the local default branch (so it also works on local-only repos).
|
|
1094
|
-
function driftRepo(p, opts = {}) {
|
|
1095
|
-
const name = p.split("/").pop();
|
|
1096
|
-
const def = gitDefaultBranch(p), st = repoState(p), flags = [];
|
|
1097
|
-
const originDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/remotes/origin/${def} 2>/dev/null`).trim();
|
|
1098
|
-
const localDef = sh(`git -C ${JSON.stringify(p)} rev-parse --verify -q refs/heads/${def} 2>/dev/null`).trim();
|
|
1099
|
-
const defRef = originDef ? `origin/${def}` : (localDef ? def : "");
|
|
1100
|
-
const cmd = opts.deploys && (opts.deploys[p] || opts.deploys[name]);
|
|
1101
|
-
// Fetch only when asked, or for a deploy-configured repo (comparing a live
|
|
1102
|
-
// deployed sha against a stale origin gives a confidently wrong answer).
|
|
1103
|
-
if (originDef && (opts.fetch || cmd)) sh(`git -C ${JSON.stringify(p)} fetch -q origin 2>/dev/null`);
|
|
1104
|
-
const fa = fetchAgeDays(p);
|
|
1105
|
-
const stale = fa != null && fa > 1 ? ` (as of ${fa}d ago)` : "";
|
|
1106
|
-
|
|
1107
|
-
if (st.stale) flags.push({ level: "warn", text: `checkout is stale — working tree ≈ ${st.staleBy ? "HEAD~" + st.staleBy : "an older commit"}, not new work` });
|
|
1108
|
-
else if (st.dirty) flags.push({ level: "info", text: `${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)` });
|
|
1109
|
-
if (st.behind) flags.push({ level: "warn", text: `${st.behind} behind upstream on ${st.branch}${stale}` });
|
|
1110
|
-
const wc = worktreeCount(p); if (wc > 1) flags.push({ level: "info", text: `${wc} checkouts of this repo` });
|
|
1111
|
-
if (originDef) {
|
|
1112
|
-
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"));
|
|
1113
|
-
if (unmerged.length) flags.push({ level: "info", text: `${unmerged.length} branch(es) with work not on ${def}` });
|
|
1114
|
-
}
|
|
1115
|
-
if (defRef) {
|
|
1116
|
-
const hits = mergedOffDefault(p, defRef);
|
|
1117
|
-
const gone = hits.filter((h) => h.added > 0 && h.missing > 0);
|
|
1118
|
-
const other = hits.length - gone.length;
|
|
1119
|
-
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(" | ") });
|
|
1120
|
-
if (other > 0) flags.push({ level: "info", text: `${other} other PR merge(s) off ${def} (files present — probably re-done/squashed)` });
|
|
1121
|
-
}
|
|
1122
|
-
if (cmd && defRef) {
|
|
1123
|
-
const sha = sh(cmd).trim().split(/\s+/)[0];
|
|
1124
|
-
if (sha) {
|
|
1125
|
-
const onDef = sh(`git -C ${JSON.stringify(p)} merge-base --is-ancestor ${sha} ${defRef} 2>/dev/null && echo Y`).trim() === "Y";
|
|
1126
|
-
const behind = Number(sh(`git -C ${JSON.stringify(p)} rev-list --count ${sha}..${defRef} 2>/dev/null`).trim()) || 0;
|
|
1127
|
-
const ahead = Number(sh(`git -C ${JSON.stringify(p)} rev-list --count ${defRef}..${sha} 2>/dev/null`).trim()) || 0;
|
|
1128
|
-
if (!onDef) flags.push({ level: "warn", text: `production runs code NOT on ${def}${ahead ? ` (${ahead} commits ahead of it)` : ""}`, evidence: sha.slice(0, 9) });
|
|
1129
|
-
else if (behind) flags.push({ level: "warn", text: `production is ${behind} behind ${def}`, evidence: sha.slice(0, 9) });
|
|
1130
|
-
else flags.push({ level: "info", text: `production in sync with ${def}`, evidence: sha.slice(0, 9) });
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
if (opts.ci) { const ci = ciState(p, def); if (ci) flags.push(ci); }
|
|
1134
|
-
return { name, path: p, def, flags, fetchAgeDays: fa };
|
|
1135
|
-
}
|
|
784
|
+
// ---- drift across every repo (per-repo facts: drift.mjs) -------------------
|
|
1136
785
|
function computeDrift(opts = {}) {
|
|
1137
786
|
const own = scanBegin();
|
|
1138
787
|
try {
|
|
@@ -1789,7 +1438,7 @@ async function cmdApp() {
|
|
|
1789
1438
|
if (r && r.ok) {
|
|
1790
1439
|
const p = await r.json().catch(() => ({}));
|
|
1791
1440
|
if (p && p.version) {
|
|
1792
|
-
const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = openApp(url);
|
|
1441
|
+
const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url);
|
|
1793
1442
|
console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
|
|
1794
1443
|
console.log(how ? c.d(` Opened the existing window (a ${how}).`) : c.d(" Open that URL in your browser."));
|
|
1795
1444
|
console.log(c.d(" (Not starting a second copy. Set SYMBIOT_FORCE_NEW=1 to force one.)"));
|
|
@@ -1826,6 +1475,7 @@ async function cmdApp() {
|
|
|
1826
1475
|
if (u.pathname === "/api/pending") return json(res, pendingReview()); // ticked by the agent, awaiting approval
|
|
1827
1476
|
if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
|
|
1828
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 || ""))); }
|
|
1829
1479
|
if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
|
|
1830
1480
|
if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
|
|
1831
1481
|
if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
|
|
@@ -1834,7 +1484,7 @@ async function cmdApp() {
|
|
|
1834
1484
|
if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
|
|
1835
1485
|
if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
|
|
1836
1486
|
if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
|
|
1837
|
-
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 : "" }); }
|
|
1838
1488
|
if (u.pathname === "/api/setup-local" && req.method === "POST") {
|
|
1839
1489
|
const b = await readBody(req);
|
|
1840
1490
|
if (!hasOllama()) return json(res, { error: "not-installed", install: ollamaInstall() });
|
|
@@ -1852,14 +1502,10 @@ async function cmdApp() {
|
|
|
1852
1502
|
if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
|
|
1853
1503
|
if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1") await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: semverGt(LATEST_VERSION, VERSION) }); }
|
|
1854
1504
|
if (u.pathname === "/api/update" && req.method === "POST") {
|
|
1855
|
-
// Install the
|
|
1856
|
-
//
|
|
1857
|
-
//
|
|
1858
|
-
|
|
1859
|
-
// a stale cached packument. The page's heartbeat reconnects and reloads.
|
|
1860
|
-
const target = LATEST_VERSION && semverGt(LATEST_VERSION, VERSION) ? LATEST_VERSION : "latest";
|
|
1861
|
-
const pkg = "symbiot@" + target;
|
|
1862
|
-
const inst = (process.platform === "win32" ? "npm i -g " : "npm install -g ") + pkg + " --prefer-online";
|
|
1505
|
+
// Install the exact newest version (see updateCmd), then relaunch this
|
|
1506
|
+
// same app (same port+token => same URL) and exit. The page's heartbeat
|
|
1507
|
+
// reconnects and reloads.
|
|
1508
|
+
const { target, cmd: inst } = updateCmd(LATEST_VERSION, VERSION);
|
|
1863
1509
|
const e = track("symbiot update", inst, homedir(), (code) => {
|
|
1864
1510
|
if (code !== 0) return;
|
|
1865
1511
|
try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: process.env }); ch.unref(); } catch {}
|
|
@@ -1896,7 +1542,7 @@ function cmdMail() {
|
|
|
1896
1542
|
const add = flag("add", null);
|
|
1897
1543
|
if (add) { const r = setMail({ add }); if (r.error) console.log(c.y(r.error)); }
|
|
1898
1544
|
const m = mailState();
|
|
1899
|
-
console.log(`\n${c.g("●")} ${c.b("Symbiot mail")} ${c.d("· " + (m.enabled ? "on — what you sent feeds week and standup" : "off"))}\n`);
|
|
1545
|
+
console.log(`\n${c.g("●")} ${c.b("Symbiot mail")} ${c.d("(experimental) · " + (m.enabled ? "on — what you sent feeds week and standup" : "off"))}\n`);
|
|
1900
1546
|
const srcs = [...m.detected.map((d) => [d.path, d.kind]), ...m.sources.map((p) => [p, "added"])];
|
|
1901
1547
|
if (!srcs.length) console.log(c.y("No mail found on this computer.") + c.d(" Use a desktop mail app (Thunderbird, Apple Mail, Evolution…), or add an export: symbiot mail --add ~/Takeout/Mail/All.mbox"));
|
|
1902
1548
|
for (const [p, k] of srcs) console.log(` ${c.g("·")} ${p} ${c.d(k)}`);
|
|
@@ -1915,16 +1561,19 @@ ${c.b("Usage")}
|
|
|
1915
1561
|
symbiot standup yesterday + today, for standup
|
|
1916
1562
|
symbiot todo what's still on your plate
|
|
1917
1563
|
symbiot app open the visual app in your browser
|
|
1918
|
-
symbiot drift
|
|
1564
|
+
symbiot drift [--fetch] what's out of sync / at risk across repos
|
|
1919
1565
|
symbiot push [--open] write tasks into each repo (and run your agent)
|
|
1920
|
-
symbiot mail [--on|--off] use the mail you sent in write-ups (local, no API)
|
|
1921
|
-
symbiot models recommend AI models for your hardware
|
|
1922
|
-
symbiot setup-local [--model X] install/run a free local model (Ollama)
|
|
1923
1566
|
symbiot login connect it to an AI (once)
|
|
1924
1567
|
symbiot whoami show how it's connected
|
|
1925
1568
|
symbiot logout forget saved credentials
|
|
1926
1569
|
symbiot help
|
|
1927
1570
|
|
|
1571
|
+
${c.b("Experimental")}
|
|
1572
|
+
symbiot drift --ci also check GitHub Actions (needs gh)
|
|
1573
|
+
symbiot mail [--on|--off] use the mail you sent in write-ups (local, no API)
|
|
1574
|
+
symbiot models recommend AI models for your hardware
|
|
1575
|
+
symbiot setup-local [--model X] install/run a free local model (Ollama)
|
|
1576
|
+
|
|
1928
1577
|
${c.b("Options")}
|
|
1929
1578
|
--dir <path> where your repos are (default: ${homedir()})
|
|
1930
1579
|
--since <days> window for 'week' (default 7)
|
|
@@ -1969,4 +1618,4 @@ const isMain = (() => {
|
|
|
1969
1618
|
})();
|
|
1970
1619
|
if (isMain) main();
|
|
1971
1620
|
|
|
1972
|
-
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, 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 };
|