import { stat } from "node:fs/promises"; import { basename, join } from "node:path"; import type { ExtensionAPI, ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { getSessionsRoot, loadConfig, resolvePortableRoot, toPortableNameOptions, } from "./config"; import { defaultSessionDirName, findPendingMigrations, type JsonlConflictHandler, type MigrationResult, type MigrationState, migrateAllSessionDirs, migrateNamedSessionDirs, migrateSessionDir, } from "./migrate"; import { mergeJsonlWithModel } from "./model-merge"; import { portableSessionDirName } from "./portable-name"; function stateLabel(state: MigrationState): string { switch (state) { case "migrated": return "already migrated"; case "migrated-now": return "migrated"; case "would-migrate": return "would migrate (dry run)"; case "already-portable": return "already portable"; case "no-sessions": return "no sessions"; case "portable-only": return "portable dir exists; default path missing"; case "conflict": return "conflict"; } } function summarize(results: MigrationResult[], dryRun: boolean): string { // Directories Pi scanned that could not be attributed to a working directory // (no session file with a cwd header, e.g. empty dirs created by running Pi // outside a real project) are not worth a per-directory line: collapse them // into a single summary count instead of noisy "no sessions" rows. const skipped = results.filter( (result) => result.state === "no-sessions" && result.portableName === "", ); const rest = results.filter( (result) => !(result.state === "no-sessions" && result.portableName === ""), ); const lines = rest.map((result) => { const name = result.portableName || ""; const note = result.note ? ` — ${result.note}` : ""; let conflicts = ""; if (result.jsonlConflicts > 0) { conflicts = `, ${result.jsonlMerged}/${result.jsonlConflicts} jsonl conflicts merged`; if (result.jsonlPreserved > 0) { conflicts += `, ${result.jsonlPreserved} preserved as *-conflicted.jsonl`; } } return ` ${stateLabel(result.state)}: ${name}${conflicts}${note}`; }); if (skipped.length > 0) { lines.push( ` ${skipped.length} director${skipped.length === 1 ? "y" : "ies"} skipped (no session files with a cwd header)`, ); } const verb = dryRun ? "Would migrate" : "Migrated"; return `${verb} ${rest.length} session director${rest.length === 1 ? "y" : "ies"}:\n${lines.join("\n")}`; } interface MigrateFlags { all: boolean; dryRun: boolean; yes: boolean; } function parseMigrateFlags(args: string[]): MigrateFlags { return { all: args.includes("--all"), dryRun: args.includes("--dry-run"), yes: args.includes("--yes"), }; } const USAGE = [ "Usage: /portable-sessions ", "", " status Show the portable name and directories for the current project", " migrate Migrate session directories to portable names", " [--all] [--dry-run] [--yes] [...]", "", " ... One or more session directory names to migrate", " (default `----` names or portable names)", " or absolute working directory paths.", ].join("\n"); export default function piPortableSessionsExtension(pi: ExtensionAPI): void { const agentDir = getAgentDir(); // While a migration is running, cancel session lifecycle operations and // swallow user input so no session file can be written into a directory that // is about to be renamed or merged. let migrating = false; const cancelWhileMigrating = async (): Promise< { cancel: true } | undefined > => (migrating ? { cancel: true } : undefined); pi.on("session_before_switch", cancelWhileMigrating); pi.on("session_before_fork", cancelWhileMigrating); pi.on("session_before_tree", cancelWhileMigrating); pi.on("session_before_compact", cancelWhileMigrating); pi.on("input", async (_event, ctx) => { if (!migrating) { return undefined; } ctx.ui.notify( "pi-portable-sessions: migration in progress — input ignored, please retry", "warning", ); return { action: "handled" as const }; }); pi.on("session_start", async (event, ctx) => { const { config } = await loadConfig(agentDir, ctx.cwd); const sessionsRoot = await getSessionsRoot(agentDir, ctx.cwd); const portableRoot = resolvePortableRoot( config, join(agentDir, "portable-sessions"), ); // Whenever a session starts, make sure the current project's directory is // bridged: Pi's default `----` directory becomes a symlink into a // `portable-sessions/` directory. Idempotent — already // bridged projects (default path is a symlink) are left untouched. migrating = true; try { await migrateSessionDir(ctx.cwd, config, { sessionsRoot, portableRoot, }); } finally { migrating = false; } // The hint belongs to Pi startup only. Whenever a session is entered or // switched afterwards (resume/new/fork/reload), or when notifications are // disabled, clear any widget that may still be showing. const clearWidget = () => ctx.ui.setWidget("pi-portable-sessions", undefined); if (!config.notifyOnStart || event.reason !== "startup") { clearWidget(); return; } const pending = await findPendingMigrations(config, { sessionsRoot }); if (pending.length === 0) { clearWidget(); return; } const hasCurrent = pending.some((item) => item.cwd === ctx.cwd); const shown = pending.slice(0, 10); const width = Math.max(...shown.map((item) => item.defaultDirName.length)); ctx.ui.setWidget("pi-portable-sessions", (_tui, theme) => { const lines = [ theme.fg( "accent", `📁 pi-portable-sessions: ${pending.length} session director${pending.length === 1 ? "y" : "ies"} can be migrated`, ), "", ...shown.map((item) => { const from = item.defaultDirName.padEnd(width); return ` ${from} ${theme.fg("success", "→")} ${item.portableName}`; }), ]; if (pending.length > 10) { lines.push(` … and ${pending.length - 10} more`); } lines.push(""); lines.push( theme.fg( "dim", hasCurrent ? "Run /portable-sessions migrate to migrate the current project." : "Run /portable-sessions migrate --all to migrate all of them.", ), ); return new Text(lines.join("\n"), 0, 0); }); }); pi.registerCommand("portable-sessions", { description: "Manage portable session directories. Subcommands: status, migrate.", getArgumentCompletions: (prefix) => { const candidates = [ "status", "migrate", "migrate --all", "migrate --dry-run", "migrate --yes", "migrate --", ]; return candidates .filter((candidate) => candidate.startsWith(prefix)) .map((value) => ({ value, label: value })); }, handler: async (args, ctx) => { const [subcommand, ...rest] = args.trim().split(/\s+/); if (subcommand === "status" || subcommand === "") { await showStatusInfo(ctx); return; } if (subcommand === "migrate") { await runMigrate(rest, ctx); return; } ctx.ui.notify(USAGE, "info"); }, }); async function showStatusInfo(ctx: ExtensionCommandContext): Promise { const cwd = ctx.cwd; const { config, warnings } = await loadConfig(agentDir, cwd); const options = toPortableNameOptions(config); const sessionsRoot = await getSessionsRoot(agentDir, cwd); const portableRoot = resolvePortableRoot( config, join(agentDir, "portable-sessions"), ); const defaultDir = join(sessionsRoot, defaultSessionDirName(cwd)); const portableName = portableSessionDirName(cwd, options); const portableDir = join(portableRoot, portableName); let defaultState = "does not exist"; try { const info = await stat(defaultDir); defaultState = info.isSymbolicLink() ? "symlink" : info.isDirectory() ? "directory" : "other"; } catch { // Keep "does not exist". } let portableState = "does not exist"; try { const info = await stat(portableDir); portableState = info.isDirectory() ? "directory" : "other"; } catch { // Keep "does not exist". } const lines = [ `Working directory : ${cwd}`, `Portable name : ${portableName}`, `Default directory : ${defaultDir} (${defaultState})`, `Portable directory: ${portableDir} (${portableState})`, "", "Tip: run /portable-sessions migrate to rename the session directory", "to the portable name and leave a symlink at Pi's default path.", ]; for (const warning of warnings) { lines.push(`\nWarning: ${warning}`); } ctx.ui.notify(lines.join("\n"), "info"); } async function runMigrate( args: string[], ctx: ExtensionCommandContext, ): Promise { const flags = parseMigrateFlags(args); const { config, warnings } = await loadConfig(agentDir, ctx.cwd); const sessionsRoot = await getSessionsRoot(agentDir, ctx.cwd); const portableRoot = resolvePortableRoot( config, join(agentDir, "portable-sessions"), ); // Positional arguments name specific session directories to migrate: // either absolute working directories or directory names under the // sessions root (default `----` or portable names). const targets = args.filter((arg) => !arg.startsWith("--")); const onJsonlConflict: JsonlConflictHandler = (source, target) => mergeJsonlWithModel(source, target, { exec: (command, args, options) => pi.exec(command, args, options), }); const runTargets = async (opts: { dryRun?: boolean; }): Promise => { const migrateOpts = { ...opts, sessionsRoot, portableRoot, onJsonlConflict, }; if (flags.all) { return migrateAllSessionDirs(config, migrateOpts); } if (targets.length > 0) { return migrateNamedSessionDirs(targets, config, migrateOpts); } return [await migrateSessionDir(ctx.cwd, config, migrateOpts)]; }; // Plan first (dry run): decide what would change before asking. const plan = await runTargets({ dryRun: true }); const pending = plan.filter((result) => result.state === "would-migrate"); if (pending.length === 0) { ctx.ui.notify(`${summarize(plan, true)}\nNothing to migrate.`, "info"); return; } if (flags.dryRun) { ctx.ui.notify(summarize(plan, true), "info"); return; } const results: MigrationResult[] = []; if (ctx.hasUI && !flags.yes) { // Walk through each pending migration one at a time. for (const item of pending) { const from = basename(item.defaultDir); const to = item.portableName; const ok = await ctx.ui.confirm( "Migrate session directory?", ` ${from} → ${to}`, ); if (!ok) { ctx.ui.notify( `Cancelled after ${results.length} director${results.length === 1 ? "y" : "ies"} migrated.`, "info", ); break; } migrating = true; let result: MigrationResult; try { result = await migrateSessionDir(item.cwd, config, { sessionsRoot, portableRoot, onJsonlConflict, }); } finally { migrating = false; } results.push(result); } } else if (flags.yes) { // Non-interactive: migrate everything at once, keep the symlink bridges. migrating = true; try { results.push(...(await runTargets({}))); } finally { migrating = false; } } else { ctx.ui.notify( "Confirmation requires TUI mode; pass --yes to run without confirmation.", "warning", ); return; } // The startup widget may be stale now; clear it. ctx.ui.setWidget("pi-portable-sessions", undefined); const lines = [summarize(results, false)]; for (const warning of warnings) { lines.push(`Warning: ${warning}`); } ctx.ui.notify(lines.join("\n"), "info"); } } export { type ConfigWarnings, DEFAULT_CONFIG, getSessionsRoot, loadConfig, normalizeConfig, type PortableSessionsConfig, toPortableNameOptions, } from "./config"; export { EXTENSION_ID, getGlobalConfigDir, getGlobalConfigPath, getProjectConfigPath, } from "./config-paths"; export { defaultSessionDirName, findPendingMigrations, type JsonlConflictHandler, type MigrateOptions, type MigrationResult, type MigrationState, migrateAllSessionDirs, migrateNamedSessionDirs, migrateSessionDir, type PendingMigration, } from "./migrate"; export { type DecodedPortableName, decodePortableSessionDirName, type PortableNameOptions, portableSessionDirName, portableSessionDirNameToAbsolute, toPosixAbsolute, } from "./portable-name";