symbiot 0.57.0 → 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 +10 -0
- package/ai.mjs +31 -7
- package/home.mjs +4 -1
- package/index.mjs +4 -1
- package/package.json +2 -2
- package/scan.mjs +42 -17
- package/scanworker.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.57.1
|
|
10
|
+
|
|
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.
|
|
12
|
+
- **Your Claude subscription on Windows.** Claude Code installs as `claude.cmd` on Windows, which Symbiot didn't find, so it fell back to asking for a key. And Symbiot's instructions now reach Claude Code in a file instead of on the command line, which Windows caps at about 8,000 characters.
|
|
13
|
+
- **`symbiot --version` and `symbiot --help` work.** Both ran the weekly write-up instead.
|
|
14
|
+
|
|
5
15
|
## 0.57.0
|
|
6
16
|
|
|
7
17
|
- **Setup, before anything else.** A new Symbiot opens straight into setup: what it is (it watches, it works, it asks), your AI (your Claude subscription, found by itself), your work (your projects, found while you read), your coding agent (one click), every app you use (each connected, or marked "I don't use it"; you can't go on until they're all decided), and an optional folder of your documents. Then a short "finding your way" and Home. Where you are is kept, so closing it resumes there; Settings → Setup → Run setup again walks it again.
|
package/ai.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import Anthropic from "@anthropic-ai/sdk";
|
|
|
4
4
|
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
import { homedir, totalmem, cpus as oscpus } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
|
8
8
|
import { loadConfig, saveConfig, sh, hasCmd, CONFIG_DIR } from "./core.mjs";
|
|
9
9
|
|
|
10
10
|
const MAX_TOKENS = 1600;
|
|
@@ -26,13 +26,33 @@ const PROVIDERS = {
|
|
|
26
26
|
// agents. Whether it's there and signed in: `claude auth status` (a quarter of a
|
|
27
27
|
// second), kept 5 minutes. SYMBIOT_CLAUDE_CMD replaces the command (the tests' stand-in).
|
|
28
28
|
const CLAUDE_BIN = () => process.env.SYMBIOT_CLAUDE_CMD || "claude";
|
|
29
|
+
// How to run it. Windows: npm installs `claude` as claude.cmd, which Node can only
|
|
30
|
+
// start through cmd.exe (a .exe, from Claude's own installer, it starts directly), so
|
|
31
|
+
// it's found with `where` and its arguments are quoted for cmd.exe.
|
|
32
|
+
const winQuote = (a) => '"' + String(a).replace(/"/g, '""') + '"';
|
|
33
|
+
function claudeCommand(platform = process.platform) {
|
|
34
|
+
const bin = CLAUDE_BIN();
|
|
35
|
+
if (platform !== "win32") return { file: bin, shell: false };
|
|
36
|
+
if (/\.exe$/i.test(bin)) return { file: bin, shell: false };
|
|
37
|
+
if (/\.(cmd|bat)$/i.test(bin)) return { file: bin, shell: true };
|
|
38
|
+
try {
|
|
39
|
+
const hits = String(spawnSync("where", [bin], { encoding: "utf8", timeout: 5000 }).stdout || "").split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
|
|
40
|
+
const exe = hits.find((h) => /\.exe$/i.test(h)); if (exe) return { file: exe, shell: false };
|
|
41
|
+
const cmd = hits.find((h) => /\.(cmd|bat)$/i.test(h)); if (cmd) return { file: cmd, shell: true };
|
|
42
|
+
} catch {}
|
|
43
|
+
return { file: bin, shell: true };
|
|
44
|
+
}
|
|
45
|
+
function claudeSpawn(args, opts = {}, sync = false) {
|
|
46
|
+
const c = claudeCommand(), run = sync ? spawnSync : spawn;
|
|
47
|
+
return c.shell ? run([c.file, ...args].map(winQuote).join(" "), [], { ...opts, shell: true, windowsHide: true }) : run(c.file, args, { ...opts, windowsHide: true });
|
|
48
|
+
}
|
|
29
49
|
let CLAUDE_SEEN = null;
|
|
30
50
|
function claudeState(fresh = false) {
|
|
31
51
|
if (!fresh && CLAUDE_SEEN && Date.now() - CLAUDE_SEEN.at < 300000) return CLAUDE_SEEN;
|
|
32
52
|
let installed = !!process.env.SYMBIOT_CLAUDE_CMD || hasCmd("claude"), signedIn = false;
|
|
33
53
|
if (installed) {
|
|
34
54
|
try {
|
|
35
|
-
const r =
|
|
55
|
+
const r = claudeSpawn(["auth", "status"], { encoding: "utf8", timeout: 8000, env: process.env }, true);
|
|
36
56
|
if (r.error && r.error.code === "ENOENT") installed = false;
|
|
37
57
|
else { const j = JSON.parse(String(r.stdout || "").trim() || "{}"); signedIn = !!j.loggedIn && j.authMethod !== "apiKey"; }
|
|
38
58
|
} catch {}
|
|
@@ -83,7 +103,8 @@ function resolveProvider() {
|
|
|
83
103
|
|
|
84
104
|
// ---- model calls (one per provider, same in/out) --------------------------
|
|
85
105
|
// Claude Code, headless, on your subscription: Symbiot's own instructions as the
|
|
86
|
-
// system prompt
|
|
106
|
+
// system prompt (from a file: Windows' command line holds only ~8,000 characters,
|
|
107
|
+
// and they run longer), the prompt on stdin, no tools (Read only, for screenshots you
|
|
87
108
|
// attached), none of your settings, plugins or servers, no session kept. Not --bare:
|
|
88
109
|
// that ignores the subscription sign-in.
|
|
89
110
|
function callClaude(r, system, prompt, images = []) {
|
|
@@ -93,15 +114,18 @@ function callClaude(r, system, prompt, images = []) {
|
|
|
93
114
|
try { const dir = join(CONFIG_DIR, "uploads"); mkdirSync(dir, { recursive: true, mode: 0o700 }); const f = join(dir, `${Date.now()}-${shots.length}.${/png/.test(i.mime) ? "png" : /webp/.test(i.mime) ? "webp" : /gif/.test(i.mime) ? "gif" : "jpg"}`); writeFileSync(f, Buffer.from(i.data, "base64"), { mode: 0o600 }); shots.push(f); } catch {}
|
|
94
115
|
}
|
|
95
116
|
const input = shots.length ? `${prompt}\n\nScreenshots they attached (look at them with your Read tool): ${shots.join(", ")}` : prompt;
|
|
96
|
-
|
|
117
|
+
let sysFile = ""; try { mkdirSync(CONFIG_DIR, { recursive: true }); sysFile = join(CONFIG_DIR, `.system-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`); writeFileSync(sysFile, system, { mode: 0o600 }); } catch { sysFile = ""; }
|
|
118
|
+
const drop = () => { if (sysFile) { try { unlinkSync(sysFile); } catch {} } };
|
|
119
|
+
const args = ["-p", "--output-format", "json", ...(sysFile ? ["--system-prompt-file", sysFile] : ["--system-prompt", system]), "--tools", shots.length ? "Read" : "", "--setting-sources", "",
|
|
97
120
|
"--strict-mcp-config", "--no-session-persistence", "--disable-slash-commands", ...(r.model ? ["--model", r.model] : [])];
|
|
98
121
|
return new Promise((resolve, reject) => {
|
|
99
122
|
let out = "", err = "", done = false;
|
|
100
|
-
let ch; try { ch =
|
|
123
|
+
let ch; try { ch = claudeSpawn(args, { cwd: CONFIG_DIR, stdio: ["pipe", "pipe", "pipe"], env: process.env }); } catch (e) { drop(); reject(e); return; }
|
|
101
124
|
const t = setTimeout(() => { if (!done) { done = true; try { ch.kill("SIGTERM"); } catch {} reject(new Error("Claude Code didn't answer within 3 minutes")); } }, 180000);
|
|
102
125
|
ch.stdout.on("data", (d) => { out += d; }); ch.stderr.on("data", (d) => { err += d; });
|
|
103
|
-
ch.on("error", (e) => { if (!done) { done = true; clearTimeout(t); reject(e); } });
|
|
126
|
+
ch.on("error", (e) => { drop(); if (!done) { done = true; clearTimeout(t); reject(e); } });
|
|
104
127
|
ch.on("close", () => {
|
|
128
|
+
drop();
|
|
105
129
|
if (done) return; done = true; clearTimeout(t);
|
|
106
130
|
let j = null; try { j = JSON.parse(out.trim().split("\n").filter(Boolean).pop() || "{}"); } catch {}
|
|
107
131
|
if (j && j.is_error === false && typeof j.result === "string") { resolve(j.result.trim()); return; }
|
|
@@ -284,4 +308,4 @@ async function connectProvider(b) {
|
|
|
284
308
|
return saveConfig(cfg) ? { ok: true, message: `Connected: ${PROVIDERS[provider].label} · ${model}` } : { ok: false, message: "Couldn't write the config file." };
|
|
285
309
|
}
|
|
286
310
|
|
|
287
|
-
export { PROVIDERS, AI_UI, resolveProvider, write, validate, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel , claudeState, claudeReady, claudeHelp };
|
|
311
|
+
export { PROVIDERS, AI_UI, resolveProvider, write, validate, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel , claudeState, claudeReady, claudeHelp , claudeCommand };
|
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/index.mjs
CHANGED
|
@@ -49,7 +49,8 @@ import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowled
|
|
|
49
49
|
|
|
50
50
|
// ---- tiny arg parse --------------------------------------------------------
|
|
51
51
|
const argv = process.argv.slice(2);
|
|
52
|
-
|
|
52
|
+
// a first word that's a flag means the default (week), except asking for help or the version
|
|
53
|
+
const cmd = (argv[0] && (!argv[0].startsWith("-") || ["--help", "-h", "--version", "-v"].includes(argv[0])) ? argv[0] : "week").toLowerCase();
|
|
53
54
|
const flag = (name, def) => {
|
|
54
55
|
const i = argv.indexOf(`--${name}`);
|
|
55
56
|
return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
|
|
@@ -812,6 +813,7 @@ ${c.b("Usage")}
|
|
|
812
813
|
symbiot standup yesterday + today, for standup
|
|
813
814
|
symbiot todo what's still on your plate
|
|
814
815
|
symbiot app open the visual app in your browser
|
|
816
|
+
symbiot --version which Symbiot this is
|
|
815
817
|
symbiot open start Symbiot in the background and open its window
|
|
816
818
|
(what its app-menu icon does)
|
|
817
819
|
symbiot uninstall [--keep-data] remove Symbiot and everything it added
|
|
@@ -868,6 +870,7 @@ ${c.d("local Ollama model).")}`;
|
|
|
868
870
|
// ---- main -----------------------------------------------------------------
|
|
869
871
|
async function main() {
|
|
870
872
|
if (cmd === "help" || cmd === "--help" || cmd === "-h") { console.log(HELP); return; }
|
|
873
|
+
if (cmd === "--version" || cmd === "-v" || cmd === "version") { console.log(VERSION); return; }
|
|
871
874
|
if (cmd === "login" || cmd === "auth") return cmdLogin();
|
|
872
875
|
if (cmd === "logout") return cmdLogout();
|
|
873
876
|
if (cmd === "whoami" || cmd === "status") return cmdWhoami();
|
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": {
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"@anthropic-ai/sdk": "^0.128.0"
|
|
81
81
|
},
|
|
82
82
|
"scripts": {
|
|
83
|
-
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/subscription.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
83
|
+
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/subscription.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
84
84
|
"lint": "eslint .",
|
|
85
85
|
"postinstall": "node postinstall.mjs"
|
|
86
86
|
},
|
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