/** Task-tab GC decisions (D29 rules as a pure function; no I/O, no pi/herdr). */ export type GcEntryKind = string; export type GcEntryStatus = 'running' | 'settled' | 'consumed' | 'closed'; export interface GcEntryLike { /** 'task' or a role name; legacy short/resident is a label only. */ kind: GcEntryKind; status: GcEntryStatus; /** Consume timestamp (GC grace); closed rows keep the original value. */ consumedAt?: number | null; } function isFinished(status: GcEntryStatus): boolean { return status === 'consumed' || status === 'closed' || status === 'settled'; } /** Close a task tab only when every work pane finished, the grace TTL elapsed (`ttlMs=0` means never * auto-close), no pane is blocked and the rest are idle/done/unknown. */ export function shouldCloseTaskTab(opts: { entries: readonly GcEntryLike[]; paneStatuses: readonly string[]; ttlMs: number; now: number; }): boolean { if (opts.entries.length === 0) return false; if (!opts.entries.every((e) => isFinished(e.status))) return false; if (!opts.entries.every((e) => (e.consumedAt ?? 0) > 0 && (e.consumedAt ?? 0) < opts.now - opts.ttlMs)) return false; if (opts.paneStatuses.some((s) => s === 'blocked')) return false; if (!opts.paneStatuses.every((s) => s === 'idle' || s === 'done' || s === 'unknown')) return false; return true; } /** Pane-level collection (orphan / compat path): consumed before the previous turn (grace so the * settlement notice is still visible); missing pane → record closed (caller handles the write). */ export function shouldClosePane(opts: { consumedAt: number | null; herdrStatus: string | undefined; prevTurnStart: number; }): boolean { if (opts.herdrStatus === undefined) return true; // pane gone → record closed if (opts.herdrStatus !== 'idle' && opts.herdrStatus !== 'done') return false; return (opts.consumedAt ?? 0) > 0 && opts.consumedAt! < opts.prevTurnStart; } /* Isolate worktree collection is deliberately narrow: `refs/heads/pier/*` is NOT proof of pier * ownership — a worker session lives in such a worktree, so a namespace-wide sweep deletes the * worktree its own process is running in. Only THIS session's registered branches are candidates, * cwd never is, and untracked `pier/*` branches need an explicit opt-in. */ export function isPathInside(path: string, parent: string): boolean { const norm = (p: string): string => p.replace(/[\\/]+/g, '/').replace(/\/+$/, ''); const child = norm(path); const base = norm(parent); if (!child || !base) return false; return child === base || child.startsWith(`${base}/`); } export interface IsolateSweepCandidate { branch: string; worktreePath: string; } export interface IsolateSweepSkip { branch: string; reason: 'unregistered' | 'pending' | 'self' | 'no-worktree'; } export interface IsolateSweepPlan { candidates: IsolateSweepCandidate[]; skipped: IsolateSweepSkip[]; } /** Pure: callers pass git output, the subagent registry and the process cwd. */ export function planIsolateSweep(input: { branches: readonly string[]; /** branch → worktree path, from `git worktree list --porcelain`. */ worktreesByBranch: ReadonlyMap; registeredBranches: ReadonlySet; /** Branches created but not yet registered (worktree add → subs.set window). */ pendingBranches: ReadonlySet; sessionOwned: ReadonlyArray<{ branch: string; worktreePath: string }>; /** Path to protect (normally process.cwd()). */ cwd: string; /** Explicit opt-in for branches this session never registered. */ sweepOrphans: boolean; }): IsolateSweepPlan { const candidates = new Map(); const skipped: IsolateSweepSkip[] = []; const add = (branch: string, worktreePath: string): void => { if (isPathInside(input.cwd, worktreePath)) { skipped.push({ branch, reason: 'self' }); return; } candidates.set(branch, { branch, worktreePath }); }; if (input.sweepOrphans) { for (const branch of input.branches) { if (input.registeredBranches.has(branch)) continue; if (input.pendingBranches.has(branch)) { skipped.push({ branch, reason: 'pending' }); continue; } const wtPath = input.worktreesByBranch.get(branch); if (wtPath) add(branch, wtPath); } } for (const entry of input.sessionOwned) { const wtPath = input.worktreesByBranch.get(entry.branch) ?? entry.worktreePath; add(entry.branch, wtPath); } return { candidates: [...candidates.values()], skipped }; }