UNPKG

relay-companion

Version:

Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.

275 lines (255 loc) • 11.2 kB
// Local repo index — resolves a portable originKey (see repo-identity.js) to a // real directory on THIS machine. // // The index is assembled from signals the machine already has, so repo-aware // opening works with zero user configuration on first run: // // 1. Codex's own thread DB (~/.codex/state_5.sqlite). Its `threads` table // already stores git_origin_url alongside cwd, and is indexed on // (archived, cwd, recency_at_ms). That is a pre-built, frequency-ranked // origin -> directory map. Strongest signal by far. // 2. ~/.claude.json `projects` keys — a clean, small registry of directories // Claude has actually been run in. (~/.claude/projects/ is deliberately // NOT used: the vast majority of its entries are ephemeral agent-run temp // dirs, and its directory-name encoding is lossy and non-invertible.) // 3. A bounded scan of conventional source roots, used only when 1 and 2 // miss. Unbounded scans of $HOME are far too slow to sit on a click path. // // Results are cached on disk with a short TTL because this runs on the open // path, between a click and a window appearing. import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { codexStateDbPath, storeDir } from "./host-paths.js"; import { normalizeOriginKey } from "./repo-identity.js"; const CACHE_TTL_MS = Number(process.env.RELAY_REPO_INDEX_TTL_MS || 10 * 60 * 1000); const SCAN_ROOT_NAMES = ["src", "code", "projects", "dev", "work", "repos", "Documents"]; const SCAN_MAX_DEPTH = 3; function cachePath() { return path.join(storeDir(), "repo-index.json"); } function readCache() { try { const raw = JSON.parse(fs.readFileSync(cachePath(), "utf8")); if (!raw || typeof raw !== "object") return null; if (!Number.isFinite(raw.builtAt) || Date.now() - raw.builtAt > CACHE_TTL_MS) return null; return Array.isArray(raw.checkouts) ? raw.checkouts : null; } catch { return null; } } function writeCache(checkouts) { try { fs.mkdirSync(storeDir(), { recursive: true }); fs.writeFileSync(cachePath(), JSON.stringify({ builtAt: Date.now(), checkouts }, null, 2)); } catch { /* cache is an optimization; a failure must never break an open */ } } function git(args, cwd) { try { return String( execFileSync("git", args, { cwd, timeout: 2000, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }), ).trim(); } catch { return ""; } } function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } } // Codex threads: cwd + git_origin_url + recency, already joined for us. // Read-only and immutable so a concurrent Codex write can never be disturbed. function fromCodexThreads() { const dbPath = codexStateDbPath(); if (!fs.existsSync(dbPath)) return []; const sql = "SELECT cwd, COALESCE(git_origin_url,'') AS origin, COUNT(*) AS uses, " + "MAX(COALESCE(recency_at_ms,0)) AS last FROM threads " + "WHERE cwd IS NOT NULL AND cwd <> '' GROUP BY cwd, git_origin_url;"; const uri = `file:${dbPath}?mode=ro&immutable=1`; const result = spawnSync("sqlite3", ["-json", uri, sql], { encoding: "utf8", timeout: 5000 }); if (result.status !== 0 || !String(result.stdout || "").trim()) return []; try { return JSON.parse(result.stdout) .filter((r) => r && r.cwd) .map((r) => ({ dir: String(r.cwd), originKey: normalizeOriginKey(r.origin), uses: Number(r.uses) || 0, lastUsedAt: Number(r.last) || 0, source: "codex", })); } catch { return []; } } // ~/.claude.json `projects` keys are directories Claude has run in. They carry // no origin, so origin is resolved from disk during enrichment. function fromClaudeRegistry() { const file = path.join(os.homedir(), ".claude.json"); try { const parsed = JSON.parse(fs.readFileSync(file, "utf8")); const projects = parsed && typeof parsed.projects === "object" ? parsed.projects : {}; return Object.keys(projects) // Windows/WSL keys from another machine cannot resolve locally. .filter((dir) => dir.startsWith("/")) .map((dir) => ({ dir, originKey: "", uses: 1, lastUsedAt: 0, source: "claude" })); } catch { return []; } } // Extra roots for people who keep repos somewhere unconventional. Use the host // path delimiter (":" on POSIX, ";" on Windows) or commas; splitting Windows // roots on every colon turns C:\\work into two invalid paths. function configuredRoots() { const separator = path.delimiter === ";" ? /[;,]/ : /[:,]/; return String(process.env.RELAY_REPO_ROOTS || "") .split(separator) .map((entry) => entry.trim()) .filter(Boolean); } function scanRoot(root) { const repos = []; const queue = [{ dir: root, depth: 0 }]; while (queue.length) { const current = queue.shift(); let entries; try { entries = fs.readdirSync(current.dir, { withFileTypes: true }); } catch { continue; } if (entries.some((entry) => entry.name === ".git")) { repos.push(current.dir); continue; } if (current.depth + 1 >= SCAN_MAX_DEPTH) continue; for (const entry of entries) { if (!entry.isDirectory() || entry.name === ".git") continue; queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 }); } } return repos; } // Bounded breadth scan of conventional source roots. Keeping traversal in Node // gives Windows the same discovery contract without depending on Unix `find`. function fromDiskScan() { const home = os.homedir(); const roots = [...configuredRoots(), ...SCAN_ROOT_NAMES.map((name) => path.join(home, name))].filter(isDir); if (!roots.length) return []; return roots.flatMap(scanRoot).map((dir) => ({ dir, originKey: "", uses: 0, lastUsedAt: 0, source: "disk", })); } // Fill in origin/branch/common-dir for candidates that lack them, drop anything // that is no longer a real directory, and merge duplicates. // // --git-common-dir is the grouping primitive that makes worktrees behave: a repo // with many linked worktrees reports the SAME common dir from every checkout, so // they collapse into one repo with several candidate directories instead of // looking like unrelated projects. function enrich(candidates) { const byDir = new Map(); for (const candidate of candidates) { if (!candidate.dir || !isDir(candidate.dir)) continue; const existing = byDir.get(candidate.dir); if (existing) { existing.uses += candidate.uses; existing.lastUsedAt = Math.max(existing.lastUsedAt, candidate.lastUsedAt); existing.originKey ||= candidate.originKey; continue; } byDir.set(candidate.dir, { ...candidate }); } const out = []; for (const entry of byDir.values()) { if (!entry.originKey) entry.originKey = normalizeOriginKey(git(["remote", "get-url", "origin"], entry.dir)); const commonDir = git(["rev-parse", "--path-format=absolute", "--git-common-dir"], entry.dir); if (!commonDir) continue; // not a git checkout — cannot be matched by repo identity entry.commonDir = commonDir; entry.branch = git(["rev-parse", "--abbrev-ref", "HEAD"], entry.dir); entry.rootCommit = git(["rev-list", "--max-parents=0", "HEAD"], entry.dir).split("\n").pop() || ""; entry.isPrimary = path.resolve(commonDir) === path.resolve(entry.dir, ".git"); out.push(entry); } return out; } // Build (or reuse) the index. `deep` adds the disk scan, which callers use only // after a history-only lookup has already missed. export function buildRepoIndex({ deep = false, useCache = true } = {}) { if (useCache) { const cached = readCache(); if (cached && (!deep || cached.some((c) => c.source === "disk"))) return cached; } const raw = [...fromCodexThreads(), ...fromClaudeRegistry(), ...(deep ? fromDiskScan() : [])]; const checkouts = enrich(raw); if (useCache) writeCache(checkouts); return checkouts; } // Does a checkout answer to the loose `owner/name` or bare `name` the sender // declared? Checked against the checkout's own origin and its directory name — // both are local facts this machine derived itself, never sender input. // // This exists because the sender states the SUBJECT of the relay, and usually // knows the project by name ("relay") rather than by the recipient's forge and // owner. A name is a weaker key than an origin, so it is only ever consulted // after exact-identity matching has already failed. export function matchesNameClaim(checkout, claim) { const want = String(claim || "").trim().toLowerCase(); if (!want) return false; const originKey = String(checkout?.originKey || "").toLowerCase(); // "owner/name" must match the tail of host/owner/name exactly. if (want.includes("/")) return originKey.endsWith(`/${want}`); // Bare "name" matches the repo's name segment, or the directory it lives in // (which covers a local-only clone with no origin configured at all). if (originKey && originKey.split("/").pop() === want) return true; const dir = String(checkout?.dir || ""); const base = dir.split(/[\\/]/).filter(Boolean).pop() || ""; return base.toLowerCase() === want; } // Resolve a repo context to local checkouts, best first. Returns [] when this // machine has no copy of that repo. // // Matching is by IDENTITY first — originKey, else root-commit SHA for repos with // no origin — and only then by the sender's declared NAME. A sender-supplied path // is never used to locate anything. export function findCheckouts(repoContext, { index } = {}) { const originKey = String(repoContext?.originKey || "").trim().toLowerCase(); const rootCommit = String(repoContext?.rootCommit || "").trim(); const nameClaim = String(repoContext?.nameClaim || "").trim().toLowerCase(); if (!originKey && !rootCommit && !nameClaim) return []; const search = (checkouts) => { const exact = checkouts.filter( (c) => (originKey && c.originKey === originKey) || (rootCommit && c.rootCommit === rootCommit), ); if (exact.length || !nameClaim) return exact; return checkouts.filter((c) => matchesNameClaim(c, nameClaim)); }; let matches = search(index || buildRepoIndex()); // Miss on history alone: pay for the disk scan once, then re-check. if (!matches.length && !index) matches = search(buildRepoIndex({ deep: true, useCache: false })); if (!matches.length) return []; const wantBranch = String(repoContext?.branch || "").trim(); return matches.sort((a, b) => { // Same branch as the sender wins — the most likely intended checkout. const branchA = wantBranch && a.branch === wantBranch ? 1 : 0; const branchB = wantBranch && b.branch === wantBranch ? 1 : 0; if (branchA !== branchB) return branchB - branchA; // Then the checkout the user actually works in most recently. if (a.lastUsedAt !== b.lastUsedAt) return b.lastUsedAt - a.lastUsedAt; if (a.uses !== b.uses) return b.uses - a.uses; // Then the primary clone over a linked worktree. if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; return a.dir.localeCompare(b.dir); }); }