/** * Claudian vault resolution for @pi-claudian extensions. * * Claudian stores conversation metadata under `/.claudian/sessions/`: * metas created by Claudian 2.2.5+ live in per-device subdirectories * (`sessions/devices//conv-*.meta.json`), older ones — and * conversations created before that switch — at the top level * (`sessions/conv-*.meta.json`). See listClaudianMetaFiles. * Earlier extensions assumed `process.cwd()` *is* the vault root. That breaks * when Pi is launched from a sub-directory and a Claudian session is resumed: * Pi sets the extension's `ctx.cwd` to the *session's* recorded home directory * (the jsonl header's `cwd`, see SessionManager.getCwd()), NOT to * `process.cwd()`. Deriving the sessions dir from `process.cwd()` therefore * misses the vault in that case and silently skips the sync. * * This module resolves the nearest enclosing `.claudian/sessions` directory by * walking up from the extension's own `ctx.cwd`, and provides a realpath-aware * path comparison so symlinked vaults match correctly. * * Cross-vault safety: pi's session id is globally unique and each vault keeps * its own `.claudian/sessions`, so a session from vault A has no meta inside * vault B — `findMeta` simply returns null there. Because `ctx.cwd` is the * session's own home, the resolved vault is always the one that owns the * session, so no extra cross-vault guard is needed. * * Source-only (Pi loads it via jiti). Each @pi-claudian package vendors its * own copy, mirroring the debug.ts convention. */ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { Dirent } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { debug } from "./debug.js"; const SESSIONS_REL = path.join(".claudian", "sessions"); const DEVICES_SUBDIR = "devices"; // pi's cwd is fixed for the lifetime of a process, so a single cached lookup // is enough. If a different start path is ever seen it simply re-walks. let cachedStart: string | undefined; let cachedSessionsDir: string | undefined; /** * Resolve the nearest `.claudian/sessions` directory enclosing the extension's * `ctx.cwd`. Walks upward so a session whose home is a sub-directory of the * vault still resolves to the vault. Returns `undefined` when no Claudian vault * encloses the current directory (a plain TUI session, or a non-Claudian * project) — callers treat that as a silent no-op. */ export async function resolveClaudianSessionsDir( ctx: ExtensionContext, ): Promise { const start = ctx.cwd; if (start === cachedStart) return cachedSessionsDir; let dir = start; for (;;) { const candidate = path.join(dir, SESSIONS_REL); try { await fs.access(candidate); cachedStart = start; cachedSessionsDir = candidate; debug("resolved claudian sessions dir:", candidate, "(from", start, ")"); return candidate; } catch { // not present at this level — keep walking up } const parent = path.dirname(dir); if (parent === dir) break; // filesystem root dir = parent; } cachedStart = start; cachedSessionsDir = undefined; debug("no .claudian/sessions enclosing", start, "— not a Claudian environment"); return undefined; } /** * List every conversation meta file in a Claudian sessions dir, across both * storage layouts: the top level (`sessions/conv-*.meta.json` — all Claudian * versions, and newer ones for conversations created before the switch) and * each per-device subdirectory (`sessions/devices//` — where * Claudian 2.2.5+ files new conversations' metas). A conversation's meta * lives in exactly one of the two locations, so callers must scan both. * Order: top level first, then device subdirectories in name order. * * Only `*.meta.json` files are returned. Unreadable device subdirectories are * skipped; a missing `devices/` layout degrades to the top level alone. An * unreadable sessions root propagates its error to the caller. */ export async function listClaudianMetaFiles(sessionsDir: string): Promise { const collect = async (dir: string): Promise => { const entries: Dirent[] = await fs.readdir(dir, { withFileTypes: true }); const files: string[] = []; for (const e of entries) { if (e.isFile() && e.name.endsWith(".meta.json")) files.push(path.join(dir, e.name)); } return files; }; const out: string[] = [...(await collect(sessionsDir))]; let devices: Dirent[] = []; try { devices = await fs.readdir(path.join(sessionsDir, DEVICES_SUBDIR), { withFileTypes: true }); } catch { // no per-device layout (older Claudian) — top level only } const deviceDirs = devices .filter((d) => d.isDirectory()) .sort((a, b) => a.name.localeCompare(b.name)); for (const d of deviceDirs) { out.push(...(await collect(path.join(sessionsDir, DEVICES_SUBDIR, d.name)))); } debug("listed", out.length, "claudian meta file(s) under", sessionsDir); return out; } /** * Robust path equality: resolves both paths through `fs.realpath` (so symlinked * directories compare equal) before a case-insensitive compare on darwin/win. * Falls back to lexical resolution when a path does not exist on disk. */ export async function samePath(a: string, b: string): Promise { const norm = async (p: string): Promise => { const rp = await realpathOrSelf(p); return process.platform === "win32" || process.platform === "darwin" ? rp.toLowerCase() : rp; }; return (await norm(a)) === (await norm(b)); } async function realpathOrSelf(p: string): Promise { try { return await fs.realpath(p); } catch { return path.resolve(p); } }