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 +4 -0
- package/home.mjs +4 -1
- package/package.json +1 -1
- package/scan.mjs +42 -17
- package/scanworker.mjs +1 -1
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
|
-
|
|
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.
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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