/** * Subagent inventory: the on-demand parent-facing report of running Subagents * and the Subagent worktrees of their source repositories. Everything is * derived at call time from the Running subagent registry, Herdr's worktree * state, and Git. A released attempt contributes only what it left on disk, * so the report never claims attempt metadata it cannot recover. * See ADR-0015. */ import { countWorktreeCommitsAhead, inspectGitWorktree, listHerdrPanes, listHerdrWorktrees, type HerdrPaneEntry, type HerdrWorktreeListing, } from "./herdr.ts"; import { formatElapsed } from "./format.ts"; import { samePortablePath } from "./path-utils.ts"; import type { SubagentToolResult } from "./completion-handoff.ts"; import type { GitWorktreeSnapshot, ManagedSubagentWorktree } from "./subagent-worktree.ts"; /** Branch namespace owned by this extension's Subagent worktrees. */ export const WORKTREE_BRANCH_PREFIX = "pi-subagent/"; /** Herdr's error code for a path that is not inside a Git work tree. */ const NOT_A_GIT_WORKTREE_PATTERN = /not_git_worktree|not inside a Git work tree/i; /** One running Subagent projected for the inventory by the parent runtime. */ export interface InventoryLiveSubagent { operationId: string; name: string; task: string; agent?: string; modelId?: string; thinking?: string; /** Lifecycle projection kind, e.g. "running", "stalled". */ status: string; /** Optional projection detail, e.g. the activity label. */ statusDetail?: string; elapsedSeconds: number; startTime: number; surface: string; worktree?: ManagedSubagentWorktree; } /** One repository query the inventory should resolve and read. */ export interface InventoryRepositoryQuery { path: string; origin: "parent-cwd" | "live-worktree"; } export interface SubagentInventoryRequest { repositories: InventoryRepositoryQuery[]; live: InventoryLiveSubagent[]; now?: number; } /** Transport seam; tests substitute deterministic Herdr and Git fakes. */ export interface SubagentInventoryTransport { /** Herdr's worktree view for the repository containing `path`. */ listWorktrees(path: string): HerdrWorktreeListing; /** Every Herdr pane, read once per call as ownership evidence. */ listPanes(): HerdrPaneEntry[]; /** Git state of one worktree checkout; throws when it cannot be read. */ inspectWorktree(checkout: string): GitWorktreeSnapshot; /** Commits `checkout` adds on top of `sourceBranch`. */ countCommitsAhead(checkout: string, sourceBranch: string): number; } /** * How a reported worktree relates to this parent runtime: * - `owned`: a running Subagent of this running registry holds it; * - `retained`: no Herdr workspace hosts it, so its attempt is over; * - `unclaimed`: a Herdr workspace hosts it, but no running Subagent here does: * it may belong to another parent runtime or to an earlier session. */ export type InventoryWorktreeKind = "owned" | "retained" | "unclaimed"; export interface InventoryWorktreeCommands { /** Reopen the checkout so Herdr mints a workspace for it. */ reopen: string; /** Remove it through that reissued workspace identity. */ remove: string; } export interface InventoryWorktree { kind: InventoryWorktreeKind; path: string; branch: string; repoRoot: string; sourceCheckout: string; /** Completion operation identity recovered from the branch name. */ operationId?: string; /** Herdr workspace hosting the checkout; absent once none does. */ workspaceId?: string; /** Pane hosting it, for `unclaimed` entries. */ paneId?: string; /** Herdr's detected agent in that pane, e.g. "pi". */ paneAgent?: string; paneAgentStatus?: string; dirty?: boolean; /** Commits the checkout adds on top of the source branch. */ commitsAhead?: number; /** Set when a Git or Herdr probe could not answer; the entry stays reportable. */ probeError?: string; commands?: InventoryWorktreeCommands; } export interface InventoryRepository { repoRoot?: string; sourceCheckout?: string; sourceBranch?: string; queried: InventoryRepositoryQuery[]; /** Set when no query resolved a repository for this entry. */ error?: string; } export interface InventoryLiveEntry extends InventoryLiveSubagent { /** * True when a successfully listed repository does not contain the record's * worktree: it was removed or moved by hand while the Subagent ran. */ worktreeMissing?: boolean; } export interface SubagentInventory { repositories: InventoryRepository[]; live: InventoryLiveEntry[]; worktrees: InventoryWorktree[]; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } /** Quote one argument for the removal commands embedded in the report. */ function quoteCommandArgument(value: string): string { return `"${value.replace(/"/g, '\\"')}"`; } /** * Recover the Completion operation identity a worktree branch was built from. * The display name is unrecoverable — the branch carries only its slug — so * nothing here may pretend to report it. */ export function parseWorktreeBranchIdentity(branch: string): string | undefined { if (!branch.startsWith(WORKTREE_BRANCH_PREFIX)) return undefined; const match = /^(.+)-([0-9a-f]{32})$/.exec(branch.slice(WORKTREE_BRANCH_PREFIX.length)); return match ? match[2] : undefined; } /** Build the removal commands an operator or model can paste verbatim. */ export function buildWorktreeRemovalCommands( repoRoot: string, checkout: string, ): InventoryWorktreeCommands { return { reopen: `herdr worktree open --cwd ${quoteCommandArgument(repoRoot)} --path ${quoteCommandArgument(checkout)}`, remove: `herdr worktree remove --workspace ${quoteCommandArgument("")}`, }; } function answersNotGitWorktree( repositories: InventoryRepository[], worktreePath: string, ): boolean { return repositories.some( (repository) => repository.error !== undefined && NOT_A_GIT_WORKTREE_PATTERN.test(repository.error) && repository.queried.some((query) => query.path === worktreePath), ); } function findOwner( live: InventoryLiveSubagent[], worktreePath: string, ): InventoryLiveSubagent | undefined { return live.find((entry) => entry.worktree && samePortablePath(entry.worktree.path, worktreePath)); } function probeWorktree( checkout: string, sourceBranch: string | undefined, transport: SubagentInventoryTransport, ): Pick { const failures: string[] = []; let dirty: boolean | undefined; let commitsAhead: number | undefined; try { dirty = transport.inspectWorktree(checkout).dirty; } catch (error) { failures.push(`Git state unavailable: ${errorMessage(error)}`); } if (!sourceBranch) { failures.push("Commit distance unavailable: the source checkout has no branch."); } else { try { commitsAhead = transport.countCommitsAhead(checkout, sourceBranch); } catch (error) { failures.push(`Commit distance unavailable: ${errorMessage(error)}`); } } return { ...(dirty !== undefined ? { dirty } : {}), ...(commitsAhead !== undefined ? { commitsAhead } : {}), ...(failures.length > 0 ? { probeError: failures.join(" ") } : {}), }; } /** * Derive the current inventory. Repositories are resolved through Herdr so the * report names the source root Herdr knows, and every probe failure degrades * one field of one entry instead of failing the report. */ export function collectSubagentInventory( request: SubagentInventoryRequest, transport: SubagentInventoryTransport, ): SubagentInventory { const repositories: InventoryRepository[] = []; const listings: Array<{ repository: InventoryRepository; listing: HerdrWorktreeListing }> = []; const listedPaths: string[] = []; function findResolved(repoRoot: string): InventoryRepository | undefined { return repositories.find( (entry) => entry.repoRoot !== undefined && samePortablePath(entry.repoRoot, repoRoot), ); } for (const query of request.repositories) { if (repositories.some((entry) => entry.queried.some((seen) => seen.path === query.path))) continue; try { const listing = transport.listWorktrees(query.path); const resolved = findResolved(listing.repoRoot); if (resolved) { // The same repository reached through another path adds no worktrees. resolved.queried.push(query); continue; } const repository: InventoryRepository = { repoRoot: listing.repoRoot, sourceCheckout: listing.sourceCheckout, ...(listing.sourceBranch ? { sourceBranch: listing.sourceBranch } : {}), queried: [query], }; repositories.push(repository); listings.push({ repository, listing }); for (const entry of listing.worktrees) listedPaths.push(entry.path); } catch (error) { repositories.push({ queried: [query], error: errorMessage(error) }); } } const worktrees: InventoryWorktree[] = []; let panes: HerdrPaneEntry[] | undefined; let paneError: string | undefined; for (const { repository, listing } of listings) { for (const entry of listing.worktrees) { if (!entry.branch.startsWith(WORKTREE_BRANCH_PREFIX)) continue; const owner = findOwner(request.live, entry.path); const kind: InventoryWorktreeKind = owner ? "owned" : entry.workspaceId ? "unclaimed" : "retained"; const operationId = parseWorktreeBranchIdentity(entry.branch); const probe = kind === "owned" ? {} : probeWorktree(entry.path, listing.sourceBranch, transport); const worktree: InventoryWorktree = { kind, path: entry.path, branch: entry.branch, repoRoot: repository.repoRoot!, sourceCheckout: repository.sourceCheckout!, ...(operationId ? { operationId } : {}), ...(entry.workspaceId ? { workspaceId: entry.workspaceId } : {}), ...probe, }; if (kind === "unclaimed" && entry.workspaceId) { if (!panes && !paneError) { try { panes = transport.listPanes(); } catch (error) { paneError = errorMessage(error); } } const pane = panes?.find((candidate) => candidate.workspaceId === entry.workspaceId); if (pane) { worktree.paneId = pane.paneId; if (pane.agent) worktree.paneAgent = pane.agent; worktree.paneAgentStatus = pane.agentStatus; } else if (paneError) { worktree.probeError = worktree.probeError ? `${worktree.probeError} Pane state unavailable: ${paneError}` : `Pane state unavailable: ${paneError}`; } } if (kind !== "owned") { worktree.commands = buildWorktreeRemovalCommands(repository.repoRoot!, entry.path); } worktrees.push(worktree); } } const live: InventoryLiveEntry[] = request.live.map((entry) => { if (!entry.worktree) return { ...entry }; const worktreePath = entry.worktree.path; const listed = listedPaths.some((path) => samePortablePath(path, worktreePath)); if (listed) return { ...entry }; // A vanished checkout reports itself as "not a Git work tree" when Herdr // is asked about its path. Only that evidence justifies saying the // worktree is gone; any other failure leaves the claim unverified. const vanished = repositories.some((repository) => repository.repoRoot !== undefined) && answersNotGitWorktree(repositories, worktreePath); return vanished ? { ...entry, worktreeMissing: true } : { ...entry }; }); // Herdr's listing order is not a contract; keep the report stable. worktrees.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); return { repositories, live, worktrees }; } function formatWorktreeState(worktree: InventoryWorktree): string { const parts: string[] = []; if (worktree.dirty === true) parts.push("uncommitted changes"); if (worktree.dirty === false) parts.push("no local changes"); if (worktree.commitsAhead !== undefined) { parts.push( worktree.commitsAhead === 0 ? "no commits of its own" : `${worktree.commitsAhead} commit${worktree.commitsAhead === 1 ? "" : "s"} not on the source branch`, ); } if (worktree.probeError) parts.push(`state unknown (${worktree.probeError})`); return parts.join(", "); } function formatPaneEvidence(worktree: InventoryWorktree): string { if (!worktree.paneId) return ""; const agent = worktree.paneAgent ? `a ${worktree.paneAgent} agent (${worktree.paneAgentStatus ?? "status unknown"})` : "no agent"; return `open in pane ${worktree.paneId} with ${agent}`; } function formatRepositoryLine(repository: InventoryRepository): string { if (repository.error || !repository.repoRoot) { const queried = repository.queried.map((query) => query.path).join(", "); return `Repository: ${queried} — unavailable: ${repository.error ?? "unknown reason"}`; } const checkout = repository.sourceCheckout ?? repository.repoRoot; const branch = repository.sourceBranch ? ` on ${repository.sourceBranch}` : ""; const origins = repository.queried .map((query) => (query.origin === "parent-cwd" ? "the parent session cwd" : query.path)) .join(", "); return `Repository: ${repository.repoRoot} — source checkout ${checkout}${branch}; queried via ${origins}`; } function formatLiveLine(entry: InventoryLiveEntry, worktree: InventoryWorktree | undefined): string { const runtime = [entry.agent, [entry.modelId, entry.thinking].filter(Boolean).join("|")] .filter(Boolean) .join(" · "); const task = entry.statusDetail ? `${entry.status} (${entry.statusDetail})` : entry.status; // Only a classified worktree can say whether Herdr still hosts it; an // unlisted or unverifiable one must not imply anything about its workspace. const checkout = !entry.worktree ? "shared checkout" : entry.worktreeMissing ? `worktree ${entry.worktree.path} — no longer on disk` : worktree && !worktree.workspaceId ? `worktree ${entry.worktree.path} (branch ${entry.worktree.branch}) — Herdr reports no open workspace for it` : `worktree ${entry.worktree.path} (branch ${entry.worktree.branch})`; return `- ${entry.name}${runtime ? ` (${runtime})` : ""} — ${task} for ${formatElapsed(entry.elapsedSeconds)} — ${checkout}`; } function formatWorktreeLines(worktree: InventoryWorktree): string[] { const identity = [formatWorktreeState(worktree), formatPaneEvidence(worktree)] .filter(Boolean) .join(", "); const head = `- ${worktree.path} (branch ${worktree.branch})${identity ? ` — ${identity}` : ""}`; const lines = [head]; if (worktree.kind === "unclaimed") { const pane = worktree.paneId ? ` Inspect pane ${worktree.paneId}` : " Inspect its pane"; lines.push( ` No Subagent of this session owns it: it may belong to another pi session or to an earlier one.${pane} before acting.`, ); } if (worktree.commands) { lines.push(` To remove: ${worktree.commands.reopen}`); lines.push(` then ${worktree.commands.remove}`); if (worktree.dirty !== false || worktree.commitsAhead !== 0 || worktree.probeError) { lines.push( " Herdr refuses to remove a checkout with changes, and --force discards them; check the entry's state first.", ); } } return lines; } /** Render the derived inventory as the tool result the parent model reads. */ export function formatSubagentInventory(inventory: SubagentInventory): SubagentToolResult { const retained = inventory.worktrees.filter((entry) => entry.kind === "retained"); const unclaimed = inventory.worktrees.filter((entry) => entry.kind === "unclaimed"); const owned = inventory.worktrees.filter((entry) => entry.kind === "owned"); const lines: string[] = [ `Subagent inventory — ${inventory.live.length} running, ` + `${inventory.worktrees.length} Subagent worktree${inventory.worktrees.length === 1 ? "" : "s"} ` + `(${owned.length} held, ${retained.length} retained, ${unclaimed.length} unclaimed)`, ]; const ownedByPath = (path: string) => owned.find((entry) => samePortablePath(entry.path, path)); for (const repository of inventory.repositories) lines.push(formatRepositoryLine(repository)); lines.push("", `Running (${inventory.live.length})`); for (const entry of inventory.live) { lines.push(formatLiveLine(entry, entry.worktree ? ownedByPath(entry.worktree.path) : undefined)); } lines.push("", `Retained worktrees (${retained.length})`); for (const worktree of retained) lines.push(...formatWorktreeLines(worktree)); lines.push("", `Unclaimed worktrees (${unclaimed.length})`); for (const worktree of unclaimed) lines.push(...formatWorktreeLines(worktree)); return { content: [{ type: "text", text: lines.join("\n") }], details: { repositories: inventory.repositories.map((repository) => ({ ...repository, queried: repository.queried.map((query) => ({ ...query })), })), live: inventory.live.map((entry) => ({ ...entry })), worktrees: inventory.worktrees.map((worktree) => ({ ...worktree })), }, }; } /** Production transport: Herdr's own view, plus Git for worktree state. */ export const productionSubagentInventoryTransport: SubagentInventoryTransport = { listWorktrees: listHerdrWorktrees, listPanes: listHerdrPanes, inspectWorktree: inspectGitWorktree, countCommitsAhead: countWorktreeCommitsAhead, };