symbiot 0.63.3 → 0.63.5

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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
4
4
 
5
+ ## 0.63.5
6
+
7
+ - **Home stops stalling on git.** Every few seconds Home asked git, one project after another, what each of your last 60 agent runs changed, and what's waiting for review. The app couldn't do anything else while it waited: 20 seconds in every 45 on a busy day. It now reuses each answer and refreshes it in the background, so Home answers straight away. Approve still checks git fresh, and Home forgets what it kept as soon as you approve.
8
+
9
+ ## 0.63.4
10
+
11
+ - **Symbiot no longer freezes as your task list grows.** Every 20 seconds it checks the ideas your agents left against all your tasks, and with a few hundred tasks that took up to 10 seconds a time, until the app stopped answering altogether. The update to 0.63.3 then couldn't restart it. The same check now takes a fraction of a second.
12
+ - **An update keeps trying for 10 minutes.** npm can list a new version a few minutes before its download is ready. Symbiot gave up after 2.5 minutes; now it keeps trying every 30 seconds for 10.
13
+
5
14
  ## 0.63.3
6
15
 
7
16
  - **Allow works on a request that takes access away.** When an agent asked to lock itself out of something (a deny rule, with nothing new allowed), Symbiot dropped the request, so clicking Allow did nothing and the agent waited for good. Now it's turned on, without asking you, and the agent carries on.
package/agents.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // tab, and the questions an unattended agent leaves for the user.
4
4
  import { MARKETING_DIR } from "./marketing.mjs";
5
5
  import { licenceState, isPro, can, canWorkIn } from "./licence.mjs";
6
- import { spawn, spawnSync } from "node:child_process";
6
+ import { spawn, spawnSync, execFile } from "node:child_process";
7
7
  import { homedir } from "node:os";
8
8
  import { join, dirname, resolve, relative } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
@@ -957,18 +957,41 @@ function track(name, cmd, cwd, onExit, env) {
957
957
  // Agent-agnostic "what did it do": read it straight from git, so it works the
958
958
  // same whoever the agent was (Claude, Codex/GPT, Aider, Gemini, Cursor…). Shows
959
959
  // current working-tree changes + any commits the agent made since it started.
960
- function agentChanges(path, startedAt) {
961
- try {
962
- const q = JSON.stringify(path);
963
- const porcelain = sh(`git -C ${q} status --porcelain`).trim();
964
- // Don't count Symbiot's own .symbiot/ dir (agent.log, TASKS.md) as the agent's work.
960
+ // The answer is kept a while (CHANGES): Home and the Agents tab ask every few seconds
961
+ // for every earlier run (up to 60), and three or four git commands each, one after
962
+ // another, held the whole app up 20s in every 45 (2026-10-12). One older than
963
+ // CHANGES_FRESH is still given while git is asked again in the background, so a
964
+ // screen never waits on git for a run it has seen before.
965
+ const CHANGES = new Map(), CHANGES_FRESH = 15000, CHANGES_MAX = 300;
966
+ const NO_CHANGES = { dirty: 0, stat: "", commits: [] };
967
+ function changesOf(path, startedAt, git) {
968
+ // Don't count Symbiot's own .symbiot/ dir (agent.log, TASKS.md) as the agent's work.
969
+ const since = new Date(startedAt || Date.now()).toISOString();
970
+ const read = ([porcelain, stat, cached, raw]) => {
971
+ porcelain = porcelain.trim(); raw = raw.trim();
965
972
  const dirty = porcelain ? porcelain.split("\n").filter((l) => { const p = l.slice(3); return p !== ".symbiot" && p !== ".symbiot/" && p.indexOf(".symbiot/") !== 0; }).length : 0;
966
- const stat = (sh(`git -C ${q} diff --shortstat`).trim() || sh(`git -C ${q} diff --cached --shortstat`).trim()).replace(/^\s+/, "");
967
- const since = new Date(startedAt || Date.now()).toISOString();
968
- const raw = sh(`git -C ${q} log --since=${JSON.stringify(since)} --pretty=%h\u0001%s`).trim();
969
973
  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) }; }) : [];
970
- return { dirty, stat, commits };
971
- } catch { return { dirty: 0, stat: "", commits: [] }; }
974
+ return { dirty, stat: (stat.trim() || cached.trim()).replace(/^\s+/, ""), commits };
975
+ };
976
+ const asks = [["status", "--porcelain"], ["diff", "--shortstat"], ["diff", "--cached", "--shortstat"], ["log", `--since=${since}`, "--pretty=%h\u0001%s"]];
977
+ const outs = asks.map((a) => git(["-C", path, ...a]));
978
+ return outs.some((o) => o && typeof o.then === "function") ? Promise.all(outs).then(read) : read(outs);
979
+ }
980
+ const gitNow = (args) => { const r = spawnSync("git", args, { encoding: "utf8", timeout: 6000, killSignal: "SIGKILL", maxBuffer: 32 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }); return r.status === 0 ? String(r.stdout || "") : ""; };
981
+ const gitLater = (args) => new Promise((res) => execFile("git", args, { encoding: "utf8", timeout: 6000, killSignal: "SIGKILL", maxBuffer: 32 * 1024 * 1024 }, (e, out) => res(e ? "" : String(out || ""))));
982
+ function agentChanges(path, startedAt) {
983
+ const had = CHANGES.get(path), now = Date.now();
984
+ // the same run: a run's start is only known roughly when it began before a restart
985
+ if (had && Math.abs(had.startedAt - (startedAt || now)) < 60000) {
986
+ if (now - had.at > CHANGES_FRESH && !had.busy) {
987
+ had.busy = true;
988
+ Promise.resolve(changesOf(path, startedAt, gitLater)).then((v) => { CHANGES.set(path, { at: Date.now(), startedAt: had.startedAt, v }); }, () => { had.busy = false; });
989
+ }
990
+ return had.v;
991
+ }
992
+ let v = NO_CHANGES; try { v = changesOf(path, startedAt, gitNow); } catch {}
993
+ if (CHANGES.size >= CHANGES_MAX) CHANGES.clear();
994
+ CHANGES.set(path, { at: now, startedAt: startedAt || now, v }); return v;
972
995
  }
973
996
  // ---- agent questions: decisions, options and ideas, from ANY agent ---------
974
997
  // A handoff runs unattended (claude -p, codex exec, aider --message, …), so the
@@ -1327,4 +1350,4 @@ function agentsList() {
1327
1350
  }).concat(earlierRuns());
1328
1351
  }
1329
1352
 
1330
- export { toKnownHost, continueLanes, autoPick, autoDecide, FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, nothingToDo, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentUndo, urgentDone, urgentSweep, urgentState , licenceGate };
1353
+ export { CHANGES, toKnownHost, continueLanes, autoPick, autoDecide, FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, nothingToDo, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentUndo, urgentDone, urgentSweep, urgentState , licenceGate };
package/core.mjs CHANGED
@@ -77,7 +77,9 @@ const clipWords = (s, max) => { s = String(s || ""); return s.length > max ? s.s
77
77
  // in the login form".
78
78
  // Or nearly the same (nearTask): an agent that suggests a task again rewords
79
79
  // it, and TASKS.md listed both.
80
- const taskWords = (s) => String(s || "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
80
+ // kept per text, like wordsOf's words: sameTask runs for every idea against every task
81
+ const keep = (f, max = 4000) => { const m = new Map(); return (s) => { s = String(s || ""); let v = m.get(s); if (v === undefined) { if (m.size >= max) m.clear(); v = f(s); m.set(s, v); } return v; }; };
82
+ const taskWords = keep((s) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim());
81
83
  function sameTask(a, b) {
82
84
  const x = taskWords(a), y = taskWords(b); if (!x || !y) return false;
83
85
  if (x === y) return true;
@@ -93,14 +95,20 @@ function sameTask(a, b) {
93
95
  // "Watch WhatsApp…" are two tasks, however alike the rest. A name is a word
94
96
  // with a capital that doesn't just start a sentence (Gmail, WhatsApp, WA).
95
97
  const NEAR = 0.85, NEAR_WORDS = 12, LEAD_WORDS = 5;
96
- function wordsOf(s) {
97
- s = String(s || "");
98
+ // A word starts a sentence when only spacing and punctuation since the last word
99
+ // end a sentence: only that gap is read, not all the text before it, which made a
100
+ // 4000-character task cost millions of steps. Every 20s each project's ideas are
101
+ // checked against every task (agentQuestions), so the words of a text are kept
102
+ // (keep): 40 ideas against 393 tasks took 10s a tick, and the app stopped
103
+ // answering, its update never restarting it (2026-10-12).
104
+ const wordsOf = keep((s) => {
105
+ let end = 0;
98
106
  return [...s.matchAll(/[\p{L}\p{N}]+/gu)].map((m) => {
99
- const w = m[0], before = s.slice(0, m.index);
100
- const starts = !before.trim() || /[.!?]["'`)\]]*\s*$/.test(before);
107
+ const w = m[0], first = end === 0, gap = s.slice(end, m.index); end = m.index + w.length;
108
+ const starts = (first && !gap.trim()) || /[.!?]["'`)\]]*\s*$/.test(gap);
101
109
  return { w: w.toLowerCase(), name: w.length > 1 && /\p{Lu}/u.test(w) && (!starts || /\p{Lu}/u.test(w.slice(1))) };
102
110
  });
103
- }
111
+ });
104
112
  // The first sentence's words, when more follows it ("" when it's all one sentence).
105
113
  function leadOf(s) { const m = String(s || "").match(/^(.*?[.!?])["'`)\]]*\s+\S/s); return m ? taskWords(m[1]) : ""; }
106
114
  function nearTask(a, b) {
@@ -128,7 +136,7 @@ function nearTask(a, b) {
128
136
  // "Map the whole page … (not for Gmail)".
129
137
  const ASK = 0.6, ASK_WORDS = 8;
130
138
  const STOP = new Set(("the and but for from with are was were been its this that these those then than once each his her him she they them their you your yourself can cant not does did have has had will would should could just also there here into onto out about before after when what which who whom how why all any some more most other only own same too very again").split(" "));
131
- function askWords(s) { return new Set(wordsOf(s).map((x) => x.w).filter((w) => w.length > 2 && !STOP.has(w)).map((w) => w.replace(/(ing|ed|es|s)$/, ""))); }
139
+ const askWords = keep((s) => new Set(wordsOf(s).map((x) => x.w).filter((w) => w.length > 2 && !STOP.has(w)).map((w) => w.replace(/(ing|ed|es|s)$/, ""))));
132
140
  function sameAsk(a, b) {
133
141
  const x = askWords(a), y = askWords(b); if (Math.min(x.size, y.size) < ASK_WORDS) return false;
134
142
  const nx = new Set(wordsOf(a).filter((w) => w.name).map((w) => w.w)), ny = new Set(wordsOf(b).filter((w) => w.name).map((w) => w.w));
package/home.mjs CHANGED
@@ -302,7 +302,7 @@ function marketingGo(deps = {}) {
302
302
  function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
303
303
  if (!fresh && !deps.board && cached && now - cached.at < KEEP) return cached;
304
304
  const board = deps.board || (() => { try { return watchBoard(24, now); } catch { return { cards: [] }; } });
305
- const pending = deps.pending || (() => { try { return pendingReview(); } catch { return []; } });
305
+ const pending = deps.pending || (() => { try { return pendingReview({ kept: true }); } catch { return []; } });
306
306
  const agents = deps.agents || (() => { try { return agentsList(); } catch { return []; } });
307
307
  const lanes = deps.lanes || (() => { try { return lanesState(); } catch { return { handoffs: [] }; } });
308
308
  const reports = deps.reports || (() => { try { return reportsNews(); } catch { return { count: 0 }; } });
@@ -626,7 +626,7 @@ function runNow(a, { now = Date.now(), est = estimate } = {}) {
626
626
  // not a sphere each with an id for a name.
627
627
  function workScene({ deps = {}, full = false } = {}) {
628
628
  const agents = deps.agents || (() => { try { return agentsList(); } catch { return []; } });
629
- const pending = deps.pending || (() => { try { return pendingReview(); } catch { return []; } });
629
+ const pending = deps.pending || (() => { try { return pendingReview({ kept: true }); } catch { return []; } });
630
630
  const tasks = deps.tasks || (() => { try { return loadTasks(); } catch { return []; } });
631
631
  const map = (deps.repos || (() => { try { return laneMap(); } catch { return {}; } }))() || {};
632
632
  const named = deps.name || displayName;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.63.3",
4
- "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
3
+ "version": "0.63.5",
4
+ "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "symbiot": "index.mjs"
package/server.mjs CHANGED
@@ -248,6 +248,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
248
248
  if (!SANDBOX) { refreshRevoked().catch(() => {}); setInterval(() => refreshRevoked().catch(() => {}), 6 * 3600 * 1000).unref(); } // cancelled Pro keys, about daily
249
249
  { const t0 = Date.now(); refreshRepos().then((l) => console.log(`Found ${l.length} project${l.length === 1 ? "" : "s"} in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)); }
250
250
  let UPDATING = null; // an update in flight: { target, attempt, retrying? }
251
+ const UPDATE_TRIES = 20;
251
252
  let NEWEST_WIN = ""; // the Symbiot window opened last: older ones close themselves (one window, not a pile)
252
253
  process.on("exit", () => closeAway()); // Away's windows go with the app (a quit, an update's restart)
253
254
  const server = createServer(async (req, res) => {
@@ -515,7 +516,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
515
516
  // a sandbox (symbiot app --fresh) never offers an update: it would replace your real install.
516
517
  // A headless browser (an agent's screenshot of Home) never becomes the newest window:
517
518
  // that closed the user's real one, again and again (2026-10-08, "symbiot keeps crashing").
518
- if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1" && !headlessAgent(req.headers["user-agent"])) NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && !DESKTOP && semverGt(LATEST_VERSION, VERSION), ...(DESKTOP ? { desktop: true } : {}), ...(UPDATING && UPDATING.retrying ? { retrying: UPDATING } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
519
+ if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1" && !headlessAgent(req.headers["user-agent"])) NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && !DESKTOP && semverGt(LATEST_VERSION, VERSION), ...(DESKTOP ? { desktop: true } : {}), ...(UPDATING && UPDATING.retrying ? { retrying: { ...UPDATING, of: UPDATE_TRIES } } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
519
520
  // What's new: after an update, since the version you last saw (until you click Got it);
520
521
  // ?latest=1, what the update on offer brings, from its package on npm
521
522
  if (u.pathname === "/api/whatsnew") {
@@ -552,12 +553,13 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
552
553
  const { target, cmd } = updateCmd(LATEST_VERSION, VERSION);
553
554
  const inst = process.env.SYMBIOT_UPDATE_CMD || cmd;
554
555
  // npm lists a new version a little before its download is there (a 404 for the
555
- // .tgz): then it tries again every 30s, up to 5 times, and says so, instead of
556
- // giving up and sending you to a terminal.
556
+ // .tgz): then it tries again every 30s for 10 minutes (UPDATE_TRIES), and says so,
557
+ // instead of giving up and sending you to a terminal. 5 tries (2.5 minutes) gave up
558
+ // on 0.63.3, still a 404 four minutes after it was published (2026-10-12).
557
559
  const attempt = (n) => track("symbiot update", inst, homedir(), (code) => {
558
560
  if (code !== 0) {
559
561
  const tail = readRunLog(join(homedir(), ".symbiot", "agent.log")).slice(-3000);
560
- if (n < 5 && /E404|ETARGET|notarget|No matching version|is not in this registry/i.test(tail)) { UPDATING = { target, retrying: true, attempt: n + 1, at: Date.now() }; setTimeout(() => attempt(n + 1), 30000).unref(); }
562
+ if (n < UPDATE_TRIES && /E404|ETARGET|notarget|No matching version|is not in this registry/i.test(tail)) { UPDATING = { target, retrying: true, attempt: n + 1, at: Date.now() }; setTimeout(() => attempt(n + 1), 30000).unref(); }
561
563
  else UPDATING = null;
562
564
  return;
563
565
  }
package/tasks.mjs CHANGED
@@ -439,17 +439,29 @@ function finishedOffer(path, open) {
439
439
  // uncommitted changes no ticked task covers (untasked: approve them as-is, with
440
440
  // `open`, the repo's open tasks, to tick the ones the run finished).
441
441
  // running: an agent is still editing there, so its changes may be half done.
442
- function pendingReview() {
442
+ // A repo's git side of Review (what changed, what's unreleased), kept REVIEW_FRESH for
443
+ // the screens that only show it (pendingReview({ kept: true }): Home and its 20s
444
+ // tick), which asked git for every repo every few seconds and held the app up
445
+ // (2026-10-12). Approve asks git itself, and forgets what was kept (forgetReview).
446
+ const REVIEW_GIT = new Map(), REVIEW_FRESH = 15000;
447
+ function reviewGit(path, kept) {
448
+ const had = kept && REVIEW_GIT.get(path);
449
+ if (had && Date.now() - had.at < REVIEW_FRESH) return had.v;
450
+ const v = { ...workingChanges(path), unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) };
451
+ REVIEW_GIT.set(path, { at: Date.now(), v }); return v;
452
+ }
453
+ const forgetReview = () => REVIEW_GIT.clear();
454
+ function pendingReview({ kept = false } = {}) {
443
455
  const t = loadTasks(), by = {}; for (const x of t) if (x.review && !x.done && !x.archived) (by[x.repo] = by[x.repo] || []).push(x);
444
456
  const sent = [...new Set(t.filter((x) => x.repo && !x.archived && !by[x.repo]).map((x) => x.repo))];
445
457
  const map = Object.keys(by).length || sent.length ? laneMap() : {};
446
458
  const am = autoMergeRepos();
447
- 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: "" }) }; });
459
+ 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 ? reviewGit(path, kept) : { branch: "", files: [], stat: "" }) }; });
448
460
  for (const repo of sent.sort()) {
449
461
  const path = map[repo]; if (!path || !existsSync(join(path, ".symbiot", "TASKS.md"))) continue;
450
- const wc = workingChanges(path); if (!wc.files.length) continue;
462
+ const wc = reviewGit(path, kept); if (!wc.files.length) continue;
451
463
  const open = finishedOffer(path, t.filter((x) => x.repo === repo && !x.done && !x.archived && !x.review));
452
- out.push({ repo, path, tasks: [], untasked: true, open, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc, unreleased: unreleased(path), bumpOffer: bumpOffer(path), publishesOnMerge: publishesOnMerge(path) });
464
+ out.push({ repo, path, tasks: [], untasked: true, open, autoMerge: am.includes(repo), running: !!runningHandoff(path), ...wc });
453
465
  }
454
466
  return out;
455
467
  }
@@ -569,6 +581,7 @@ async function shipChanges(path, texts, opts = {}) {
569
581
  function autoMergeRepos() { const a = loadConfig().autoMerge; return Array.isArray(a) ? a : []; }
570
582
  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 || [] }; }
571
583
  async function approveRepo(repo, opts = {}) {
584
+ forgetReview();
572
585
  const pro = can("ship"); if (!pro.ok) return { error: pro.why, pro: true }; // Pro (licence.mjs); the work stays on its branch
573
586
  const waiting = (t) => t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
574
587
  const shipped = waiting(loadTasks());
@@ -590,6 +603,7 @@ async function approveRepo(repo, opts = {}) {
590
603
  // repo's open tasks the changes finished (the run didn't tick them): they're
591
604
  // approved with the changes, as Approve does, so they don't go out again.
592
605
  async function approveChanges(repo, opts = {}) {
606
+ forgetReview();
593
607
  const pro = can("ship"); if (!pro.ok) return { error: pro.why, pro: true };
594
608
  if (loadTasks().some((x) => x.repo === repo && x.review && !x.done && !x.archived)) return await approveRepo(repo, opts);
595
609
  const path = laneMap()[repo]; if (!path) return { error: "Repo not found: " + (repo || "(no repo)") };
@@ -710,4 +724,4 @@ function pushTasks(filter) {
710
724
  return { empty: false, written, unresolved, handoff: handoffCmd() };
711
725
  }
712
726
 
713
- export { gitFailed, taskPaths, addTask, toggleTask, setTaskDone, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, applyDrops, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, changelogEntry, changelogSection, noteChangelog, releaseInput, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
727
+ export { forgetReview, gitFailed, taskPaths, addTask, toggleTask, setTaskDone, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, applyDrops, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, changelogEntry, changelogSection, noteChangelog, releaseInput, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
package/ui.mjs CHANGED
@@ -2801,7 +2801,7 @@ function heartbeat(fresh){
2801
2801
  if(tried){
2802
2802
  if(p.version!==tried){try{localStorage.removeItem('symbiot_update_tried');}catch(e){} } // advanced → success, clear
2803
2803
  else if(p.retrying){ // npm hasn't finished publishing it: the app is trying again by itself
2804
- b.className='updatebar show';b.innerHTML="Updating to "+esc(p.retrying.target)+": npm is still publishing it, so Symbiot tries again every 30 seconds (try "+p.retrying.attempt+" of 5). Nothing to do.";return;
2804
+ b.className='updatebar show';b.innerHTML="Updating to "+esc(p.retrying.target)+": npm is still publishing it, so Symbiot tries again every 30 seconds (try "+p.retrying.attempt+" of "+(p.retrying.of||20)+"). Nothing to do.";return;
2805
2805
  }
2806
2806
  else if(p.latest&&p.newer){ // still on the same version → the install didn't advance
2807
2807
  b.className='updatebar reconnect show';