UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

508 lines (507 loc) • 25.5 kB
import { AsyncLocalStorage } from "node:async_hooks"; import { nonInteractiveGitEnv } from "../utils-helpers.js"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { request } from "node:http"; import { userInfo } from "node:os"; import { homeDir } from "../home-paths.js"; import { readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { phrenStoreRoot } from "./transcripts.js"; import { locateProject } from "./locate.js"; import { BridgeError } from "./protocol.js"; import { LookupCache } from "./lookup-cache.js"; import { countGit } from "./metrics.js"; const exec = promisify(execFile); const gitReadBudget = new AsyncLocalStorage(); const GIT_READ_MS = 10_000; const gitTimeout = () => new BridgeError(504, "Git did not finish within the request time limit. Retry when the repository is idle.", { code: "git-timeout" }); function remainingGitTime(budget) { const remaining = budget.deadline - performance.now(); if (budget.controller.signal.aborted || remaining <= 0) throw gitTimeout(); return Math.max(1, Math.ceil(remaining)); } /** Nested reads share one deadline. Abort and reap outstanding Git processes on * expiry or failure; never leave a timed-out request running its command chain. */ export async function withGitReadDeadline(read) { const inherited = gitReadBudget.getStore(); if (inherited) { remainingGitTime(inherited); return read(); } const budget = { deadline: performance.now() + GIT_READ_MS, controller: new AbortController(), pending: new Set() }; const timer = setTimeout(() => budget.controller.abort(), GIT_READ_MS); try { return await gitReadBudget.run(budget, async () => { const result = await read(); remainingGitTime(budget); return result; }); } finally { clearTimeout(timer); budget.controller.abort(); await Promise.allSettled([...budget.pending]); } } /** Run provider work outside a completed route's local Git budget. Each local * Git call retains its own timeout, while forge requests use provider limits. */ export function withoutGitReadDeadline(read) { return gitReadBudget.exit(read); } /** A background lookup owns its deadline even after the requesting route ends. */ export function withIndependentGitReadDeadline(read) { return gitReadBudget.exit(() => withGitReadDeadline(read)); } /** Bind Git to the selected checkout, even when Hook inherited another Git * process's repository/index environment. Keep user config (including excludes). */ export function checkoutGitEnv() { const env = nonInteractiveGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }); for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_PREFIX"]) delete env[key]; return env; } export async function git(cwd, ...args) { const budget = gitReadBudget.getStore(); const timeout = budget ? remainingGitTime(budget) : 10_000; countGit("projects"); let pending; try { pending = exec("git", ["-C", cwd, "--no-pager", ...args], { timeout, maxBuffer: 4_194_304, env: checkoutGitEnv(), ...(budget ? { signal: budget.controller.signal, killSignal: "SIGKILL" } : {}), }); budget?.pending.add(pending); const result = await pending; if (budget) remainingGitTime(budget); return result.stdout; } catch (error) { const failure = error; if (budget && (budget.controller.signal.aborted || performance.now() >= budget.deadline)) throw gitTimeout(); const stderr = String(failure.stderr ?? ""); // No-index uses exit 1 for differences, even with conversion warnings. // Keep read errors and unknown diagnostics fatal, including partial output. const diagnostics = stderr.split(/\r?\n/).filter(line => line.trim() && !line.startsWith("warning:")); const options = args.slice(0, args.indexOf("--") < 0 ? args.length : args.indexOf("--")); if (args[0] === "diff" && options.includes("--no-index") && String(failure.code) === "1" && diagnostics.length === 0 && !failure.killed && !failure.signal && failure.errno === undefined && failure.syscall === undefined && failure.name !== "AbortError") return failure.stdout ?? ""; const [status, code, message] = failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" ? [413, "git-output-limit", "Git output exceeded the 4 MiB limit. Narrow the selection."] : failure.killed || failure.signal ? [504, "git-timeout", "Git did not finish within its time limit. Retry when the repository is idle."] : /index\.lock/.test(stderr) ? [409, "git-index-locked", "Git could not lock the index. Wait for the other Git operation to finish; the Hook has not removed the lock."] : /not a git repository/.test(stderr) ? [409, "git-not-repository", "This folder is not a Git repository."] : [503, "git-failed", `Git ${args[0]} failed: ${stderr.trim().slice(0, 1000) || "the command could not complete"}`]; throw new BridgeError(status, message, { code, ...(failure.code !== undefined ? { exitCode: String(failure.code) } : {}) }); } finally { if (pending) budget?.pending.delete(pending); } } export async function gitRoot(dir) { try { return await realpath((await git(dir, "rev-parse", "--show-toplevel")).replace(/\r?\n$/, "")); } catch (error) { if (error instanceof BridgeError && error.details?.code === "git-not-repository") return undefined; throw error; } } /** An unborn HEAD is normal; process/configuration failures are not. */ export async function gitHead(root) { try { return (await git(root, "rev-parse", "--verify", "--quiet", "HEAD")).trim(); } catch (error) { if (error instanceof BridgeError && error.details?.exitCode === "1") return undefined; throw error; } } /** `git status` as the app lists it: one record per file, a staged and an * unstaged section where each has a patch. Limited to `pathspecs` when given. */ async function statusFiles(root, pathspecs = []) { const status = (await git(root, "status", "--porcelain=v1", "-z", "--untracked-files=normal", "--", ...pathspecs.map(spec => `:(literal)${spec}`))).split("\0"); const files = []; let totalFiles = 0, truncated = false, remaining = 1_000_000; for (let index = 0; index < status.length; index++) { const record = status[index]; if (!record) continue; const code = record.slice(0, 2), file = record.slice(3); const original = /[RC]/.test(code) ? status[++index] : undefined; totalFiles++; if (files.length >= 500) { truncated = true; continue; } const sections = []; for (const [kind, staged, column] of [["staged", true, 0], ["unstaged", false, 1]]) { if (code === "??" || code[column] === " ") continue; if (remaining <= 0) { sections.push({ id: `${kind}:${file}`, kind, loadState: "loaded", patch: "", truncated: true }); truncated = true; continue; } const patch = await git(root, "diff", "--no-ext-diff", "--no-textconv", "--no-color", ...(staged ? ["--cached"] : []), "--", ...[file, ...(original ? [original] : [])].map(name => `:(literal)${name}`)); if (patch) { const limit = Math.min(200_000, remaining), clipped = patch.length > limit; sections.push({ id: `${kind}:${file}`, kind, binary: /^(?:Binary files |GIT binary patch)/m.test(patch), loadState: "loaded", patch: patch.slice(0, limit), truncated: clipped }); remaining -= Math.min(patch.length, limit); truncated ||= clipped; } } files.push({ path: file, status: code, ...(file.endsWith("/") ? { directory: true, countsComplete: false } : {}), sections }); } return { files, totalFiles, truncated }; } /** The last commit that touched `pathspec` in the past half hour — what a * command changed when a hook (phren's own Stop hook, say) committed it before * anyone looked. */ async function committed(root, pathspec) { if (!await gitHead(root)) return undefined; const log = await git(root, "log", "-1", "--since=30.minutes", "--format=%h%x1f%s%x1f%cr", "-p", "--no-ext-diff", "--no-textconv", "--no-color", "--", `:(literal)${pathspec}`); const newline = log.indexOf("\n"); if (newline < 0) return undefined; const [hash, subject, when] = log.slice(0, newline).split("\x1f"); const patch = log.slice(newline + 1).replace(/^\n+/, ""); if (!patch) return undefined; return { id: `committed:${pathspec}`, kind: "committed", binary: patch.includes("Binary files"), loadState: "loaded", patch: patch.slice(0, 200_000), truncated: patch.length > 200_000, note: `${hash} · ${subject.slice(0, 120)} · ${when}` }; } /** A path a command named, made absolute and real — `~/` expanded, relative * ones taken from the pane. The caller checks conversation scope after * resolution. Missing files resolve through their nearest existing parent so * a deleted file still finds its repository. */ async function resolveTouched(raw, cwd) { if (typeof raw !== "string" || !raw || raw.length > 4096 || raw.includes("\0")) return undefined; const home = homeDir(); const absolute = raw === "~" || raw.startsWith("~/") ? path.join(home, raw.slice(1)) : path.resolve(cwd, raw); let existing = absolute, rest = []; while (!(await stat(existing).catch(() => undefined))) { const parent = path.dirname(existing); if (parent === existing) return undefined; rest.unshift(path.basename(existing)); existing = parent; } const real = path.join(await realpath(existing), ...rest); return real; } /** The pane's working tree, plus anything the command named: files in the * same repository that a hook already committed, and files in other * repositories — the phren store, a sibling checkout — grouped by root. */ export function repositoryDiff(cwd, touched = [], allowedPaths = []) { return withGitReadDeadline(() => readRepositoryDiff(cwd, touched, allowedPaths)); } async function readRepositoryDiff(cwd, touched, allowedPaths) { const root = await gitRoot(cwd); if (!root) throw new BridgeError(409, "This pane is not in a Git repository.", { code: "git-not-repository" }); const allowed = (await Promise.all([root, phrenStoreRoot(), ...allowedPaths].map(raw => resolveTouched(raw, cwd)))).filter((p) => !!p); const requested = []; for (const raw of touched.slice(0, 24)) { const file = await resolveTouched(raw, cwd); if (!file || !allowed.some(base => file === base || file.startsWith(base + path.sep))) throw new BridgeError(403, "This path is outside the conversation's recorded changes and commands."); requested.push(file); } const branch = (await git(root, "branch", "--show-current")).trim(); const listing = await statusFiles(root); const byRoot = new Map(); for (const file of requested) { const owner = await gitRoot((await stat(file).catch(() => undefined))?.isDirectory() ? file : path.dirname(file)); if (!owner) continue; // git speaks forward slashes on every platform. const rel = (path.relative(owner, file) || ".").split(path.sep).join("/"); if (rel.startsWith(":")) throw new BridgeError(400, "Invalid diff pathspec."); const list = byRoot.get(owner) ?? []; if (!list.includes(rel)) list.push(rel); byRoot.set(owner, list); } const related = []; for (const [owner, specs] of byRoot) { if (related.length >= 8) break; const ownerListing = owner === root ? listing : await statusFiles(owner, specs); const listed = ownerListing.files; const seen = new Set(listed.map(file => file.path)); for (const spec of specs) { // Uncommitted changes under the path are already listed; otherwise show the commit. if ([...seen].some(file => file === spec || spec === "." || file.startsWith(spec + "/"))) continue; if (ownerListing.truncated) continue; // An omitted dirty row must not be replaced with a historical commit. const section = await committed(owner, spec); if (section) { ownerListing.totalFiles++; if (listed.length >= 500) { ownerListing.truncated = true; continue; } listed.push({ path: spec, status: " ", sections: [section] }); seen.add(spec); ownerListing.truncated ||= section.truncated === true; } } if (owner !== root && listed.length) related.push({ root: owner, branch: (await git(owner, "branch", "--show-current")).trim(), ...ownerListing }); } return { branch, root, repository: root, observedAt: new Date().toISOString(), launchPath: cwd, ...listing, ...(related.length ? { related } : {}) }; } /** The HEAD file of the repository holding `cwd` (`.git/HEAD`, or a linked * worktree's through its `.git` file), found without spawning git. */ export async function headFile(cwd) { let directory = cwd; for (let depth = 0; depth < 64; depth++) { const dotGit = path.join(directory, ".git"); const info = await stat(dotGit).catch(() => undefined); if (info?.isDirectory()) return path.join(dotGit, "HEAD"); if (info?.isFile()) { const match = /^gitdir:\s*(.+)$/m.exec(await readFile(dotGit, "utf8").catch(() => "")); return match ? path.join(path.resolve(directory, match[1].trim()), "HEAD") : undefined; } const parent = path.dirname(directory); if (parent === directory) return undefined; directory = parent; } return undefined; } async function headState(cwd) { const file = await headFile(cwd); if (!file) return undefined; const text = await readFile(file, "utf8").catch(() => undefined); return text === undefined ? undefined : `${file}\n${text}`; } /** How long a branch is reused outright, and the longest it is kept while * the repository's HEAD file reads the same. */ const BRANCH_FRESH_MS = 10_000, BRANCH_MAX_MS = 300_000; /** The pane's current branch for the chat header and the overview cards. * Cached per directory: the status stream asks every 1.5s, the overview every * 3s, and a branch rarely moves. Past ten seconds the cached branch is kept * while HEAD's file and contents are unchanged (a checkout rewrites it), so * `git branch` runs again only after a switch or after five minutes. */ const branches = new Map(); export async function repositoryBranch(cwd) { const cached = branches.get(cwd); const now = Date.now(); if (cached && now - cached.checked < BRANCH_FRESH_MS) return cached.value; const head = await headState(cwd); if (cached && head !== undefined && head === cached.head && now - cached.at < BRANCH_MAX_MS) { cached.checked = now; return cached.value; } let value; try { countGit("branch"); const { stdout } = await exec("git", ["-C", cwd, "--no-pager", "branch", "--show-current"], { timeout: 5_000, maxBuffer: 65_536, env: nonInteractiveGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_CONFIG_NOSYSTEM: "1" }), }); value = stdout.trim().slice(0, 200) || undefined; } catch { value = undefined; } if (branches.size >= 64) branches.delete(branches.keys().next().value); branches.set(cwd, { at: Date.now(), checked: Date.now(), head, value }); return value; } const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " }; /** Page titles arrive HTML-escaped ("Safety &amp; Quality"); show them as text. */ function decodeEntities(text) { return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity) => { if (entity[0] === "#") { const code = entity[1]?.toLowerCase() === "x" ? Number.parseInt(entity.slice(2), 16) : Number(entity.slice(1)); return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match; } return ENTITIES[entity.toLowerCase()] ?? match; }); } function probe(port, host) { return new Promise(resolve => { let settled = false; const finish = (value) => { if (!settled) { settled = true; clearTimeout(deadline); resolve(value); } }; const deadline = setTimeout(() => { finish(null); req.destroy(); }, 1200); const req = request({ hostname: host, port, path: "/", method: "GET", timeout: 1200, headers: { Host: `localhost:${port}` } }, res => { let body = ""; res.on("data", data => { body += data.toString(); if (body.length > 32_768) res.destroy(); }); const end = () => finish(decodeEntities(/<title[^>]*>([^<]{1,300})<\/title>/i.exec(body)?.[1] ?? "").trim() || `Web server on port ${port}`); res.on("end", end); res.on("close", end); res.on("error", () => finish(null)); }); req.on("error", () => finish(null)); req.on("timeout", () => { req.destroy(); finish(null); }); req.end(); }); } const LISTEN_HOST = /^(\*|127\.0\.0\.1|localhost|0\.0\.0\.0|\[::\]|\[::1\]|::|::1)$/; /** Linux ephemeral range starts here; ports below it are far more likely to be a * dev server than a browser's devtools or IPC listener. */ const EPHEMERAL_PORT = 32_768; function addListener(ports, address, port, processName, pid) { if (!LISTEN_HOST.test(address) || !Number.isInteger(port) || port <= 0) return; const host = address.includes(":") ? "[::1]" : "127.0.0.1"; ports.set(`${host}:${port}`, { name: "", port, origin: `http://${host}:${port}`, process: processName, pid }); } /** Listening TCP sockets owned by this user, via lsof (always present on macOS). */ async function listenersFromLsof(ports) { const result = await exec(process.platform === "darwin" ? "/usr/sbin/lsof" : "lsof", ["-nP", "-a", "-u", userInfo().username, "-iTCP", "-sTCP:LISTEN", "-Fpcn"], { timeout: 4000, maxBuffer: 1_048_576 }); let pid, processName; for (const line of result.stdout.split("\n")) { if (line.startsWith("p")) pid = Number(line.slice(1)); if (line.startsWith("c")) processName = line.slice(1); if (!line.startsWith("n")) continue; const split = line.lastIndexOf(":"); if (split < 0) continue; addListener(ports, line.slice(1, split), Number(line.slice(split + 1)), processName, pid); } } /** Listening TCP sockets via iproute2's ss, which every Linux ships even when * lsof is absent (Arch/Omarchy, minimal containers). Lines look like * `LISTEN 0 512 *:3000 *:* users:(("bun",pid=123,fd=24))`. */ async function listenersFromSs(ports) { const result = await exec("ss", ["-ltnpH"], { timeout: 4000, maxBuffer: 1_048_576 }); for (const line of result.stdout.split("\n")) { const columns = line.trim().split(/\s+/); if (columns.length < 4) continue; const local = columns[3]; const split = local.lastIndexOf(":"); if (split < 0) continue; const owner = /users:\(\("([^"]*)",pid=(\d+)/.exec(line); addListener(ports, local.slice(0, split), Number(local.slice(split + 1)), owner?.[1], owner ? Number(owner[2]) : undefined); } } const serversCache = new LookupCache(10_000, 300_000, 1, () => true, 20_000); /** When each cached listing's socket-table read began: the listing is only as fresh as that, however long probing took. */ const scanStarted = new WeakMap(); /** A cached listener is trusted this long after its scan; the socket table is read again after that. */ const LISTENER_TRUST_MS = 1_000; /** The web servers listening here. A cached listener is checked against the live socket table * before it is returned: if the process that held its address and port no longer listens there * (it stopped, or closed the socket and kept running), the port may already belong to another * session's server, and attributing it to the old owner would open that session's preview on the * wrong app. Such a result drops the entry and refreshes the cache. */ export async function webServers() { const servers = await serversCache.get("listeners", scanWebServers); if (!servers.some(server => server.pid !== undefined) || Date.now() - (scanStarted.get(servers) ?? 0) < LISTENER_TRUST_MS) return servers; const live = await sharedListeners(); // The table could not be read (ss timed out, no lsof): ownership cannot be verified, so none is claimed. if (!live) return servers.map(({ pid: _pid, ...server }) => ({ ...server, unverified: true })); const kept = servers.filter(server => server.pid === undefined || live.get(listenerKey(server))?.pid === server.pid); if (kept.length !== servers.length) serversCache.invalidate(() => true); return kept; } /** Concurrent ownership checks share one socket-table read; a read is never reused once it has settled. */ let listenersInFlight; function sharedListeners() { return listenersInFlight ??= readListeners().catch(() => undefined).finally(() => { listenersInFlight = undefined; }); } /** The key `addListener` files a server under: its host with the port always spelled out (a URL's host drops the default one). */ export function listenerKey(server) { return `${server.origin.includes("[::1]") ? "[::1]" : "127.0.0.1"}:${server.port}`; } /** The listening sockets (address:port to owner), without probing any of them. */ async function readListeners() { const ports = new Map(); // Prefer ss on Linux and lsof elsewhere; try the other if the first is missing. const readers = process.platform === "linux" ? [listenersFromSs, listenersFromLsof] : [listenersFromLsof, listenersFromSs]; let lastError; for (const reader of readers) { try { await reader(ports); lastError = undefined; break; } catch (error) { lastError = error; } } if (lastError && ports.size === 0) throw new BridgeError(503, "No socket listing tool found: install lsof (or iproute2's ss on Linux)."); return ports; } async function scanWebServers() { const started = Date.now(); const ports = await readListeners(); // Probe well-known ports first so a browser's dozens of ephemeral listeners // cannot push a dev server on :3000 past the cap. const candidates = [...ports.values()] .sort((a, b) => Number(a.port >= EPHEMERAL_PORT) - Number(b.port >= EPHEMERAL_PORT) || a.port - b.port) .slice(0, 64); const found = []; // Every probe is bounded (1.2 s) and loopback-only, so they all run at once: in groups of eight // a handful of listeners that accept and never answer cost 1.2 s per group. await Promise.all(candidates.map(async (server) => { const name = await probe(server.port, server.origin.includes("[::1]") ? "::1" : "127.0.0.1"); if (name !== null) found.push({ ...server, name }); })); const listing = found.sort((a, b) => a.port - b.port); scanStarted.set(listing, started); return listing; } /** Launch only in real local directories under home or a locator candidate. */ export async function launchDirectory(raw, activity = [], located = []) { if (typeof raw !== "string" || raw.length > 4096 || !path.isAbsolute(raw) || /[\x00-\x1f\x7f]/.test(raw)) throw new BridgeError(400, "Invalid workspace directory."); let dir; try { dir = await realpath(raw); if (!(await stat(dir)).isDirectory()) throw new Error(); } catch { throw new BridgeError(400, "Workspace directory does not exist."); } const home = await realpath(homeDir()); if (dir === home || dir.startsWith(home + path.sep)) return dir; for (const candidate of located) { const real = await realpath(candidate).catch(() => undefined); if (real && (dir === real || dir.startsWith(real + path.sep))) return dir; } const names = [...new Set(dir.split(path.sep).filter(name => /^[a-z0-9][a-z0-9-]{0,99}$/.test(name)))]; for (const name of names) { for (const candidate of await locateProject(name, activity)) { if (dir === candidate.directory || dir.startsWith(candidate.directory + path.sep)) return dir; } } throw new BridgeError(403, "Workspace directory must be under home or a located project."); }