/** * Shared helpers for internal-url protocol handlers that resolve IDs against * registered agent sessions. */ import type { Dirent } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { isEnoent } from "@oh-my-pi/pi-utils"; import { AgentRegistry } from "../registry/agent-registry"; const extraArtifactsDirs = new Set(); /** Deepest nesting `sessionFilesFromDisk` descends below an artifacts dir. */ const MAX_SCAN_DEPTH = 8; export function registerArtifactsDir(dir: string): () => void { extraArtifactsDirs.add(dir); return () => { extraArtifactsDirs.delete(dir); }; } export function resetRegisteredArtifactDirsForTests(): void { extraArtifactsDirs.clear(); } /** * Snapshot of artifacts dirs for every registered session, deduped. * * Collects TWO candidate dirs per ref, because a subagent reads from its * adopted (root-wide) `ArtifactManager.dir` but its own children are written * one level deeper, under `sessionFile.slice(0, -6)` (`task/index.ts`). A * depth-2+ subagent's output therefore lives in the write-time dir, not the * adopted one, so `agent://` must scan both or it 404s a live nested peer. * `addDir` dedup collapses the depth-0 case (both formulas agree) back to a * single entry. * * When `options.preferredDir` is supplied — the caller root's canonical * artifact directory, derived from the caller's session file — it is inserted * FIRST, ahead of every registry-derived dir. Under A/B same-id conflicts the * caller's own root must win even when the process-global registry's single * `Main` ref belongs to another root (or the caller's session is not * registered at all). Absent a preferred dir, the pre-existing process-global * ordering is preserved unchanged. */ export function artifactsDirsFromRegistry(options?: { preferredDir?: string }): string[] { const dirs: string[] = []; const addDir = (dir: string | null | undefined) => { if (!dir) return; if (!dirs.includes(dir)) dirs.push(dir); }; if (options?.preferredDir) addDir(options.preferredDir); for (const ref of AgentRegistry.global().list()) { addDir(ref.session?.sessionManager?.getArtifactsDir()); if (ref.sessionFile) addDir(ref.sessionFile.slice(0, -6)); } for (const dir of extraArtifactsDirs) addDir(dir); return dirs; } /** * Recursively scan artifacts dirs for agent session transcripts, keyed by * agent id (the `.jsonl` basename). Used by `history://` so transcripts of * agents no longer in the registry (unregistered one-shot helpers, released * agents, or any agent after session resume) remain reachable — mirroring how * `agent://` reads `.md` outputs straight off disk. * * Layout follows `task/index.ts`: a subagent's transcript is * `/.jsonl`, and its own children nest one level deeper * under `//..jsonl`. Advisor * transcripts (`__advisor*.jsonl`) are observability-only and excluded; * EPERM-rewrite backups (`.bak`) are skipped. When the same id appears in * multiple dirs, the first hit wins (registry dirs are scanned first; a * `preferredDir` from the caller root is scanned before them). Directory * listings are prefetched in parallel; the walk itself stays a sequential * depth-first pass so first-hit order is unaffected. In `onlyPreferred` mode, * only the supplied directory is scanned, with direct files before descendants. * `preserveDuplicates` retains every candidate path per id for ambiguity checks. */ export function sessionFilesFromDisk( preferredDir: string | undefined, options: { onlyPreferred?: boolean; preserveDuplicates: true }, ): Promise>; export function sessionFilesFromDisk( preferredDir?: string, options?: { onlyPreferred?: boolean; preserveDuplicates?: false }, ): Promise>; export async function sessionFilesFromDisk( preferredDir?: string, options?: { onlyPreferred?: boolean; preserveDuplicates?: boolean }, ): Promise | Map> { const dirs = options?.onlyPreferred ? [] : artifactsDirsFromRegistry(); if (preferredDir) dirs.unshift(preferredDir); const listings = new Map>(); const list = (dir: string, depth: number): Promise => { let listing = listings.get(dir); if (listing) return listing; listing = readDirEntries(dir); listings.set(dir, listing); // Fan out to subdirectories as soon as this listing lands. Rejections // are surfaced (in walk order) by the awaiting scan below. listing.then( entries => { if (!entries || depth >= MAX_SCAN_DEPTH) return; for (const entry of entries) { if (entry.isDirectory()) list(path.join(dir, entry.name), depth + 1); } }, () => {}, ); return listing; }; for (const dir of dirs) list(dir, 0); const found = options?.preserveDuplicates ? new Map() : new Map(); const seenDirs = new Set(); const recordFile = (dir: string, entry: Dirent): void => { if (!entry.isFile() || !entry.name.endsWith(".jsonl")) return; if (entry.name.startsWith("__advisor")) return; const id = entry.name.slice(0, -".jsonl".length); const file = path.join(dir, entry.name); if (options?.preserveDuplicates) { const files = found as Map; const candidates = files.get(id); if (candidates) candidates.push(file); else files.set(id, [file]); } else { const files = found as Map; if (!files.has(id)) files.set(id, file); } }; const scan = async (dir: string, depth: number): Promise => { if (depth > MAX_SCAN_DEPTH || seenDirs.has(dir)) return; seenDirs.add(dir); const entries = await list(dir, depth); if (!entries) return; if (options?.onlyPreferred) { // Prefer a direct transcript over a nested same-id copy. for (const entry of entries) recordFile(dir, entry); for (const entry of entries) { if (entry.isDirectory()) await scan(path.join(dir, entry.name), depth + 1); } return; } for (const entry of entries) { if (entry.isDirectory()) { await scan(path.join(dir, entry.name), depth + 1); continue; } recordFile(dir, entry); } }; for (const dir of dirs) await scan(dir, 0); return found; } /** Directory entries of `dir`, or null when it is missing or not a directory. */ async function readDirEntries(dir: string): Promise { try { return await fs.readdir(dir, { withFileTypes: true }); } catch (err) { if (isEnoent(err) || (err as NodeJS.ErrnoException).code === "ENOTDIR") return null; throw err; } } /** * Availability half of the `history://` resolution semantics: true when a * transcript for `agentId` can be served from a registered ref's live session * or retained session file, or from an on-disk `.jsonl` under a known * artifacts dir. Hint surfaces use this so they only advertise * `history://` links that `HistoryProtocolHandler` can actually * resolve. A retained sessionFile path is verified on disk before it counts, * and probing never throws: a stale path or unreadable artifacts subtree * reads as unavailable instead of disturbing the caller's delivery path. */ export async function hasResolvableTranscript(agentId: string): Promise { try { const registry = AgentRegistry.global(); const lower = agentId.toLowerCase(); let ref = registry.get(agentId); if (ref?.kind === "advisor") ref = undefined; ref ??= registry.list().find(candidate => candidate.kind !== "advisor" && candidate.id.toLowerCase() === lower); if (ref?.session) return true; // A running worker resolves to a "no transcript yet" note, not an error. if (ref?.status === "running") return true; if (ref?.sessionFile && (await isReadableFile(ref.sessionFile))) return true; const files = await sessionFilesFromDisk(); for (const id of files.keys()) { if (id.toLowerCase() === lower) return true; } } catch { // Availability probing is advisory; any filesystem failure means the // transcript cannot be promised, never that delivery should fail. } return false; } /** True when `file` exists and is a regular file; never throws. */ async function isReadableFile(file: string): Promise { try { const stat = await fs.stat(file); return stat.isFile(); } catch { return false; } }