symbiot 0.57.1 → 0.57.2

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,10 @@
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.57.2
6
+
7
+ - **No more "Show me your work" when you've already said.** On a big home folder the search for your projects could run out of time and come back empty, so Home asked where your repos were. Repos found before the cut-off are now kept, a short search keeps the repos the last one found, the folders you set in Settings count as your answer, and your main checkout stays a lane even when a worktree is fresher. Tool installs like `~/.nvm` and `~/.rustup` are no longer taken for projects.
8
+
5
9
  ## 0.57.1
6
10
 
7
11
  - **Windows is tested on every change.** A Windows job runs on GitHub with each pull request: Symbiot has to install, start, open into setup, find your Claude subscription and uninstall cleanly there, or the change doesn't go in. The whole suite runs there too and reports what isn't Windows-ready yet.
package/home.mjs CHANGED
@@ -256,6 +256,7 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
256
256
  // Marketing, a lane of its own across products: its orb, lit when something there needs you
257
257
  const marketing = deps.marketing || ((o) => { try { const m = marketingState(o); return m.on ? { needs: m.needCount, working: m.working, waiting: m.waiting } : false; } catch { return false; } });
258
258
  const named = deps.name || displayName;
259
+ const rootsSet = deps.rootsSet || (() => { try { const r = loadConfig().scanRoots; return Array.isArray(r) && r.length > 0; } catch { return false; } });
259
260
  const you = [], map = repos() || {};
260
261
  // What Symbiot works through (an AI, your agent, the connectors runs use) shows
261
262
  // only when it's missing, and then first (urgent): nothing works without it.
@@ -263,7 +264,9 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
263
264
  if (!connected()) you.push({ kind: "setup", id: "setup:ai", urgent: true, title: "Connect an AI", sub: "Symbiot can't work without one: your Claude subscription (sign in to Claude Code), a key, or a free local model", shape: "settings", focus: "ai" });
264
265
  const gone = agentGone(); if (gone) you.push({ kind: "setup", id: "setup:agent", urgent: true, title: "Your agent is unavailable", sub: `${gone} isn't on this computer any more, so no task can start`, shape: "settings", focus: "agent" });
265
266
  for (const c of signedOut()) you.push({ kind: "setup", id: "setup:conn:" + c.id, urgent: true, title: `Reconnect ${c.name}`, sub: `its Claude connector is signed out, so agent runs can't use ${c.name}`, shape: "settings", focus: "agent" });
266
- if (!Object.keys(map).some((n) => map[n] !== MARKETING_DIR)) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
267
+ // Folders you set in Settings (scanRoots) are your answer already: a search that finds
268
+ // nothing in them (or is cut short) is Symbiot's to retry, never a card asking again.
269
+ if (!rootsSet() && !Object.keys(map).some((n) => map[n] !== MARKETING_DIR)) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
267
270
  // no agent yet: Send to repos and Go would only write TASKS.md files nothing runs, so
268
271
  // Home didn't say "All handled" truthfully. One click for the one on this computer.
269
272
  if (!agentCmd()) { const p = offer(); you.push({ kind: "setup", id: "setup:pick", title: "Pick your agent", sub: p ? `${p.name} is on this computer: one click and it takes your tasks` : "the coding agent that takes your tasks", shape: "settings", focus: "agent", ...(p ? { pick: { name: p.name, tmpl: p.tmpl } } : {}) }); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.57.1",
3
+ "version": "0.57.2",
4
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": {
package/scan.mjs CHANGED
@@ -57,20 +57,29 @@ function scanDraw() {
57
57
  }
58
58
  // Run `find` directly (no shell, no pipe): a timeout then kills find itself
59
59
  // rather than a shell whose piped children keep running, and whatever it found
60
- // before the timeout is kept instead of discarded.
61
- function findPaths(args, limit) {
62
- const timeout = SCAN.active ? Math.max(1000, Math.min(FIND_TIMEOUT_MS, SCAN.deadline - Date.now())) : FIND_TIMEOUT_MS;
63
- const r = spawnSync("find", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024, timeout, killSignal: "SIGKILL" });
60
+ // before the timeout is kept instead of discarded. Kept only if find wrote it out:
61
+ // writing to a pipe, GNU find holds its output in a 4 KB buffer, so a kill threw
62
+ // every repo away (a home folder that took 21s gave 0 repos, and Home then asked
63
+ // the user where their repos were). stdbuf -oL makes it write each line as found.
64
+ let STDBUF = null;
65
+ const hasStdbuf = () => (STDBUF === null ? (STDBUF = spawnSync("stdbuf", ["--version"], { stdio: "ignore" }).status === 0) : STDBUF);
66
+ function findPaths(args, limit, cap = FIND_TIMEOUT_MS) {
67
+ const timeout = SCAN.active ? Math.max(1000, Math.min(cap, SCAN.deadline - Date.now())) : cap;
68
+ const [cmd, argv] = hasStdbuf() ? ["stdbuf", ["-oL", "find", ...args]] : ["find", args];
69
+ const r = spawnSync(cmd, argv, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024, timeout, killSignal: "SIGKILL" });
64
70
  if (r.error && r.error.code !== "ETIMEDOUT") return [];
65
71
  if (r.error && SCAN.active) SCAN.partial = true;
66
72
  return String(r.stdout || "").split("\n").filter(Boolean).slice(0, limit);
67
73
  }
68
- const PRUNE = ["node_modules", ".cache", ".local", ".npm", "venv", ".venv", ".gradle", "Pods"];
74
+ // Tool installs that are git checkouts (~/.nvm, ~/.rustup…) aren't your projects: a lane for
75
+ // them is a lane nobody works in.
76
+ const PRUNE = ["node_modules", ".cache", ".local", ".npm", "venv", ".venv", ".gradle", "Pods", ".nvm", ".rustup", ".cargo", ".pyenv", ".oh-my-zsh"];
69
77
  const anyName = (names) => ["(", ...names.flatMap((n, i) => (i ? ["-o", "-name", n] : ["-name", n])), ")"];
70
78
  // find .git dirs quickly by PRUNING heavy trees (node_modules etc.) instead of
71
- // crawling into them — this is the big speedup for the map scan.
79
+ // crawling into them — this is the big speedup for the map scan. Finding the repos
80
+ // is what the scan is for, so this walk gets the scan's whole budget, not one walk's.
72
81
  function findGitDirs(base, limit) {
73
- return findPaths([base, "-maxdepth", "7", ...anyName([...PRUNE, ".git-crypt"]), "-prune", "-o", "-name", ".git", "-print"], limit);
82
+ return findPaths([base, "-maxdepth", "7", ...anyName([...PRUNE, ".git-crypt"]), "-prune", "-o", "-name", ".git", "-print"], limit, SCAN_TIMEOUT_MS);
74
83
  }
75
84
  function me() {
76
85
  return { email: sh("git config --global user.email").trim(), name: sh("git config --global user.name").trim() };
@@ -299,18 +308,24 @@ function loadRepoCache() {
299
308
  function refreshRepos({ worker = true } = {}) {
300
309
  if (REPO_JOB) return REPO_JOB;
301
310
  REPO_STATE.searching = true; Object.assign(REPO_STATE, { done: 0, total: 0, phase: "" });
302
- const finish = (list) => {
303
- REPO_CACHE = { at: Date.now(), list: Array.isArray(list) ? list : [] }; REPO_STATE.at = REPO_CACHE.at; REPO_STATE.searching = false; REPO_JOB = null; LAST_MAP = null;
311
+ const finish = (list, partial = false) => {
312
+ list = Array.isArray(list) ? list : [];
313
+ // A search cut short by its deadline found less than is there: keep the repos the
314
+ // last one found that still exist, rather than lose lanes (and ask the user where
315
+ // their repos are) because a disk was slow this time.
316
+ if (partial && REPO_CACHE) { const have = new Set(list.map((r) => r.path)); list = [...list, ...REPO_CACHE.list.filter((r) => r && !have.has(r.path) && existsSync(join(r.path, ".git")))]; }
317
+ REPO_CACHE = { at: Date.now(), list }; REPO_STATE.at = REPO_CACHE.at; REPO_STATE.searching = false; REPO_JOB = null; LAST_MAP = null;
304
318
  try { mkdirSync(join(repoFile(), ".."), { recursive: true }); writeFileSync(repoFile(), JSON.stringify(REPO_CACHE), { mode: 0o600 }); } catch {}
305
319
  try { if (REPO_STATE.onChange) REPO_STATE.onChange(REPO_CACHE.list); } catch {}
306
320
  return REPO_CACHE.list;
307
321
  };
308
- if (!worker) { const was = OPTS.cache; OPTS.cache = false; let l = []; try { l = findAllRepos(); } finally { OPTS.cache = was; } return Promise.resolve(finish(l)); }
322
+ loadRepoCache();
323
+ if (!worker) { const was = OPTS.cache; OPTS.cache = false; let l = []; try { l = findAllRepos(); } finally { OPTS.cache = was; } return Promise.resolve(finish(l, SCAN.partial)); }
309
324
  REPO_JOB = new Promise((resolve) => {
310
325
  let w; try { w = new Worker(new URL("./scanworker.mjs", import.meta.url)); } catch { const was = OPTS.cache; OPTS.cache = false; let l = []; try { l = findAllRepos(); } finally { OPTS.cache = was; } resolve(finish(l)); return; }
311
- let got = null;
312
- w.on("message", (m) => { if (m && m.progress) Object.assign(REPO_STATE, m.progress); if (m && m.list) got = m.list; });
313
- w.on("error", () => {}); w.on("exit", () => resolve(finish(got || (REPO_CACHE && REPO_CACHE.list) || [])));
326
+ let got = null, partial = false;
327
+ w.on("message", (m) => { if (m && m.progress) Object.assign(REPO_STATE, m.progress); if (m && m.list) { got = m.list; partial = !!m.partial; } });
328
+ w.on("error", () => {}); w.on("exit", () => resolve(finish(got || (REPO_CACHE && REPO_CACHE.list) || [], partial || !got)));
314
329
  });
315
330
  return REPO_JOB;
316
331
  }
@@ -339,15 +354,25 @@ function findAllRepos(base) {
339
354
  if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
340
355
  }
341
356
  }
342
- // Group worktrees by shared git dir; keep only the freshest checkout of each.
357
+ // Group worktrees by shared git dir: the main checkout is always a lane (it's
358
+ // where the project lives: dropping it for a fresher worktree lost the lane), plus
359
+ // the freshest worktree kept outside it (one inside it, like .claude/worktrees,
360
+ // is an agent's scratch copy, not a place of its own).
343
361
  // (Not deadline-bound: it's what makes the result correct, and it's cheap.)
344
362
  const byCommon = {};
345
363
  for (const r of repos) {
346
- const cd = sh(`git -C ${JSON.stringify(r.path)} rev-parse --git-common-dir 2>/dev/null`).trim() || r.path;
364
+ const cd = sh(`git -C ${JSON.stringify(r.path)} rev-parse --git-common-dir 2>/dev/null`).trim() || join(r.path, ".git");
347
365
  const key = cd.startsWith("/") ? cd : join(r.path, cd);
348
- if (!byCommon[key] || byCommon[key].recency < r.recency) byCommon[key] = r;
366
+ const g = byCommon[key] || (byCommon[key] = { main: null, trees: [] });
367
+ if (key === join(r.path, ".git")) g.main = r; else g.trees.push(r);
349
368
  }
350
- return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 60);
369
+ const kept = [];
370
+ for (const { main, trees } of Object.values(byCommon)) {
371
+ if (main) kept.push(main);
372
+ const tree = trees.filter((t) => !(main && t.path.startsWith(main.path + "/"))).sort((a, b) => b.recency - a.recency)[0];
373
+ if (tree) kept.push(tree);
374
+ }
375
+ return kept.sort((a, b) => b.recency - a.recency).slice(0, 60);
351
376
  } finally { scanEnd(own); }
352
377
  }
353
378
  // What a project is about, in its own words: its package description and keywords
package/scanworker.mjs CHANGED
@@ -9,4 +9,4 @@ const tick = setInterval(() => { const s = scan.SCAN; parentPort.postMessage({ p
9
9
  let list = [];
10
10
  try { list = scan.findAllRepos(); } catch {}
11
11
  clearInterval(tick);
12
- parentPort.postMessage({ list });
12
+ parentPort.postMessage({ list, partial: scan.SCAN.partial });