import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { isEnoent } from "@oh-my-pi/pi-utils"; import { LRUCache } from "@oh-my-pi/pi-utils/lru"; import localDoc from "../prompts/internal-urls/local.md" with { type: "text" }; import { AgentRegistry } from "../registry/agent-registry"; import { buildDirectoryResource, containedRealPath, contentTypeForPath, ensureCreatableWithinRoot, ensureWithinRoot, validateRelativePath, } from "./filesystem-resource"; import { parseInternalUrl } from "./parse"; import type { InternalResource, InternalUrl, LocateOptions, ProtocolHandler, ResolveContext, SchemeSpec, UrlCompletion, } from "./types"; import { formatByteSize } from "../utils/video"; export interface LocalProtocolOptions { getArtifactsDir?: () => string | null; getSessionId?: () => string | null; } const WINDOWS_LOCAL_ROOT_MAX_CHARS = 180; function safeSessionId(options: LocalProtocolOptions): string { const raw = options.getSessionId?.() ?? "session"; const safe = raw.replace(/[^a-zA-Z0-9_.-]/g, "_"); return safe && safe !== "." && safe !== ".." ? safe : "session"; } function shortLocalRoot(options: LocalProtocolOptions): string { // Derive the short root from the stable session id, never the artifact path, // so `SessionManager.moveTo()` and the resume-after-move flow keep finding // the same `local://` directory the session wrote pre-move. return path.join(os.tmpdir(), "omp-local", safeSessionId(options)); } const LOCAL_TEXT_SNIFF_BYTES = 8 * 1024; const LOCAL_TEXT_RESOURCE_MAX_BYTES = 1024 * 1024; const BINARY_FILE_EXTENSIONS = new Set([ ".7z", ".avi", ".bmp", ".bz2", ".db", ".doc", ".docx", ".gif", ".gz", ".ico", ".jpeg", ".jpg", ".m4v", ".mkv", ".mov", ".mp4", ".pdf", ".png", ".ppt", ".pptx", ".rar", ".sqlite", ".tgz", ".webm", ".webp", ".wmv", ".xls", ".xlsx", ".xz", ".zip", ]); function buildNonTextLocalResource(url: InternalUrl, filePath: string, size: number, reason: string): InternalResource { const content = `[Cannot read binary local:// file '${url.href}' (${formatByteSize(size)}): ${reason}. This resource is not text. Use a metadata/key-frame/video-specific workflow instead.]`; return { url: url.href, content, contentType: "text/plain", size: Buffer.byteLength(content, "utf-8"), sourcePath: filePath, notes: [LOCAL_WRITE_NOTE], }; } function buildLargeLocalTextResource(url: InternalUrl, filePath: string, size: number): InternalResource { const content = `[Cannot materialize local:// file '${url.href}' as an internal text resource (${formatByteSize(size)} exceeds ${formatByteSize(LOCAL_TEXT_RESOURCE_MAX_BYTES)}). Use the read tool's filesystem path handling or a line selector so content is streamed with file-size safeguards.]`; return { url: url.href, content, contentType: "text/plain", size: Buffer.byteLength(content, "utf-8"), sourcePath: filePath, notes: [LOCAL_WRITE_NOTE], }; } async function readFilePrefix(filePath: string, maxBytes: number): Promise { if (maxBytes <= 0) return new Uint8Array(); const handle = await fs.open(filePath, "r"); try { const buffer = Buffer.allocUnsafe(maxBytes); const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0); return buffer.subarray(0, bytesRead); } finally { await handle.close(); } } function isUtf8Text(bytes: Uint8Array): boolean { if (bytes.indexOf(0) !== -1) return false; try { new TextDecoder("utf-8", { fatal: true }).decode(bytes); return true; } catch { return false; } } async function buildFileResource( url: InternalUrl, resolved: Extract, ): Promise { if (BINARY_FILE_EXTENSIONS.has(path.extname(resolved.path).toLowerCase())) { return buildNonTextLocalResource(url, resolved.path, resolved.size, "extension is a known binary/container type"); } const sniffBytes = await readFilePrefix(resolved.path, Math.min(resolved.size, LOCAL_TEXT_SNIFF_BYTES)); if (!isUtf8Text(sniffBytes)) { return buildNonTextLocalResource(url, resolved.path, resolved.size, "content is not valid UTF-8 text"); } if (resolved.size > LOCAL_TEXT_RESOURCE_MAX_BYTES) { return buildLargeLocalTextResource(url, resolved.path, resolved.size); } const content = await Bun.file(resolved.path).text(); return { url: url.href, content, contentType: contentTypeForPath(resolved.path), size: Buffer.byteLength(content, "utf-8"), sourcePath: resolved.path, notes: [LOCAL_WRITE_NOTE], }; } async function listFilesRecursively(rootPath: string): Promise { const files: string[] = []; // Sibling directories are read concurrently; the final sort fixes the order. const walk = async (relativeDir: string): Promise => { const entries = await fs.readdir(path.join(rootPath, relativeDir), { withFileTypes: true }); const subdirs: Promise[] = []; for (const entry of entries) { const entryPath = path.join(relativeDir, entry.name); if (entry.isDirectory()) { subdirs.push(walk(entryPath)); } else if (entry.isFile()) { files.push(entryPath.replaceAll(path.sep, "/")); } } await Promise.all(subdirs); }; await walk(""); return files.sort((a, b) => a.localeCompare(b)); } /** * `complete()` runs on every keystroke; reuse a root's sorted listing for a * short window instead of re-walking and re-sorting the tree each time. */ const completionListings = new LRUCache>({ max: 16, ttl: 2000 }); function listCompletionFiles(localRoot: string): Promise { const cached = completionListings.get(localRoot); if (cached) return cached; const listing = listFilesRecursively(localRoot); completionListings.set(localRoot, listing); listing.catch(() => completionListings.delete(localRoot)); return listing; } async function buildListing(url: InternalUrl, localRoot: string): Promise { const files = await listFilesRecursively(localRoot); const listing = files.length === 0 ? "(empty)" : files.map(file => `- [${file}](local://${file})`).join("\n"); const content = `# Local\n\n` + `Session-scoped scratch space for large intermediate data, subagent handoffs, and reusable planning artifacts.\n\n` + `Root: ${localRoot}\n\n` + `${files.length} file${files.length === 1 ? "" : "s"} available:\n\n` + `${listing}\n`; return { url: url.href, content, contentType: "text/markdown", size: Buffer.byteLength(content, "utf-8"), sourcePath: localRoot, }; } const LOCAL_AUTHORITY_RE = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i; function extractRelativePath(url: InternalUrl): string { // The authority is the first path segment, decoded below with the rest, so take it as // written: `rawHost` is already decoded (a second decode corrupts names containing `%`), // and WHATWG `hostname` drops `user@` / `:port` parts of names like `a@b` or `a:1`. const host = url.rawHref?.match(LOCAL_AUTHORITY_RE)?.[1] ?? url.hostname; const pathname = url.rawPathname ?? url.pathname; const combined = host ? pathname && pathname !== "/" ? `${host}${pathname}` : host : pathname && pathname !== "/" ? pathname.slice(1) : ""; if (!combined) { return ""; } let decoded: string; try { decoded = decodeURIComponent(combined.replaceAll("\\", "/")); } catch { throw new Error(`Invalid URL encoding in local:// path: ${url.href}`); } validateRelativePath(decoded, "local"); return decoded; } /** Resolve the session-scoped local:// root, shortening long Windows artifact paths before writes hit MAX_PATH. */ export function resolveLocalRoot(options: LocalProtocolOptions, platform: NodeJS.Platform = process.platform): string { const artifactsDir = options.getArtifactsDir?.(); if (artifactsDir) { const candidate = path.resolve(artifactsDir, "local"); if (platform === "win32" && candidate.length >= WINDOWS_LOCAL_ROOT_MAX_CHARS) { return shortLocalRoot(options); } return candidate; } return path.join(os.tmpdir(), "omp-local", safeSessionId(options)); } export interface LocalFileEntry { /** Root-relative, `/`-separated name, e.g. `plans/x-plan.md`. */ name: string; /** `local://${name}` */ url: string; /** Absolute path under the resolved local root. */ path: string; size: number; mtimeMs: number; } /** * Every regular file under the session's local:// root, newest first (ties by name); missing root → []. * Uncached, unlike completion listings, so pickers see files written moments ago. The walker skips * symlinks, so each entry is a real file inside the root. */ export async function listLocalFiles(options: LocalProtocolOptions): Promise { const root = path.resolve(resolveLocalRoot(options)); let names: string[]; try { names = await listFilesRecursively(root); } catch (err) { if (isEnoent(err)) return []; throw err; } const entries = await Promise.all( names.map(async (name): Promise => { const filePath = path.join(root, name); try { const stat = await fs.stat(filePath); return { name, url: `local://${name}`, path: filePath, size: stat.size, mtimeMs: stat.mtimeMs }; } catch (err) { if (isEnoent(err)) return null; throw err; } }), ); return entries .filter((entry): entry is LocalFileEntry => entry !== null) .sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name)); } /** * Recursively copy every local:// artifact from one session-scoped root to * another. Used when a session transition mints a fresh local root (plan * approve-and-execute, handoff) so plans, scratch files, and research notes the * carried-forward context references stay readable in the replacement session. * No-op when the roots match or the source root is absent. */ export async function copyLocalArtifacts(sourceRoot: string, destinationRoot: string): Promise { if (sourceRoot === destinationRoot) return; let sourceRootStat: { isDirectory(): boolean }; try { sourceRootStat = await fs.lstat(sourceRoot); } catch (error) { if (isEnoent(error)) return; throw error; } if (!sourceRootStat.isDirectory()) return; await fs.mkdir(destinationRoot, { recursive: true }); await copyLocalArtifactEntries(sourceRoot, destinationRoot); } async function copyLocalArtifactEntries(sourceDir: string, destinationDir: string): Promise { const entries = await fs.readdir(sourceDir, { withFileTypes: true }); for (const entry of entries) { const sourcePath = path.join(sourceDir, entry.name); const destinationPath = path.join(destinationDir, entry.name); if (entry.isDirectory()) { await fs.mkdir(destinationPath, { recursive: true }); await copyLocalArtifactEntries(sourcePath, destinationPath); continue; } if (entry.isFile()) { await fs.mkdir(path.dirname(destinationPath), { recursive: true }); await fs.copyFile(sourcePath, destinationPath); } } } /** Resolve a local:// URL to an on-disk path under the active session's local root. */ export function resolveLocalUrlToPath( input: string | InternalUrl, options: LocalProtocolOptions, platform: NodeJS.Platform = process.platform, ): string { const url = typeof input === "string" ? parseInternalUrl(input) : input; const localRoot = path.resolve(resolveLocalRoot(options, platform)); const relativePath = extractRelativePath(url); if (!relativePath) { return localRoot; } const resolved = path.resolve(localRoot, relativePath); ensureWithinRoot(resolved, localRoot, "local", url.href); return resolved; } /** * On-disk roots the eval helpers (`read`/`write`) substitute for * internal-URL schemes so e.g. `write("local://x.md")` lands where a later * `read local://x.md` resolves — instead of a literal `local:/` directory under * the cwd (a stdlib `pathlib.Path`/`path.resolve` collapses `local://` to * `local:/`). Keyed by scheme without the `://`. Currently only `local`, but the * shape is a map so additional file-backed schemes can be added without * re-plumbing the worker boundary. */ export function buildEvalUrlRoots(options: LocalProtocolOptions): Record { return { local: resolveLocalRoot(options) }; } const LOCAL_WRITE_NOTE = "Use write path local:// to persist large intermediate artifacts across turns."; type ResolvedLocalTarget = | { kind: "listing"; root: string } | { kind: "directory"; path: string } | { kind: "file"; path: string; size: number }; /** * Resolve a local:// URL to its on-disk target with realpath + containment * checks on the root, parent, and target so symlinks cannot escape the session * local root. Does NOT read or decode file contents — callers decide how to * consume the resolved path. */ async function resolveLocalTarget(url: InternalUrl, opts: LocalProtocolOptions): Promise { const localRoot = path.resolve(resolveLocalRoot(opts)); await fs.mkdir(localRoot, { recursive: true }); let resolvedRoot: string; try { resolvedRoot = await fs.realpath(localRoot); } catch (error) { if (isEnoent(error)) { throw new Error("Unable to initialize local:// root"); } throw error; } const relativePath = extractRelativePath(url); const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot; ensureWithinRoot(targetPath, resolvedRoot, "local", url.href); if (targetPath === resolvedRoot) { return { kind: "listing", root: resolvedRoot }; } const realTargetPath = await containedRealPath(targetPath, resolvedRoot, "local", url.href); if (realTargetPath === undefined) { throw new Error(`Local file not found: ${url.href}`); } const stat = await fs.stat(realTargetPath); if (stat.isDirectory()) { return { kind: "directory", path: realTargetPath }; } if (!stat.isFile()) { throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`); } return { kind: "file", path: realTargetPath, size: stat.size }; } /** * Locate a local:// URL without creating anything. With `create`, returns the * lexical path under the session root (writes land where `resolveLocalUrlToPath` * points) once creating it provably stays inside the root — the deepest existing * ancestor must realpath inside it and no entry may be a dangling symlink (the * root included; a merely missing root is created on write). Otherwise returns * the realpath of an existing target, else null. */ async function locateLocalTarget( url: InternalUrl, opts: LocalProtocolOptions, create: boolean, ): Promise { const localRoot = path.resolve(resolveLocalRoot(opts)); const relativePath = extractRelativePath(url); const targetPath = relativePath ? path.resolve(localRoot, relativePath) : localRoot; ensureWithinRoot(targetPath, localRoot, "local", url.href); let realRoot: string; try { realRoot = await fs.realpath(localRoot); } catch (error) { if (!isEnoent(error)) throw error; if (!create) return null; // Missing root: nothing to escape through unless the root itself is a dangling symlink. await ensureCreatableWithinRoot(targetPath, localRoot, "local", url.href); return targetPath; } const underRealRoot = relativePath ? path.resolve(realRoot, relativePath) : realRoot; if (create) { await ensureCreatableWithinRoot(underRealRoot, realRoot, "local", url.href); return targetPath; } return (await containedRealPath(underRealRoot, realRoot, "local", url.href)) ?? null; } /** * Protocol handler for local:// URLs. * * URL forms: * - local:// - Lists files at the session local root * - local:// - Reads a file under the session local root */ export class LocalProtocolHandler implements ProtocolHandler { readonly scheme = "local"; /** Session scratch space: `write` persists through its file pipeline via `locate({ create })`, approved at read tier. */ readonly spec: SchemeSpec = { backing: "file", selectors: "lines", immutable: false, pathAuthority: true, linkable: true, imageQuestion: true, singleSlashAlias: true, write: { via: "file", payload: "text", scope: "sandbox", tier: () => "read" }, }; static #override: LocalProtocolOptions | undefined; /** * Install a process-global override that wins over the AgentRegistry-based * derivation. Used by top-level SDK consumers that wire * `localProtocolOptions` on `createAgentSession`; subagents keep their * inherited mapping session-bound. */ static setOverride(value: LocalProtocolOptions | undefined): void { LocalProtocolHandler.#override = value; } /** Reset the process-global override. Test-only. */ static resetOverrideForTests(): void { LocalProtocolHandler.#override = undefined; } /** * Returns the active local-protocol options. * * Resolution order: * 1. **Caller-supplied** `context.localProtocolOptions` (the actual session * that initiated the `read`/`find`/`search`/`router.resolve` call). This * is what keeps `local://` reads pinned to the calling session in * multi-session hosts (cmux/ACP, embedded SDK consumers) where every * session registers as `kind: "main"` and "first one wins" would route * to the wrong artifacts directory. * 2. Explicit process-global override installed via {@link setOverride} * (used by SDK consumers with a custom artifacts/session-id mapping and * by code paths that do not have a calling session, e.g. TUI hyperlink * resolution). * 3. The first `main`-kind session in `AgentRegistry.global()`. Its * `SessionManager` supplies both `getArtifactsDir` and `getSessionId`. * Last-resort fallback — every caller that has a session reference * SHOULD thread it through `context` so this branch is never taken in * multi-session setups. */ static resolveOptions(context?: ResolveContext): LocalProtocolOptions | undefined { const fromContext = context?.localProtocolOptions; if (fromContext) return fromContext; const override = LocalProtocolHandler.#override; if (override) return override; const main = AgentRegistry.global() .list() .find(ref => ref.kind === "main"); const sessionManager = main?.session?.sessionManager; if (!sessionManager) return undefined; return { getArtifactsDir: () => sessionManager.getArtifactsDir(), getSessionId: () => sessionManager.getSessionId(), }; } async resolve(url: InternalUrl, context?: ResolveContext): Promise { const opts = LocalProtocolHandler.resolveOptions(context); if (!opts) { throw new Error("No session - local:// unavailable"); } const resolved = await resolveLocalTarget(url, opts); if (resolved.kind === "listing") { return buildListing(url, resolved.root); } if (resolved.kind === "directory") { return buildDirectoryResource(url.href, resolved.path, [LOCAL_WRITE_NOTE]); } return buildFileResource(url, resolved); } async locate(url: InternalUrl, context?: ResolveContext, options?: LocateOptions): Promise { const opts = LocalProtocolHandler.resolveOptions(context); if (!opts) { throw new Error("No session - local:// unavailable"); } return locateLocalTarget(url, opts, options?.create === true); } locateSync(url: InternalUrl, context?: ResolveContext): string | undefined { const opts = LocalProtocolHandler.resolveOptions(context); if (!opts) return undefined; try { return resolveLocalUrlToPath(url, opts); } catch { return undefined; } } promptDoc(): string { return localDoc.trim(); } async complete(_query?: string, context?: ResolveContext): Promise { const opts = LocalProtocolHandler.resolveOptions(context); if (!opts) return []; const localRoot = path.resolve(resolveLocalRoot(opts)); try { const files = await listCompletionFiles(localRoot); return files.map(value => ({ value })); } catch (err) { if (isEnoent(err)) return []; throw err; } } }