import { resolve } from "node:path"; import { type ParsedAbsolutePath, parseAbsolutePath } from "./argument.js"; import { CommandCancelledError, type CommandResult, type CommandRunner, runChecked, runCommand, } from "./process.js"; /** Herdr injects this into every managed pane. Without it there is no session to talk to. */ const SESSION_MARKER = "HERDR_ENV"; const MINIMUM_HERDR_VERSION = [0, 8, 2] as const; const CAPABILITY_MAX_AGE_MS = 10 * 60 * 1000; const UNAVAILABLE_RETRY_MS = 5 * 60 * 1000; export interface GitRepository { readonly root: ParsedAbsolutePath; readonly gitDir: ParsedAbsolutePath; } export interface HerdrCapability { readonly repository: GitRepository; readonly herdrVersion: string; readonly cache: "memory" | "detected"; } interface RememberedCapability { readonly capability: Omit; readonly expiresAt: number; } export class HerdrUnavailableError extends Error { constructor(message: string, options?: ErrorOptions) { super(message, options); this.name = "HerdrUnavailableError"; } } function isAtLeast(installed: readonly number[], minimum: readonly number[]): boolean { for (const [index, required] of minimum.entries()) { const part = installed[index] ?? 0; if (part !== required) { return part > required; } } return true; } function parseHerdrVersion(result: CommandResult): string { const raw = result.stdout.trim() || result.stderr.trim(); const match = /^(?:herdr )?(\d+)\.(\d+)\.(\d+)/u.exec(raw); if (!match) { throw new HerdrUnavailableError( `Unable to read the Herdr version from ${JSON.stringify(raw)}.`, ); } const parts = match.slice(1, 4).map(Number); if (!isAtLeast(parts, MINIMUM_HERDR_VERSION)) { throw new HerdrUnavailableError( `Herdr ${MINIMUM_HERDR_VERSION.join(".")} or newer is required, but ${parts.join(".")} is installed.`, ); } return parts.join("."); } /** * The capability is a property of the running session, not of the repository, so it is only * held in memory. Nothing is written to the repository. */ export class HerdrCapabilityResolver { readonly #runner: CommandRunner; readonly #environment: Readonly>; readonly #now: () => number; readonly #memory = new Map(); readonly #unavailable = new Map(); constructor( runner: CommandRunner = runCommand, environment: Readonly> = process.env, now: () => number = Date.now, ) { this.#runner = runner; this.#environment = environment; this.#now = now; } async ensure(cwd: string, signal?: AbortSignal): Promise { const lookupKey = resolve(cwd); const remembered = this.#memory.get(lookupKey); if (remembered && remembered.expiresAt > this.#now()) { return { ...remembered.capability, cache: "memory" }; } this.#memory.delete(lookupKey); this.#assertManagedSession(); const repository = await this.#findRepository(cwd, signal); const herdrVersion = await this.#detectHerdr(repository, signal); const capability = { repository, herdrVersion }; this.#remember(lookupKey, capability); return { ...capability, cache: "detected" }; } /** * Never throws, unlike `ensure`. A failed detection is cached for a short time, so a caller that * runs on every agent start does not spawn `git` and `herdr` every turn. */ async tryEnsure(cwd: string, signal?: AbortSignal): Promise { // The marker comes from the process environment, so the answer cannot change while it runs. if (!this.#isManagedSession()) { return undefined; } const lookupKey = resolve(cwd); const retryAt = this.#unavailable.get(lookupKey); if (retryAt !== undefined && retryAt > this.#now()) { return undefined; } this.#unavailable.delete(lookupKey); try { return await this.ensure(cwd, signal); } catch (error) { if (error instanceof HerdrUnavailableError) { this.#unavailable.set(lookupKey, this.#now() + UNAVAILABLE_RETRY_MS); } return undefined; } } forget(cwd: string): void { const lookupKey = resolve(cwd); this.#unavailable.delete(lookupKey); const remembered = this.#memory.get(lookupKey); const repositoryRoot = resolve(remembered?.capability.repository.root ?? cwd); for (const [key, entry] of this.#memory) { if (key === lookupKey || resolve(entry.capability.repository.root) === repositoryRoot) { this.#memory.delete(key); } } this.#unavailable.delete(repositoryRoot); } #remember(lookupKey: string, capability: Omit): void { const remembered = { capability, expiresAt: this.#now() + CAPABILITY_MAX_AGE_MS }; this.#memory.set(lookupKey, remembered); this.#memory.set(resolve(capability.repository.root), remembered); } #isManagedSession(): boolean { return this.#environment[SESSION_MARKER] === "1"; } #assertManagedSession(): void { if (!this.#isManagedSession()) { throw new HerdrUnavailableError( `Herdr worktree tools only run inside a Herdr-managed pane, where ${SESSION_MARKER} is 1. Start the agent from a Herdr session and retry.`, ); } } async #findRepository(cwd: string, signal: AbortSignal | undefined): Promise { let result: CommandResult; try { result = await runChecked( this.#runner, "git", ["rev-parse", "--show-toplevel", "--absolute-git-dir"], { cwd, signal }, "Unable to locate the Git repository", ); } catch (error) { if (error instanceof CommandCancelledError) { throw error; } throw new HerdrUnavailableError( "Herdr worktree actions require a Git work tree. Run the tool from inside a Git repository.", { cause: error }, ); } const lines = result.stdout.replace(/\r?\n$/u, "").split(/\r?\n/u); if (lines.length !== 2) { throw new HerdrUnavailableError( "Git returned an invalid repository location; Herdr worktree actions cannot run reliably here.", ); } try { return { root: parseAbsolutePath(lines[0], "The repository root"), gitDir: parseAbsolutePath(lines[1], "The Git directory"), }; } catch (error) { throw new HerdrUnavailableError( "Git returned an invalid repository location; Herdr worktree actions cannot run reliably here.", { cause: error }, ); } } async #detectHerdr(repository: GitRepository, signal: AbortSignal | undefined): Promise { let result: CommandResult; try { result = await runChecked( this.#runner, "herdr", ["--version"], { cwd: repository.root, signal }, "Unable to read the Herdr version", ); } catch (error) { if (error instanceof CommandCancelledError) { throw error; } throw new HerdrUnavailableError( "The Herdr CLI is unavailable. Install `herdr` and ensure it is on PATH.", { cause: error }, ); } return parseHerdrVersion(result); } }