symbiot 0.40.4 → 0.41.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/tasks.mjs ADDED
@@ -0,0 +1,445 @@
1
+ // Tasks, from your list to a merged PR: the checklist (tasks.json), the brief
2
+ // each repo gets (.symbiot/TASKS.md), what the agent ticked (review), and
3
+ // Approve: branch, commit, push, PR, and the version bump and npm release facts
4
+ // it shows.
5
+ import { spawnSync } from "node:child_process";
6
+ import { join } from "node:path";
7
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
8
+ import { randomBytes } from "node:crypto";
9
+ import { VERSION, LATEST_VERSION, REGISTRY, semverGt, loadConfig, saveConfig, loadTasks, saveTasks, sh, hasCmd, repoState } from "./core.mjs";
10
+ import { QUESTIONS_MAX, handoffCmd, runningHandoff, writeTasks, startHeldTasks } from "./agents.mjs";
11
+ import { gitDefaultBranch } from "./drift.mjs";
12
+ import { repoPathMap, openWork, detectRepo } from "./scan.mjs";
13
+
14
+ // ---- tasks: a persistent checklist (stored by core.mjs) -------------------
15
+ // One line (TASKS.md has a task per line, and a tick only matches a whole one),
16
+ // long enough for a pasted list of next steps: 300 cut one off mid-list.
17
+ function addTask(text, repo) {
18
+ text = String(text || "").replace(/\s+/g, " ").trim().slice(0, 1000);
19
+ if (!text) return { error: "empty" };
20
+ const t = loadTasks();
21
+ // The same text already open in the same repo (in review counts): that task, not a duplicate.
22
+ const same = (s) => String(s || "").replace(/\s+/g, " ").trim().toLowerCase();
23
+ const dup = t.find((x) => !x.done && !x.archived && (x.repo || "") === (repo || "") && same(x.text) === same(text));
24
+ if (dup) return { ...dup, duplicate: true };
25
+ const item = { id: randomBytes(6).toString("hex"), text, repo: repo || "", done: false, ts: Date.now() };
26
+ t.unshift(item); saveTasks(t); return item;
27
+ }
28
+ function toggleTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.done = !it.done; saveTasks(t); } return it || { error: "not found" }; }
29
+ function removeTask(id) { saveTasks(loadTasks().filter((x) => x.id !== id)); return { ok: true }; }
30
+ function restoreTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.archived = false; it.done = false; delete it.archivedAt; saveTasks(t); } return it || { error: "not found" }; }
31
+ // Which task texts the agent checked off in a repo's .symbiot/TASKS.md
32
+ function completedInRepo(repoPath) {
33
+ try { return readFileSync(join(repoPath, ".symbiot", "TASKS.md"), "utf8").split("\n").filter((l) => /^\s*-\s*\[x\]/i.test(l)).map((l) => l.replace(/^\s*-\s*\[x\]\s*/i, "").trim().toLowerCase()); }
34
+ catch { return []; }
35
+ }
36
+
37
+ // "Check what was handed out, see what's completed, then archive it" — with an
38
+ // approval step in between. An agent ticking an item in TASKS.md means "done,
39
+ // please review", NOT archived: it waits in review until the user approves it
40
+ // (approveRepo: branch + commit + push + PR, then archive) or sends it back
41
+ // (sendBack: unticked, open again). Only a task the USER ticks archives directly.
42
+ // Tasks held for an agent that has since finished land here too, and an agent
43
+ // starts on what's still open in them (one `push --open` started can't do that).
44
+ function syncTasks() {
45
+ const t = loadTasks(); const map = repoPathMap(); let review = 0, archived = 0, started = 0; const checkedByRepo = {};
46
+ for (const x of t) {
47
+ if (x.archived || x.done || x.review || !x.repo) continue;
48
+ if (!(x.repo in checkedByRepo)) { const p = map[x.repo]; if (p && startHeldTasks(p)) started++; checkedByRepo[x.repo] = p ? completedInRepo(p) : []; }
49
+ if (checkedByRepo[x.repo].includes(x.text.toLowerCase())) { x.review = true; x.reviewAt = Date.now(); review++; }
50
+ }
51
+ for (const x of t) { if (x.done && !x.archived) { x.archived = true; x.archivedAt = Date.now(); archived++; } }
52
+ saveTasks(t);
53
+ return { review, archived, started };
54
+ }
55
+ // git with an argv (task text goes into commit messages — never through a shell)
56
+ function git(repo, args, timeout = 30000) {
57
+ const r = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8", timeout, maxBuffer: 32 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
58
+ return { ok: r.status === 0, out: String(r.stdout || "").replace(/\s+$/, ""), err: String(r.stderr || (r.error && r.error.message) || "").trim() };
59
+ }
60
+ const NOT_SYMBIOT = ["--", ".", ":(exclude).symbiot"]; // .symbiot/ is Symbiot's scratch (brief + agent log), never shipped
61
+ function workingChanges(path) {
62
+ const st = git(path, ["status", "--porcelain", "-uall", ...NOT_SYMBIOT]);
63
+ const files = st.out ? st.out.split("\n").map((l) => ({ st: l.slice(0, 2).trim(), file: l.slice(3) })) : [];
64
+ const short = git(path, ["diff", "--shortstat", "HEAD", ...NOT_SYMBIOT]).out, n = (re) => +((short.match(re) || [])[1] || 0);
65
+ const ins = n(/(\d+) insertion/), del = n(/(\d+) deletion/), fresh = files.filter((f) => f.st === "??").length;
66
+ const stat = [ins && `+${ins}`, del && `−${del}`, fresh && `${fresh} new`].filter(Boolean).join(" ");
67
+ return { branch: git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).out, files, stat };
68
+ }
69
+ // The full diff the agent left behind: tracked changes + new files, capped.
70
+ function workingDiff(path, cap = 400000) {
71
+ let d = git(path, ["diff", "HEAD", ...NOT_SYMBIOT]).out;
72
+ for (const f of workingChanges(path).files.filter((x) => x.st === "??")) {
73
+ if (d.length > cap) break;
74
+ d += (d ? "\n" : "") + git(path, ["diff", "--no-index", "--", "/dev/null", f.file]).out; // exits 1 on a difference — output is what we want
75
+ }
76
+ return d.length > cap ? d.slice(0, cap) + "\n… (diff truncated)" : d;
77
+ }
78
+ // A repo that publishes on merge: a GitHub workflow runs `npm publish` on a push
79
+ // to a branch, not only on a tag. Its release is the version npm has; a v* tag,
80
+ // if any, is a best-effort record that can lag or be missing.
81
+ function publishesOnMerge(path) {
82
+ const dir = join(path, ".github", "workflows"); let files = []; try { files = readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)); } catch { return false; }
83
+ return files.some((f) => { let t = ""; try { t = readFileSync(join(dir, f), "utf8"); } catch {} return /\bnpm publish\b/.test(t) && /^\s*push:\s*\n\s+branches:/m.test(t); });
84
+ }
85
+ // Merged work that isn't released yet, or null when there's none, or the repo
86
+ // has no releases to measure from. bump: the version these uncommitted changes
87
+ // set in package.json, if they change it.
88
+ // - Released by v* tag: how many commits the default branch (or origin's copy,
89
+ // where approved PRs merge) is past its last v* tag.
90
+ // - Publishes on merge (npm: true): how many commits it's past the one that set
91
+ // the version npm has (since), once learnNpm knows. A branch whose version
92
+ // isn't on npm yet is merged and due to publish: { pending: version }.
93
+ function unreleased(path) {
94
+ const base = gitDefaultBranch(path), pom = publishesOnMerge(path); let best = null;
95
+ for (const ref of ["refs/heads/" + base, "refs/remotes/origin/" + base]) {
96
+ if (!git(path, ["rev-parse", "-q", "--verify", ref]).ok) continue;
97
+ let r = null;
98
+ if (pom) {
99
+ const k = npmKey(path, ref), v = k.slice(k.lastIndexOf("@") + 1), known = k && NPM_HAS.get(k); if (!known) continue;
100
+ if (!known.yes) r = { base, npm: true, pending: v, ahead: 0 };
101
+ else {
102
+ const set = git(path, ["log", "-1", "--format=%H", "-G", `"version"[[:space:]]*:[[:space:]]*"${v.replace(/\./g, "\\.")}"`, ref, "--", "package.json"]).out;
103
+ r = { base, npm: true, since: v, ahead: set ? +git(path, ["rev-list", "--count", set + ".." + ref]).out || 0 : 0 };
104
+ }
105
+ } else {
106
+ const tag = git(path, ["describe", "--tags", "--abbrev=0", "--match", "v*", ref]).out; if (!tag) continue;
107
+ r = { base, tag, ahead: +git(path, ["rev-list", "--count", tag + ".." + ref]).out || 0 };
108
+ }
109
+ // the copy furthest ahead; a merged version still to publish says more than a count
110
+ if (!best || (r.pending && !best.pending) || (!best.pending && r.ahead > best.ahead)) best = r;
111
+ }
112
+ if (!best || (!best.ahead && !best.pending)) return null;
113
+ const ver = (s) => { try { return String(JSON.parse(s).version || ""); } catch { return ""; } };
114
+ let now = ""; try { now = ver(readFileSync(join(path, "package.json"), "utf8")); } catch {}
115
+ const was = ver(git(path, ["show", "HEAD:package.json"]).out);
116
+ return { ...best, ...(now && was && now !== was ? { bump: now } : {}) };
117
+ }
118
+ // Approve can bump the version in the PR itself: offered when the committed
119
+ // version is already released and these changes don't change it. Released: its
120
+ // v* tag exists, or npm has it (a repo that publishes on merge may have no tag,
121
+ // or one its release couldn't push). { version, patch, minor } or null.
122
+ function bumpOffer(path) {
123
+ const ver = (s) => { try { return String(JSON.parse(s).version || ""); } catch { return ""; } };
124
+ const was = ver(git(path, ["show", "HEAD:package.json"]).out), m = was.match(/^(\d+)\.(\d+)\.(\d+)$/); if (!m) return null;
125
+ let now = ""; try { now = ver(readFileSync(join(path, "package.json"), "utf8")); } catch {}
126
+ if (now !== was || !(git(path, ["rev-parse", "-q", "--verify", "refs/tags/v" + was]).ok || onNpm(path))) return null;
127
+ return { version: was, patch: `${m[1]}.${m[2]}.${+m[3] + 1}`, minor: `${m[1]}.${+m[2] + 1}.0` };
128
+ }
129
+ // Whether npm has the committed version, as learnNpm last found ("name@version" →
130
+ // { yes, at }). bumpOffer only reads this; learnNpm asks the registry first.
131
+ const NPM_HAS = new Map();
132
+ function npmKey(path, ref = "HEAD") {
133
+ let p = {}; try { p = JSON.parse(git(path, ["show", ref + ":package.json"]).out) || {}; } catch {}
134
+ return typeof p.name === "string" && p.name && !p.private && typeof p.version === "string" && p.version ? p.name + "@" + p.version : "";
135
+ }
136
+ function onNpm(path) { const k = npmKey(path); return !!(k && NPM_HAS.get(k) && NPM_HAS.get(k).yes); }
137
+ // Ask the registry about each repo's committed version, and for a repo that
138
+ // publishes on merge, its default branch's (unreleased measures from that). A
139
+ // yes is kept; a no is asked again after 5 minutes (it may have just been
140
+ // published). True when it learned of a version on npm.
141
+ async function learnNpm(paths, registry = REGISTRY) {
142
+ let learned = false;
143
+ const keys = new Set();
144
+ for (const path of new Set(paths.filter(Boolean))) {
145
+ keys.add(npmKey(path));
146
+ if (publishesOnMerge(path)) { const base = gitDefaultBranch(path); for (const ref of ["refs/heads/" + base, "refs/remotes/origin/" + base]) keys.add(npmKey(path, ref)); }
147
+ }
148
+ await Promise.all([...keys].map(async (k) => {
149
+ const was = NPM_HAS.get(k); if (!k || (was && (was.yes || Date.now() - was.at < 300000))) return;
150
+ const at = k.lastIndexOf("@"), name = k.slice(0, at), version = k.slice(at + 1);
151
+ try {
152
+ const r = await fetch(`${registry}/${encodeURIComponent(name).replace(/^%40/, "@")}/${encodeURIComponent(version)}`, { signal: AbortSignal.timeout(5000) });
153
+ const yes = r.ok && String((await r.json().catch(() => ({}))).version || "") === version;
154
+ NPM_HAS.set(k, { yes, at: Date.now() }); learned = learned || yes;
155
+ } catch {}
156
+ }));
157
+ return learned;
158
+ }
159
+ // An agent's question that needs a release ("Once 0.41.0 is installed: …") names
160
+ // a version of the repo's own npm package. The Agents tab shows it next to the
161
+ // version installed here and the one on npm, and doesn't offer a "Done" answer
162
+ // until the step is possible: a release that isn't out, or isn't installed yet,
163
+ // can't have been tried. The version asked about is the highest one the question
164
+ // names in the package's major, up to its next minor (not "ydotool 0.1.8").
165
+ function releaseNeeded(text, current) {
166
+ const p = (v) => v.split(".").map(Number), [maj, min] = p(current);
167
+ let best = "";
168
+ for (const [v] of String(text || "").matchAll(/\b\d+\.\d+\.\d+\b/g)) { const [a, b] = p(v); if (a === maj && b <= min + 1 && (!best || semverGt(v, best))) best = v; }
169
+ return best;
170
+ }
171
+ // The version npm -g installed, or for Symbiot this app's own; "" if not installed.
172
+ function installedVersion(name) {
173
+ if (name === "symbiot") return VERSION;
174
+ const root = process.platform === "win32" ? join(process.execPath, "..", "node_modules") : join(process.execPath, "..", "..", "lib", "node_modules");
175
+ try { return String(JSON.parse(readFileSync(join(root, name, "package.json"), "utf8")).version || ""); } catch { return ""; }
176
+ }
177
+ // The newest version on npm ("" if unknown), asked again after 5 minutes.
178
+ const NPM_LATEST = new Map();
179
+ async function npmLatest(name, registry = REGISTRY) {
180
+ if (name === "symbiot" && LATEST_VERSION) return LATEST_VERSION;
181
+ const was = NPM_LATEST.get(name); if (was && Date.now() - was.at < 300000) return was.v;
182
+ let v = was ? was.v : "";
183
+ try {
184
+ const r = await fetch(`${registry}/${encodeURIComponent(name).replace(/^%40/, "@")}`, { headers: { accept: "application/vnd.npm.install-v1+json" }, signal: AbortSignal.timeout(5000) });
185
+ if (r.ok) v = String(((await r.json())["dist-tags"] || {}).latest || "");
186
+ } catch {}
187
+ NPM_LATEST.set(name, { v, at: Date.now() });
188
+ return v;
189
+ }
190
+ // Mark each open question that needs a release with { name, needs, installed,
191
+ // npm, waiting }. waiting: "npm" (not published yet), "install" (published, not
192
+ // installed here) or "" (possible now).
193
+ async function withReleases(agents, registry = REGISTRY) {
194
+ for (const a of agents) {
195
+ const qs = (a.ask && a.ask.questions) || []; if (!qs.length) continue;
196
+ let pkg = {}; try { pkg = JSON.parse(readFileSync(join(a.path, "package.json"), "utf8")) || {}; } catch {}
197
+ if (typeof pkg.name !== "string" || !pkg.name || pkg.private || !/^\d+\.\d+\.\d+/.test(String(pkg.version || ""))) continue;
198
+ const asks = qs.map((q) => [q, releaseNeeded(q.q + " " + q.context, pkg.version)]).filter((x) => x[1]); if (!asks.length) continue;
199
+ const npm = await npmLatest(pkg.name, registry), installed = installedVersion(pkg.name);
200
+ for (const [q, needs] of asks) q.release = { name: pkg.name, needs, installed, npm, waiting: npm && semverGt(needs, npm) ? "npm" : installed && semverGt(needs, installed) ? "install" : "" };
201
+ }
202
+ return agents;
203
+ }
204
+ // Set the version in package.json and in package-lock.json / npm-shrinkwrap.json
205
+ // (top level and its "" package), keeping each file's indent. Gives back a
206
+ // function that restores them, for when the ship fails.
207
+ function setVersion(path, to) {
208
+ const saved = [];
209
+ for (const f of ["package.json", "package-lock.json", "npm-shrinkwrap.json"]) {
210
+ const file = join(path, f); let text = "", j = null;
211
+ try { text = readFileSync(file, "utf8"); j = JSON.parse(text); } catch { continue; }
212
+ if (!j || typeof j !== "object" || (f !== "package.json" && !("version" in j))) continue;
213
+ j.version = to; if (f !== "package.json" && j.packages && j.packages[""]) j.packages[""].version = to;
214
+ writeFileSync(file, JSON.stringify(j, null, (text.match(/^[ \t]+(?=")/m) || [" "])[0]) + (text.endsWith("\n") ? "\n" : ""));
215
+ saved.push([file, text]);
216
+ }
217
+ return () => { for (const [file, text] of saved) try { writeFileSync(file, text); } catch {} };
218
+ }
219
+ // Ship with the version bumped when opts.bump is "patch" or "minor" and the repo
220
+ // is offered one (bumpOffer). The bump is put back if the ship fails.
221
+ function shipWithBump(path, texts, opts) {
222
+ const offer = (opts.bump === "patch" || opts.bump === "minor") && bumpOffer(path);
223
+ if (!offer) return shipChanges(path, texts, opts);
224
+ const to = offer[opts.bump], undo = setVersion(path, to);
225
+ const r = shipChanges(path, texts, { ...opts, bumped: to });
226
+ if (r.error) { undo(); return r; }
227
+ return { ...r, bumped: to };
228
+ }
229
+ // Repos with tasks awaiting review, plus repos Symbiot sent tasks to that have
230
+ // uncommitted changes no ticked task covers (untasked: approve them as-is).
231
+ // running: an agent is still editing there, so its changes may be half done.
232
+ function pendingReview() {
233
+ const t = loadTasks(), by = {}; for (const x of t) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
234
+ const sent = [...new Set(t.filter((x) => x.repo && !x.archived && !by[x.repo]).map((x) => x.repo))];
235
+ const map = Object.keys(by).length || sent.length ? repoPathMap() : {};
236
+ const am = autoMergeRepos();
237
+ const out = Object.keys(by).sort().map((repo) => { const path = map[repo] || ""; return { repo, path, tasks: by[repo], autoMerge: am.includes(repo), running: !!(path && runningHandoff(path)), ...(path ? { ...workingChanges(path), unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) } : { branch: "", files: [], stat: "" }) }; });
238
+ for (const repo of sent.sort()) {
239
+ const path = map[repo]; if (!path || !existsSync(join(path, ".symbiot", "TASKS.md"))) continue;
240
+ const wc = workingChanges(path); if (wc.files.length) out.push({ repo, path, tasks: [], untasked: true, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc, unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) });
241
+ }
242
+ return out;
243
+ }
244
+ // Approving while the agent is still editing would commit its half-done work.
245
+ const stillWorking = (repo, path) => path && runningHandoff(path) ? { error: `The agent is still working in ${repo}. Approve once it finishes.`, running: true } : null;
246
+ const branchSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "") || "tasks";
247
+ // Whether everything committed on this branch is on the default branch already,
248
+ // because an earlier PR from it merged. Squash-merged, main has the same changes
249
+ // under a different history, so a new PR from the branch carries them twice and
250
+ // can't merge (how #80 got stuck behind #79). Seen two ways: the branch's files
251
+ // are main's though main doesn't hold its commits, or GitHub says a PR from it
252
+ // merged and nothing was committed on it since. Gives the ref to start the next
253
+ // branch from, or "".
254
+ function mergedAlready(path, branch, base) {
255
+ const origin = git(path, ["remote", "get-url", "origin"]);
256
+ if (origin.ok) git(path, ["fetch", "-q", "origin", base], 30000); // best effort: main as GitHub has it
257
+ const ref = git(path, ["rev-parse", "-q", "--verify", "refs/remotes/origin/" + base]).ok ? "origin/" + base : base;
258
+ if (!git(path, ["rev-parse", "-q", "--verify", ref]).ok) return "";
259
+ if (!git(path, ["merge-base", "--is-ancestor", "HEAD", ref]).ok && git(path, ["diff", "--quiet", "HEAD", ref, ...NOT_SYMBIOT]).ok) return ref;
260
+ if (!origin.ok || !/github/i.test(origin.out) || !hasCmd("gh")) return "";
261
+ const r = spawnSync("gh", ["pr", "list", "--head", branch, "--state", "merged", "--limit", "1", "--json", "headRefOid"], { cwd: path, encoding: "utf8", timeout: 30000 });
262
+ let head = ""; try { head = String(JSON.parse(r.stdout)[0].headRefOid || ""); } catch {}
263
+ const since = head && git(path, ["rev-list", "--count", head + "..HEAD"]);
264
+ return since && since.ok && since.out === "0" ? ref : "";
265
+ }
266
+ // The commit's (and PR's) subject: what was approved, so `git log --oneline`
267
+ // reads as a history ("Watch GitHub too (+2 more)"), not twenty "symbiot: 3
268
+ // approved tasks". The first task's first line, its markdown dropped, clipped
269
+ // at a word to fit 72 characters with the count of the rest. The body lists
270
+ // them all.
271
+ function commitSubject(texts) {
272
+ if (!texts.length) return "symbiot: changes approved without a task";
273
+ const more = texts.length > 1 ? ` (+${texts.length - 1} more)` : "", max = 72 - more.length;
274
+ const t = String(texts[0]).split("\n").find((l) => l.trim()) || "";
275
+ const s = t.replace(/^\s*(#+|[-*+]|\d+[.)])\s+/, "").replace(/\*\*|__/g, "").replace(/\s+/g, " ").trim() || `${texts.length} approved tasks`;
276
+ return (s.length > max ? s.slice(0, max - 1).replace(/\s+\S*$/, "") + "…" : s) + more;
277
+ }
278
+ // Sync approved work: off the default branch onto symbiot/<task>, commit the
279
+ // working tree (minus .symbiot/), push, and open a PR with gh. On a branch whose
280
+ // earlier PR already merged, it starts a fresh symbiot/ branch from the default
281
+ // one instead (mergedAlready). Each step that can't happen (no remote, push
282
+ // rejected, no gh) stops there and says so — the commit is never lost.
283
+ // opts.push=false stops after the commit, opts.pr=false after the push.
284
+ function shipChanges(path, texts, opts = {}) {
285
+ const ch = workingChanges(path);
286
+ if (!ch.files.length) return { ok: true, nothing: true, note: "No uncommitted changes — approved without a commit." };
287
+ if (!ch.branch || ch.branch === "HEAD") return { error: "Detached HEAD — check out a branch first." };
288
+ const base = gitDefaultBranch(path), day = new Date().toISOString().slice(0, 10); let branch = ch.branch, fresh = "";
289
+ const newBranch = () => {
290
+ const stem = "symbiot/" + branchSlug(!texts.length ? `changes-${day}` : texts.length === 1 ? texts[0] : `${texts.length}-tasks-${day}`);
291
+ let b = stem; for (let i = 2; git(path, ["rev-parse", "--verify", "-q", "refs/heads/" + b]).ok; i++) b = `${stem}-${i}`;
292
+ return b;
293
+ };
294
+ if (branch === base) {
295
+ branch = newBranch();
296
+ const sw = git(path, ["switch", "-c", branch]); if (!sw.ok) return { error: "Could not create branch: " + sw.err };
297
+ } else {
298
+ const from = mergedAlready(path, branch, base);
299
+ if (from) {
300
+ // The changes come along; if main changed a file they touch, git refuses and they stay put.
301
+ const next = newBranch(), sw = git(path, ["switch", "-c", next, "--no-track", from]);
302
+ fresh = sw.ok ? `${branch}'s earlier PR already merged, so this starts a fresh branch from ${from}.`
303
+ : `${branch}'s earlier PR already merged, but these changes couldn't move to a fresh branch from ${from} (${sw.err.split("\n")[0]}), so they're on ${branch}, and its PR may not merge.`;
304
+ if (sw.ok) branch = next;
305
+ }
306
+ }
307
+ // Stage everything, then drop .symbiot. A `. :(exclude).symbiot` pathspec
308
+ // warns+exits-1 once .symbiot is gitignored ("paths are ignored, use -f"),
309
+ // which falsely aborted the ship. `add -A` skips gitignored paths silently;
310
+ // the reset also covers repos where .symbiot isn't ignored.
311
+ const add = git(path, ["add", "-A"]); if (!add.ok) return { error: "git add failed: " + add.err, branch };
312
+ git(path, ["reset", "-q", "--", ".symbiot"]); // never ship Symbiot's own scratch
313
+ const subject = commitSubject(texts);
314
+ const body = (texts.length ? texts.map((x) => "- " + x).join("\n") : "No ticked task covers these changes.") + (opts.bumped ? `\n\nBumps the version to ${opts.bumped}. ` + (publishesOnMerge(path) ? `It publishes to npm when this merges.` : `After this merges, tag v${opts.bumped} on ${base} to release it.`) : "");
315
+ const cm = git(path, ["commit", "-m", subject, "-m", body]); if (!cm.ok) return { error: "Commit failed: " + (cm.err || cm.out), branch };
316
+ const out = { ok: true, branch, base, commit: git(path, ["rev-parse", "--short", "HEAD"]).out, subject, ...(fresh ? { note: fresh } : {}) };
317
+ const noted = (note) => ({ ...out, note: (fresh ? fresh + " " : "") + note });
318
+ if (opts.push === false) return out;
319
+ if (!git(path, ["remote", "get-url", "origin"]).ok) return noted("No origin remote — committed locally.");
320
+ const ps = git(path, ["push", "-u", "origin", branch], 120000);
321
+ if (!ps.ok) return noted("Committed, but the push failed: " + (ps.err.split("\n").filter(Boolean).pop() || "unknown error"));
322
+ out.pushed = true;
323
+ if (opts.pr === false) return out;
324
+ if (!hasCmd("gh")) return noted("Pushed. Install the GitHub CLI (gh) to open the PR automatically.");
325
+ const gh = (args) => spawnSync("gh", args, { cwd: path, encoding: "utf8", timeout: 60000 });
326
+ const pr = gh(["pr", "create", "--head", branch, "--base", base, "--title", subject, "--body", body + "\n\nApproved in Symbiot."]);
327
+ const url = (String(pr.stdout || "").match(/https?:\/\/\S+/) || [])[0] || String(gh(["pr", "view", branch, "--json", "url", "-q", ".url"]).stdout || "").trim();
328
+ if (!url) return noted("Pushed, but gh couldn't open the PR: " + String(pr.stderr || "").trim().split("\n").pop());
329
+ out.pr = url;
330
+ // Opt-in auto-merge: queue GitHub's native auto-merge, which lands the PR only
331
+ // once its required checks (CI) pass — never immediately on its own if the repo
332
+ // has branch protection. Needs 'Allow auto-merge' on the repo; if off, we say so.
333
+ if (opts.autoMerge) {
334
+ const am = gh(["pr", "merge", branch, "--auto", "--squash", "--delete-branch"]);
335
+ if (am.status === 0) out.autoMerge = "queued";
336
+ else { out.autoMerge = "unavailable"; out.autoMergeErr = String(am.stderr || "").trim().split("\n").filter(Boolean).pop() || "gh pr merge failed"; }
337
+ }
338
+ return out;
339
+ }
340
+ // Per-repo opt-in to auto-merge Approve PRs once CI passes (default off).
341
+ function autoMergeRepos() { const a = loadConfig().autoMerge; return Array.isArray(a) ? a : []; }
342
+ function setAutoMerge(repo, on) { const cfg = loadConfig(); let a = (Array.isArray(cfg.autoMerge) ? cfg.autoMerge : []).filter((x) => x !== repo); if (on && repo) a.push(repo); if (a.length) cfg.autoMerge = a; else delete cfg.autoMerge; saveConfig(cfg); return { ok: true, repos: cfg.autoMerge || [] }; }
343
+ function approveRepo(repo, opts = {}) {
344
+ const t = loadTasks(); const items = t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
345
+ if (!items.length) return { error: "Nothing awaiting review for " + (repo || "(no repo)") + "." };
346
+ const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + repo };
347
+ const busy = stillWorking(repo, path); if (busy) return busy;
348
+ const r = shipWithBump(path, items.map((x) => x.text), { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
349
+ if (r.error) return r;
350
+ const now = Date.now();
351
+ for (const x of items) { x.review = false; x.done = true; x.archived = true; x.archivedAt = now; x.approvedAt = now; for (const k of ["branch", "commit", "pr"]) if (r[k]) x[k] = r[k]; }
352
+ saveTasks(t);
353
+ return { ...r, approved: items.length };
354
+ }
355
+ // "Approve changes without a task": ship the uncommitted changes even though no
356
+ // ticked task is behind them (a fix the agent made but didn't tick). If tasks are
357
+ // awaiting review in the repo, this is just Approve.
358
+ function approveChanges(repo, opts = {}) {
359
+ if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return approveRepo(repo, opts);
360
+ const path = repoPathMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
361
+ const busy = stillWorking(repo, path); if (busy) return busy;
362
+ if (!workingChanges(path).files.length) return { error: "No uncommitted changes in " + repo + "." };
363
+ const r = shipWithBump(path, [], { ...opts, autoMerge: opts.autoMerge !== undefined ? opts.autoMerge : autoMergeRepos().includes(repo) });
364
+ return r.error ? r : { ...r, approved: 0 };
365
+ }
366
+ // Not right: reopen it and untick it in TASKS.md so the agent picks it up again.
367
+ function sendBack(id) {
368
+ const t = loadTasks(); const it = t.find((x) => x.id === id); if (!it) return { error: "not found" };
369
+ it.review = false; delete it.reviewAt; saveTasks(t);
370
+ const path = it.repo && repoPathMap()[it.repo];
371
+ if (path) { const f = join(path, ".symbiot", "TASKS.md"); try { const want = it.text.trim().toLowerCase(); writeFileSync(f, readFileSync(f, "utf8").split("\n").map((l) => /^\s*-\s*\[x\]/i.test(l) && l.replace(/^\s*-\s*\[x\]\s*/i, "").trim().toLowerCase() === want ? l.replace(/\[x\]/i, "[ ]") : l).join("\n")); } catch {} }
372
+ return it;
373
+ }
374
+ // Deterministic task classification (no model) so tasks batch by kind instead
375
+ // of arriving as a flat, mixed pile. Order matters (most specific first).
376
+ const TASK_ORDER = ["Fixes", "Tests & CI", "Security", "Performance", "Refactor", "UI/UX", "Docs", "Features & other"];
377
+ function taskType(text) {
378
+ const s = String(text || "").toLowerCase();
379
+ if (/\b(test|tests|spec|coverage|smoke|fixture|ci\b)/.test(s)) return "Tests & CI";
380
+ if (/\b(secur|auth|token|secret|vulnerab|permission|sanitiz|escap|injection)/.test(s)) return "Security";
381
+ if (/\b(readme|docs?|document|changelog|comment|guide)/.test(s)) return "Docs";
382
+ if (/\b(perf|performance|slow|optimi|latency|throughput|speed up|memory leak)/.test(s)) return "Performance";
383
+ if (/\b(refactor|simplify|split|extract|clean ?up|consolidat|dedupe|modular|rename)/.test(s)) return "Refactor";
384
+ if (/\b(bug|fix|broken|error|crash|regress|hang|race|leak)/.test(s)) return "Fixes";
385
+ if (/\b(ui|ux|design|layout|style|button|screen|view\b|page\b|responsive)/.test(s)) return "UI/UX";
386
+ return "Features & other";
387
+ }
388
+ // Push tasks into the repos as an agent-readable brief (.symbiot/TASKS.md):
389
+ // a checklist plus the deterministic context an agent needs to get oriented.
390
+ function buildTasksMd(name, ctx, list) {
391
+ const L = [];
392
+ L.push(`# Symbiot tasks — ${name}`);
393
+ L.push(`_generated ${new Date().toISOString().slice(0, 10)}${ctx.branch ? ` · branch ${ctx.branch}` : ""} · by symbiot ${VERSION}_`, "");
394
+ L.push("## Context for an AI agent", `Tasks for the \`${name}\` repo. Get oriented from the signals below before actioning them.`);
395
+ if (ctx.stack) L.push(`- **Stack:** ${ctx.stack}`);
396
+ if (ctx.commits && ctx.commits.length) { L.push("- **Recent commits:**"); for (const c of ctx.commits) L.push(` - ${c}`); }
397
+ if (ctx.open && ctx.open.length) { L.push("- **Open markers (TODO/FIXME + uncommitted):**"); for (const o of ctx.open) L.push(` - ${o}`); }
398
+ if (ctx.drift && ctx.drift.length) { L.push("- **Current drift / risk:**"); for (const d of ctx.drift) L.push(` - ${d}`); }
399
+ L.push("", "## Tasks");
400
+ const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
401
+ const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
402
+ for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
403
+ L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "");
404
+ L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Agents tab, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
405
+ `- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then an optional line of context, then 2–4 options as \`- \` bullets (put the one you recommend first).`,
406
+ "- Start every option with who acts, because the user reads it as something they'd be agreeing to do: `👤 You:` when they have to do something (a setting, a click, a command), `🤖 Agent:` when picking it is enough and the next run does the work. If it takes both, put their step first: `👤 You: allow npm install in Settings. 🤖 Agent: the next run adds ESLint`.",
407
+ "- If a step needs a release that isn't out yet, name its version in the question (\"Once 0.41.0 is installed: …\"). Symbiot shows it next to the installed and npm versions, and holds back an answer that starts \"Done\" until that release is out and installed.",
408
+ "- Ideas, options or follow-ups outside these tasks go under `## Suggestions` as `- ` bullets — the user can add them to their tasks in one click.",
409
+ "- An idea for a different project than this one (Symbiot itself, say, the app that sent you this brief) starts with that project's folder name, `- [repo: symbiot] …`, so it goes to that project's tasks instead of this one's.",
410
+ "- Carry on with everything that doesn't depend on an answer, and don't tick an item that does. Ask there rather than doing anything destructive.", "");
411
+ L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Tick each item as you finish it and leave changes uncommitted for review. Confirm with me before anything destructive."');
412
+ return L.join("\n") + "\n";
413
+ }
414
+ function pushTasks(filter) {
415
+ let tasks = loadTasks().filter((t) => !t.done && !t.archived && !t.review);
416
+ if (filter && filter.type) tasks = tasks.filter((t) => taskType(t.text) === filter.type);
417
+ if (filter && filter.repo) tasks = tasks.filter((t) => t.repo === filter.repo);
418
+ if (!tasks.length) return { empty: true, written: [], unresolved: [] };
419
+ const byName = repoPathMap(); // from the already-scanned map when there is one
420
+ const groups = {}; for (const t of tasks) { const k = t.repo || ""; (groups[k] = groups[k] || []).push(t); }
421
+ const written = [], unresolved = [];
422
+ for (const name of Object.keys(groups)) {
423
+ const list = groups[name], path = name && byName[name];
424
+ if (!path) { unresolved.push({ name: name || "(no repo)", count: list.length }); continue; }
425
+ try {
426
+ // Cheap, per-repo signals only — no full drift scan (that can be very slow
427
+ // on big repos and would block the request).
428
+ const st = repoState(path);
429
+ const commits = sh(`git -C ${JSON.stringify(path)} log --format='%s' -10 2>/dev/null`).split("\n").filter(Boolean);
430
+ const open = openWork([{ path, name }]).slice(0, 12);
431
+ const det = detectRepo({ path, name, recency: 0 });
432
+ const stack = [...det.langs.slice(0, 4), ...det.tools].join(", ");
433
+ const risk = [];
434
+ if (st.stale) risk.push("stale checkout — working tree is an old snapshot, not new work");
435
+ if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
436
+ if (st.dirty && !st.stale) risk.push(`${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)`);
437
+ // held: an agent is still running there, so it lands when that one exits
438
+ const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack }, list));
439
+ written.push({ name, file: join(path, ".symbiot", "TASKS.md"), path, count: list.length, held });
440
+ } catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
441
+ }
442
+ return { empty: false, written, unresolved, handoff: handoffCmd() };
443
+ }
444
+
445
+ export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, shipWithBump, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
package/ui.mjs CHANGED
@@ -112,6 +112,8 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
112
112
  .aq .qc{color:var(--faint);font-size:12.5px;margin-top:3px;line-height:1.5}
113
113
  .aq label.opt{display:flex;gap:8px;align-items:flex-start;margin:6px 0 0;font-size:13px;color:var(--text);cursor:pointer}
114
114
  .aq .opt input{width:auto;margin-top:3px;flex:none}
115
+ .who{display:inline-block;font-size:11px;font-weight:700;border-radius:999px;padding:0 7px;margin-right:2px;white-space:nowrap}
116
+ .who.you{color:var(--amber);border:1px solid var(--amber)}.who.agent{color:var(--green);border:1px solid var(--green)}
115
117
  .aq .qrel{font-size:12px;margin-top:4px;color:var(--green)}.aq .qrel.wait{color:var(--amber)}
116
118
  .aq label.opt.off{color:var(--faint);cursor:default}.aq .opt.off i{font-size:12px}
117
119
  .aq .qother{margin-top:7px;padding:7px 10px;font-size:13px}
@@ -268,6 +270,10 @@ label.check input{width:auto}
268
270
  <div class="note muted" id="desktopnote"></div>
269
271
  </div>
270
272
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
273
+ <label>Watch on your phone <span class="muted">(experimental)</span></label>
274
+ <div id="phonelink"></div>
275
+ </div>
276
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
271
277
  <label>Local models <span class="muted">(experimental)</span></label>
272
278
  <button class="ghost" id="recbtn">Recommend models for my machine</button>
273
279
  <button class="ghost" id="setuplocal" style="margin-left:8px">Set up a free local model</button>
@@ -317,6 +323,9 @@ api('/api/connect',cfg).then(function(r){$('saveMsg').textContent=r.message||(r.
317
323
  $('quit').addEventListener('click',function(){api('/api/quit');document.body.innerHTML='<div style=\\'padding:40px;color:#7E9690;font-family:sans-serif\\'>Symbiot stopped. You can close this window.</div>';});
318
324
  var GRAPH=null,sel=null,view={k:1,x:0,y:0},GW=960,GH=620;
319
325
  function esc(s){return String(s).replace(/[&<>]/g,function(ch){return ch==='&'?'&amp;':ch==='<'?'&lt;':'&gt;';});}
326
+ // An agent's option, with its who-acts markers as badges: 👤 You (you do this
327
+ // step) and 🤖 Agent (picking it is enough; the next run does it). Escaped first.
328
+ function whoHtml(o){return esc(o).replace(/👤[ ]*(You:|You(?=[ ]))?/g,"<span class='who you'>&#128100; You</span> ").replace(/🤖[ ]*(Agent:|Agent(?=[ ]))?/g,"<span class='who agent'>&#129302; Agent</span> ");}
320
329
  function layout(nodes,edges){var idx={};nodes.forEach(function(n){n.x=GW/2+(Math.random()-0.5)*GW*0.8;n.y=GH/2+(Math.random()-0.5)*GH*0.8;n.vx=0;n.vy=0;idx[n.id]=n;});
321
330
  for(var it=0;it<340;it++){for(var i=0;i<nodes.length;i++)for(var j=i+1;j<nodes.length;j++){var a=nodes[i],b=nodes[j];var dx=a.x-b.x,dy=a.y-b.y;var d2=dx*dx+dy*dy+0.01;var d=Math.sqrt(d2);var f=4600/d2;a.vx+=f*dx/d;a.vy+=f*dy/d;b.vx-=f*dx/d;b.vy-=f*dy/d;}
322
331
  edges.forEach(function(e){var a=idx[e.source],b=idx[e.target];if(!a||!b)return;var dx=b.x-a.x,dy=b.y-a.y;var d=Math.sqrt(dx*dx+dy*dy)+0.01;var f=(d-115)*0.03;a.vx+=f*dx/d;a.vy+=f*dy/d;b.vx-=f*dx/d;b.vy-=f*dy/d;});
@@ -485,10 +494,10 @@ function askHtml(a){var k=a.ask;if(!k)return '';var qs=k.questions||[],ss=k.sugg
485
494
  var h="<div class='aq' data-id='"+esc(a.id)+"'>";
486
495
  if(qs.length){h+="<h4>&#10067; "+qs.length+" question"+(qs.length>1?"s":"")+" for you</h4>";
487
496
  qs.forEach(function(q,i){var rl=q.release;h+="<div class='q' data-i='"+i+"'><div class='qt'>"+esc(q.q)+"</div>"+(q.context?"<div class='qc'>"+esc(q.context)+"</div>":"")+(rl?relHtml(rl):"");
488
- (q.options||[]).forEach(function(o,j){var off=rl&&rl.waiting&&DONEOPT.test(o);h+="<label class='opt"+(off?" off":"")+"'><input type='radio' name='q_"+esc(a.id)+"_"+i+"' value='"+j+"'"+(off?" disabled":"")+"><span>"+esc(o)+(off?" <i>(once "+esc(rl.name+" "+rl.needs)+" is installed)</i>":"")+"</span></label>";});
497
+ (q.options||[]).forEach(function(o,j){var off=rl&&rl.waiting&&DONEOPT.test(o);h+="<label class='opt"+(off?" off":"")+"'><input type='radio' name='q_"+esc(a.id)+"_"+i+"' value='"+j+"'"+(off?" disabled":"")+"><span>"+whoHtml(o)+(off?" <i>(once "+esc(rl.name+" "+rl.needs)+" is installed)</i>":"")+"</span></label>";});
489
498
  h+="<input class='qother' placeholder='"+((q.options&&q.options.length)?"or answer in your own words":"your answer")+"'></div>";});
490
499
  h+="<div class='row'><button class='act qsend' title='save the answers and hand the repo back to your agent'>Send answers &amp; continue</button><button class='ghost qsave' title='save the answers for the next run'>Save only</button></div>";}
491
- if(ss.length){h+="<h4>&#128161; Ideas from the agent</h4>";ss.forEach(function(s,i){h+="<div class='idea'><span style='flex:1'>"+esc(s.text)+"</span>"+(s.added?"<span class='tag'>in Tasks</span>":"<button class='ghost qidea' data-i='"+i+"' style='padding:3px 9px;font-size:12px'>+ task</button>")+"</div>";});}
500
+ if(ss.length){h+="<h4>&#128161; Ideas from the agent</h4>";ss.forEach(function(s,i){h+="<div class='idea'><span style='flex:1'>"+esc(s.text)+(s.other?" <span class='tag' title='this idea is for another project, so + task adds it to that one'>for "+esc(s.repo)+"</span>":"")+"</span>"+(s.added?"<span class='tag'>in Tasks</span>":"<button class='ghost qidea' data-i='"+i+"' style='padding:3px 9px;font-size:12px'>+ task</button>")+"</div>";});}
492
501
  return h+"</div>";}
493
502
  function qKeyOf(qe){var box=qe.closest('.aq');var a=box&&agentById(box.getAttribute('data-id'));var q=a&&a.ask.questions[+qe.getAttribute('data-i')];return q?a.path+'|'+q.q:'';}
494
503
  function saveDrafts(el){el.querySelectorAll('.aq .q').forEach(function(qe){var k=qKeyOf(qe);if(!k)return;var pick=qe.querySelector('input[type=radio]:checked');QDRAFT[k]={o:pick?pick.value:'',t:qe.querySelector('.qother').value};});}
@@ -505,7 +514,7 @@ msg.innerHTML="<div class='note ok'>&#10003; Saved "+r.saved+" answer"+(r.saved>
505
514
  var s1=box.querySelector('.qsend'),s2=box.querySelector('.qsave');
506
515
  if(s1)s1.addEventListener('click',function(){send(true);});if(s2)s2.addEventListener('click',function(){send(false);});
507
516
  box.querySelectorAll('.qidea').forEach(function(btn){btn.addEventListener('click',function(){var s=a.ask.suggestions[+btn.getAttribute('data-i')];if(!s)return;btn.disabled=true;
508
- api('/api/tasks/add',{text:s.text,repo:a.name}).then(function(){s.added=true;btn.outerHTML="<span class='tag'>in Tasks</span>";});});});});}
517
+ api('/api/tasks/add',{text:s.text,repo:s.repo||a.name}).then(function(){s.added=true;btn.outerHTML="<span class='tag'>in Tasks</span>";});});});});}
509
518
  function answering(){var f=document.activeElement;return !!(f&&f.closest&&f.closest('.aq'));}
510
519
  function loadAgents(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
511
520
  if(!list||!list.length){AGENTLIST=[];el.innerHTML="<div class='muted' style='margin-top:12px'>No agents yet. In <b>Tasks</b>, tick ideas and hit <b>Send to repos</b> (with an agent command set in Settings) &mdash; you'll watch it work here.</div>";stopAgentsPoll();return;}
@@ -588,6 +597,30 @@ function phoneTermux(){var A=window.SymbiotAndroid,t={};if(A&&A.termux){try{t=JS
588
597
  function loadPhone(){var t=phoneTermux();if(!t.installed&&!t.on)return;$('phonebox').classList.remove('hidden');
589
598
  $('phonenote').innerHTML=t.on?"This window shows the Symbiot running in Termux, so it sees the projects in Termux's home folder and runs your agents there. The app's own Symbiot sees only shared storage.":"Termux keeps its home folder (<code>~</code>) private, so the app's own Symbiot can't see the projects there. Symbiot running in Termux can, and this app can show it. <b>Open Termux</b> copies the command that starts it (it installs Node and Symbiot there first if they're missing). Paste it in Termux, and Termux opens it here.";
590
599
  var b=$('phonebtn');b.textContent=t.on?"Use the app's own Symbiot":"Open Termux";b.onclick=function(){if(t.on)SymbiotAndroid.builtIn();else SymbiotAndroid.openTermux();};}
600
+ // Watch on your phone (phone.mjs): the computer lets a paired phone ask what
601
+ // Watch found; the phone pairs once with its address and code, then asks every 2 minutes.
602
+ function loadPhoneLink(msg){api('/api/phone').then(function(d){renderPhoneLink(d,msg);});}
603
+ function renderPhoneLink(d,msg){if(!d||!d.role)return;var box=$('phonelink'),h='';
604
+ if(d.role==='phone'){
605
+ if(d.paired){h+="<div class='task'><span class='t'>Paired with <b>"+esc(d.name)+"</b> <span class='muted' style='font-size:12px'>"+esc(d.url)+(d.last?" &middot; asked "+agoTxt(d.last):"")+"</span>"+(d.error?"<br><span class='err' style='font-size:12px'>"+esc(d.error)+"</span>":"")+"</span><button class='ghost' id='pcheck' title='ask your computer now'>Check now</button><button class='rm' id='pforget' title='stop getting its notifications'>&times;</button></div>";
606
+ h+="<div class='note muted'>While Symbiot runs on this phone, it asks your computer every 2 minutes and shows what Watch found there as a notification, with its brief if you switched that on there.</div>";}
607
+ else h+="<div class='row'><input id='paddr' placeholder='your computer&rsquo;s address, e.g. 192.168.8.50:7392' style='flex:1'><input id='pcode' placeholder='code' inputmode='numeric' style='flex:0 0 90px'><button class='act' id='ppair'>Pair</button></div><div class='note muted'>Get what Watch finds on your computer (new mail, review requests, failed CI runs) as notifications here. On your computer, in Symbiot&rsquo;s Settings, tick <b>Watch on your phone</b>: it shows its address and a 6-digit code. Type both here. The phone has to be on the same Wi-Fi.</div>";
608
+ if(!d.notify)h+="<div class='note err'>Notifications from Termux need the Termux:API app: install it, then run <b>pkg install termux-api</b> in Termux.</div>";}
609
+ else{h+="<label class='check'><input type='checkbox' id='plinkon'"+(d.on?" checked":"")+"> Let the Symbiot app on my phone get Watch&rsquo;s notifications, over this network</label>";
610
+ if(d.on&&d.error)h+="<div class='note err'>"+esc(d.error)+"</div>";
611
+ else if(d.on&&d.listening){var ad=(d.addresses||[]).map(function(a){return a+":"+d.port;});
612
+ h+=d.code?"<div class='note ok'>On your phone, open Symbiot &rarr; Settings &rarr; <b>Watch on your phone</b>, and type the address <b>"+esc(ad[0]||"(this computer&rsquo;s address)")+"</b>"+(ad.length>1?" <span class='muted'>(or "+esc(ad.slice(1).join(", "))+")</span>":"")+" and the code <b style='letter-spacing:2px'>"+esc(d.code)+"</b>. <span class='muted'>The code works for 10 minutes.</span></div>"
613
+ :"<div class='row' style='margin-top:4px'><button class='ghost' id='pnewcode'>Pair a phone</button></div>";
614
+ h+=(d.phones||[]).map(function(p){return "<div class='task' data-id='"+esc(p.id)+"'><span class='t'>"+esc(p.name)+" <span class='muted' style='font-size:12px'>paired "+agoTxt(p.added)+(p.seen?" &middot; asked "+agoTxt(p.seen):"")+"</span></span><button class='rm punpair' title='unpair it: it gets nothing more'>&times;</button></div>";}).join('');}
615
+ h+="<div class='note muted'>"+(d.on?"Symbiot listens on port "+d.port+" of your network for this alone: the pairing code, then what&rsquo;s new for a phone that paired. Nothing can be changed from there, and the rest of Symbiot stays on this computer. What&rsquo;s new (your mail&rsquo;s senders and subjects) crosses your Wi-Fi unencrypted, so use it on a network you trust. Pairing fails? A firewall here may need to allow port "+d.port+".":"New mail, review requests and failed CI runs that Watch finds here, as notifications on your phone, through the Symbiot app there. It asks this computer every 2 minutes while both are running, on the same Wi-Fi.")+"</div>";}
616
+ if(msg)h+="<div class='note "+(msg.ok?"ok":"err")+"'>"+esc(msg.text)+"</div>";
617
+ box.innerHTML=h;
618
+ var on=$('plinkon');if(on)on.addEventListener('change',function(){on.disabled=true;api('/api/phone/link',{on:on.checked}).then(function(x){renderPhoneLink(x);});});
619
+ var nc=$('pnewcode');if(nc)nc.addEventListener('click',function(){api('/api/phone/code',{}).then(function(x){renderPhoneLink(x);});});
620
+ box.querySelectorAll('.punpair').forEach(function(b){b.addEventListener('click',function(){if(typeof confirm==='function'&&!confirm('Unpair this phone? It gets no more notifications from here.'))return;api('/api/phone/unpair',{id:b.closest('.task').getAttribute('data-id')}).then(function(x){renderPhoneLink(x);});});});
621
+ var pp=$('ppair');if(pp)pp.addEventListener('click',function(){var a=$('paddr').value,cd=$('pcode').value;pp.disabled=true;pp.textContent='Pairing...';api('/api/phone/pair',{address:a,code:cd}).then(function(x){renderPhoneLink(x,x&&x.error?{text:x.error}:{ok:true,text:'Paired. What Watch finds on '+x.name+' shows up here as a notification.'});if(x&&x.error&&$('paddr')){$('paddr').value=a;$('pcode').value=cd;}});});
622
+ var pc=$('pcheck');if(pc)pc.addEventListener('click',function(){pc.disabled=true;api('/api/phone/check',{}).then(function(x){renderPhoneLink(x,x&&!x.error?{ok:true,text:x.shown?'Showed '+x.shown+' notification(s).':'Asked: nothing new.'}:null);});});
623
+ var pf=$('pforget');if(pf)pf.addEventListener('click',function(){if(typeof confirm==='function'&&!confirm('Forget your computer? Its notifications stop.'))return;api('/api/phone/forget',{}).then(function(x){renderPhoneLink(x);});});}
591
624
  // Trusted sites: where Screens' Press and Type don't ask first (headless.mjs).
592
625
  function loadTrusted(){api('/api/screens/trusted').then(function(d){var box=document.getElementById('trustedsites');var sites=(d&&d.sites)||[];
593
626
  box.innerHTML=sites.length?sites.map(function(h){return "<div class='task' data-h='"+esc(h)+"'><span class='t' style='font-family:ui-monospace,monospace;font-size:12px'>"+esc(h)+"</span><button class='rm rmtrusted' title='stop trusting it: press and type ask first again'>&times;</button></div>";}).join(""):"<div class='muted' style='font-size:12px'>None yet: Press and Type ask first on every site.</div>";
@@ -707,12 +740,21 @@ if(w){if(typeof confirm==='function'&&!confirm('Stop watching '+w.name+'? What i
707
740
  b.disabled=true;api('/api/watch/add',{screen:s.id,every:15}).then(function(x){b.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
708
741
  $('screenmsg').innerHTML="<div class='note ok'>&#10003; Watching "+esc(x.name)+". Symbiot reads it every "+x.every+" minutes while it runs (first within a minute, to learn what's there) and notifies you of anything new. It's listed under Watching, just below.</div>";
709
742
  api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();renderScreen();});});}
710
- function renderWatch(){var box=$('watchbox'),ws=WATCH.watches||[],ns=WATCH.news||[];if(!ws.length){box.innerHTML='';return;}
711
- var h="<div class='tgroup'>Watching <span class='tcount'>read again while Symbiot runs &middot; only reads, never presses</span></div>";
712
- h+=ws.map(function(w,i){return "<div class='task' data-i='"+i+"'><span class='t'><b>"+esc(w.name)+"</b> <span class='muted' style='font-size:12px'>"+(w.checked?"read "+agoTxt(w.checked):w.last?"tried "+agoTxt(w.last):"first read within a minute")+"</span>"+(w.error?"<br><span class='err' style='font-size:12px'>"+esc(w.error)+"</span>":"")+"</span><select class='wevery' title='how often to read it' style='flex:0 0 auto;width:auto'>"+(WATCH.every||[]).map(function(m){return "<option value='"+m+"'"+(m===w.every?" selected":"")+">every "+m+" min</option>";}).join('')+"</select><button class='ghost wcheck' title='read it now'>Check now</button><button class='rm wrm' title='stop watching it'>&times;</button></div>";}).join('');
743
+ function renderWatch(){var box=$('watchbox'),ws=WATCH.watches||[],ns=WATCH.news||[],bs=WATCH.briefs||[],h='';
744
+ var gh=ws.some(function(w){return String(w.url).indexOf('://github.com/notifications')>=0;});
745
+ if(ws.length){h+="<div class='tgroup'>Watching <span class='tcount'>read again while Symbiot runs &middot; only reads, never presses</span></div>";
746
+ h+=ws.map(function(w,i){return "<div class='task' data-i='"+i+"'><span class='t'><b>"+esc(w.name)+"</b> <span class='muted' style='font-size:12px'>"+(w.checked?"read "+agoTxt(w.checked):w.last?"tried "+agoTxt(w.last):"first read within a minute")+(w.via==='gh'?" &middot; through gh":"")+"</span>"+(w.error?"<br><span class='err' style='font-size:12px'>"+esc(w.error)+"</span>":"")+"</span><select class='wevery' title='how often to read it' style='flex:0 0 auto;width:auto'>"+(WATCH.every||[]).map(function(m){return "<option value='"+m+"'"+(m===w.every?" selected":"")+">every "+m+" min</option>";}).join('')+"</select><button class='ghost wcheck' title='read it now'>Check now</button><button class='rm wrm' title='stop watching it'>&times;</button></div>";}).join('');}
747
+ // GitHub's notifications need no map: gh reads them (watch.mjs readGitHub)
748
+ if(!gh)h+="<div class='row' style='margin-top:8px'><span class='muted' style='font-size:12px;flex:1'>Watch GitHub too: new review requests, failed CI runs and mentions reach you the same way. With the GitHub CLI signed in (<b>gh auth login</b>), Symbiot reads them through it; without, it reads github.com/notifications in its browser (click Sign in above with github.com first).</span><button class='ghost' id='wgithub'>Watch GitHub</button></div>";
749
+ if(ws.length){h+="<label class='check' style='margin-top:8px'><input type='checkbox' id='wbrief'"+(WATCH.brief?" checked":"")+"> <span>Brief me: the AI connected in Settings says what needs me and what can wait, here and in the notification. <span class='muted'>It's sent what's new: for mail, the sender, subject and preview. With a local Ollama model nothing leaves this computer.</span></span></label>";
750
+ var b0=bs[0]&&ns.some(function(n){return n.ts===bs[0].ts&&n.watch===bs[0].watch;})?bs[0]:null;
751
+ if(b0)h+="<div class='note' style='white-space:pre-line;margin-top:6px'><b>Brief</b> <span class='muted' style='font-size:12px'>"+esc(b0.name)+" &middot; "+b0.count+" new &middot; "+agoTxt(b0.ts)+"</span><br>"+esc(b0.text)+"</div>";
713
752
  h+=ns.length?"<div class='row' style='margin-top:8px'><span class='fl' style='margin-left:0;flex:1'>New &middot; "+ns.length+"</span><button class='ghost' id='wclear' title='clear this list (Symbiot still remembers what it has seen)'>Clear</button></div>"+ns.slice(0,20).map(function(n,i){return "<div class='task' data-i='"+i+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:12px'>"+esc(n.name)+" &middot; "+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost wopen' title='open it in your browser'>Open</button>":"")+"</div>";}).join('')
714
- :"<div class='muted' style='font-size:12px;margin-top:6px'>Nothing new yet. New rows show up here, and as a notification. Your agent reads them with <b>symbiot watch new</b>.</div>";
753
+ :"<div class='muted' style='font-size:12px;margin-top:6px'>Nothing new yet. New rows show up here, and as a notification. Standup counts what's waiting on you, and your agent reads them with <b>symbiot watch new</b>.</div>";}
715
754
  box.innerHTML=h;
755
+ var wg=$('wgithub');if(wg)wg.addEventListener('click',function(){wg.disabled=true;api('/api/watch/add',{site:'github',every:5}).then(function(x){wg.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
756
+ $('screenmsg').innerHTML="<div class='note ok'>&#10003; Watching "+esc(x.name)+" every "+x.every+" minutes while Symbiot runs. The first read, within a minute, learns what's unread there now; after that, a new review request or failed CI run notifies you.</div>";loadWatchUI();});});
757
+ var wbr=$('wbrief');if(wbr)wbr.addEventListener('change',function(){api('/api/watch/brief',{on:wbr.checked}).then(loadWatchUI);});
716
758
  box.querySelectorAll('.wevery').forEach(function(sel){sel.addEventListener('change',function(){var w=ws[+sel.closest('.task').getAttribute('data-i')];api('/api/watch/every',{id:w.id,every:+sel.value}).then(loadWatchUI);});});
717
759
  box.querySelectorAll('.wrm').forEach(function(b){b.addEventListener('click',function(){var w=ws[+b.closest('.task').getAttribute('data-i')];if(typeof confirm==='function'&&!confirm('Stop watching '+w.name+'? What it found goes too.'))return;api('/api/watch/remove',{id:w.id}).then(function(){api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();renderScreen();});});});});
718
760
  box.querySelectorAll('.wcheck').forEach(function(b){b.addEventListener('click',function(){var w=ws[+b.closest('.task').getAttribute('data-i')];b.disabled=true;b.textContent='Reading…';
@@ -861,5 +903,5 @@ function appBar(p){var b=$('appbar');if(!b||!p.termux||window.SymbiotAndroid||ty
861
903
  function doUpdate(){updBusy=true;try{localStorage.setItem('symbiot_update_tried',document.getElementById('ver').textContent.replace(/^v/,''));}catch(e){}var b=ubar();b.className='updatebar show';b.textContent='Updating & restarting… this page will reload itself when it is back.';api('/api/update',{});}
862
904
  setInterval(heartbeat,4000);heartbeat(true);
863
905
  window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
864
- initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadPhone();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);
906
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadPhone();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);loadPhoneLink();
865
907
  </script></body></html>`;