From 080af6fc062f41be2d0a4862a741d54ed4bc130a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 5 Apr 2026 21:24:15 +0200 Subject: [PATCH] fix(coding-agent): handle missing session cwd --- .../src/core/agent-session-runtime.ts | 12 ++- packages/coding-agent/src/core/session-cwd.ts | 59 ++++++++++++ .../coding-agent/src/core/session-manager.ts | 5 +- packages/coding-agent/src/main.ts | 64 +++++++++++-- .../src/modes/interactive/interactive-mode.ts | 40 ++++++++ .../coding-agent/test/session-cwd.test.ts | 91 +++++++++++++++++++ 6 files changed, 259 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/src/core/session-cwd.ts create mode 100644 packages/coding-agent/test/session-cwd.test.ts diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index 7e064ea8..58e1fd57 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -5,6 +5,7 @@ import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agen import type { SessionStartEvent } from "./extensions/index.js"; import { emitSessionShutdownEvent } from "./extensions/runner.js"; import type { CreateAgentSessionResult } from "./sdk.js"; +import { assertSessionCwdExists } from "./session-cwd.js"; import { SessionManager } from "./session-manager.js"; /** @@ -124,14 +125,15 @@ export class AgentSessionRuntime { this._modelFallbackMessage = result.modelFallbackMessage; } - async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> { + async switchSession(sessionPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { const beforeResult = await this.emitBeforeSwitch("resume", sessionPath); if (beforeResult.cancelled) { return beforeResult; } const previousSessionFile = this.session.sessionFile; - const sessionManager = SessionManager.open(sessionPath); + const sessionManager = SessionManager.open(sessionPath, undefined, cwdOverride); + assertSessionCwdExists(sessionManager, this.cwd); await this.teardownCurrent(); this.apply( await this.createRuntime({ @@ -246,7 +248,7 @@ export class AgentSessionRuntime { return { cancelled: false, selectedText }; } - async importFromJsonl(inputPath: string): Promise<{ cancelled: boolean }> { + async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { const resolvedPath = resolve(inputPath); if (!existsSync(resolvedPath)) { throw new Error(`File not found: ${resolvedPath}`); @@ -268,7 +270,8 @@ export class AgentSessionRuntime { copyFileSync(resolvedPath, destinationPath); } - const sessionManager = SessionManager.open(destinationPath, sessionDir); + const sessionManager = SessionManager.open(destinationPath, sessionDir, cwdOverride); + assertSessionCwdExists(sessionManager, this.cwd); await this.teardownCurrent(); this.apply( await this.createRuntime({ @@ -302,6 +305,7 @@ export async function createAgentSessionRuntime( sessionStartEvent?: SessionStartEvent; }, ): Promise { + assertSessionCwdExists(options.sessionManager, options.cwd); const result = await createRuntime(options); if (process.cwd() !== result.services.cwd) { process.chdir(result.services.cwd); diff --git a/packages/coding-agent/src/core/session-cwd.ts b/packages/coding-agent/src/core/session-cwd.ts new file mode 100644 index 00000000..79960df1 --- /dev/null +++ b/packages/coding-agent/src/core/session-cwd.ts @@ -0,0 +1,59 @@ +import { existsSync } from "node:fs"; + +export interface SessionCwdIssue { + sessionFile?: string; + sessionCwd: string; + fallbackCwd: string; +} + +interface SessionCwdSource { + getCwd(): string; + getSessionFile(): string | undefined; +} + +export function getMissingSessionCwdIssue( + sessionManager: SessionCwdSource, + fallbackCwd: string, +): SessionCwdIssue | undefined { + const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile) { + return undefined; + } + + const sessionCwd = sessionManager.getCwd(); + if (!sessionCwd || existsSync(sessionCwd)) { + return undefined; + } + + return { + sessionFile, + sessionCwd, + fallbackCwd, + }; +} + +export function formatMissingSessionCwdError(issue: SessionCwdIssue): string { + const sessionFile = issue.sessionFile ? `\nSession file: ${issue.sessionFile}` : ""; + return `Stored session working directory does not exist: ${issue.sessionCwd}${sessionFile}\nCurrent working directory: ${issue.fallbackCwd}`; +} + +export function formatMissingSessionCwdPrompt(issue: SessionCwdIssue): string { + return `cwd from session file does not exist\n${issue.sessionCwd}\n\ncontinue in current cwd\n${issue.fallbackCwd}`; +} + +export class MissingSessionCwdError extends Error { + readonly issue: SessionCwdIssue; + + constructor(issue: SessionCwdIssue) { + super(formatMissingSessionCwdError(issue)); + this.name = "MissingSessionCwdError"; + this.issue = issue; + } +} + +export function assertSessionCwdExists(sessionManager: SessionCwdSource, fallbackCwd: string): void { + const issue = getMissingSessionCwdIssue(sessionManager, fallbackCwd); + if (issue) { + throw new MissingSessionCwdError(issue); + } +} diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 87890003..926f7809 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1270,12 +1270,13 @@ export class SessionManager { * Open a specific session file. * @param path Path to session file * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent. + * @param cwdOverride Optional cwd override instead of the session header cwd. */ - static open(path: string, sessionDir?: string): SessionManager { + static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager { // Extract cwd from session header if possible, otherwise use process.cwd() const entries = loadEntriesFromFile(path); const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; - const cwd = header?.cwd ?? process.cwd(); + const cwd = cwdOverride ?? header?.cwd ?? process.cwd(); // If no sessionDir provided, derive from file's parent directory const dir = sessionDir ?? resolve(path, ".."); return new SessionManager(cwd, dir, path, true); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a066ff24..ec0e5788 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -7,6 +7,7 @@ import { resolve } from "node:path"; import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; +import { ProcessTerminal, setKeybindings, TUI } from "@mariozechner/pi-tui"; import chalk from "chalk"; import { createInterface } from "readline"; import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.js"; @@ -23,16 +24,24 @@ import { } from "./core/agent-session-services.js"; import { AuthStorage } from "./core/auth-storage.js"; import { exportFromFile } from "./core/export-html/index.js"; +import { KeybindingsManager } from "./core/keybindings.js"; import type { ModelRegistry } from "./core/model-registry.js"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; import { restoreStdout, takeOverStdout } from "./core/output-guard.js"; import type { CreateAgentSessionOptions } from "./core/sdk.js"; +import { + formatMissingSessionCwdPrompt, + getMissingSessionCwdIssue, + MissingSessionCwdError, + type SessionCwdIssue, +} from "./core/session-cwd.js"; import { SessionManager } from "./core/session-manager.js"; import { SettingsManager } from "./core/settings-manager.js"; import { printTimings, resetTimings, time } from "./core/timings.js"; import { allTools } from "./core/tools/index.js"; import { runMigrations, showDeprecationWarnings } from "./migrations.js"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js"; +import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.js"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js"; import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.js"; import { isLocalPath } from "./utils/paths.js"; @@ -375,6 +384,40 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u return paths?.map((value) => (isLocalPath(value) ? resolve(cwd, value) : value)); } +async function promptForMissingSessionCwd( + issue: SessionCwdIssue, + settingsManager: SettingsManager, +): Promise { + initTheme(settingsManager.getTheme()); + setKeybindings(KeybindingsManager.create()); + + return new Promise((resolve) => { + const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor()); + ui.setClearOnShrink(settingsManager.getClearOnShrink()); + + let settled = false; + const finish = (result: string | undefined) => { + if (settled) { + return; + } + settled = true; + ui.stop(); + resolve(result); + }; + + const selector = new ExtensionSelectorComponent( + formatMissingSessionCwdPrompt(issue), + ["Continue", "Cancel"], + (option) => finish(option === "Continue" ? issue.fallbackCwd : undefined), + () => finish(undefined), + { tui: ui }, + ); + ui.addChild(selector); + ui.setFocus(selector); + ui.start(); + }); +} + export async function main(args: string[]) { resetTimings(); const offlineMode = args.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE); @@ -448,12 +491,21 @@ export async function main(args: string[]) { // settings, resources, provider registrations, and models must be resolved only after // the target session cwd is known. The startup-cwd settings manager is used only for // sessionDir lookup during session selection. - const sessionManager = await createSessionManager( - parsed, - cwd, - parsed.sessionDir ?? startupSettingsManager.getSessionDir(), - startupSettingsManager, - ); + const sessionDir = parsed.sessionDir ?? startupSettingsManager.getSessionDir(); + let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager); + const missingSessionCwdIssue = getMissingSessionCwdIssue(sessionManager, cwd); + if (missingSessionCwdIssue) { + if (appMode === "interactive") { + const selectedCwd = await promptForMissingSessionCwd(missingSessionCwdIssue, startupSettingsManager); + if (!selectedCwd) { + process.exit(0); + } + sessionManager = SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd); + } else { + console.error(chalk.red(new MissingSessionCwdError(missingSessionCwdIssue).message)); + process.exit(1); + } + } time("createSessionManager"); const resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 2701429e..10dde29a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -61,6 +61,7 @@ import { createCompactionSummaryMessage } from "../../core/messages.js"; import { findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js"; import { DefaultPackageManager } from "../../core/package-manager.js"; import type { ResourceDiagnostic } from "../../core/resource-loader.js"; +import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js"; import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js"; import type { SourceInfo } from "../../core/source-info.js"; @@ -1719,6 +1720,14 @@ export class InteractiveMode { return result === "Yes"; } + private async promptForMissingSessionCwd(error: MissingSessionCwdError): Promise { + const confirmed = await this.showExtensionConfirm( + "Session cwd not found", + formatMissingSessionCwdPrompt(error.issue), + ); + return confirmed ? error.issue.fallbackCwd : undefined; + } + /** * Show a text input for extensions. */ @@ -3843,6 +3852,21 @@ export class InteractiveMode { this.renderCurrentSessionState(); this.showStatus("Resumed session"); } catch (error: unknown) { + if (error instanceof MissingSessionCwdError) { + const selectedCwd = await this.promptForMissingSessionCwd(error); + if (!selectedCwd) { + this.showStatus("Resume cancelled"); + return; + } + const result = await this.runtimeHost.switchSession(sessionPath, selectedCwd); + if (result.cancelled) { + return; + } + await this.handleRuntimeSessionChange(); + this.renderCurrentSessionState(); + this.showStatus("Resumed session in current cwd"); + return; + } await this.handleFatalRuntimeError("Failed to resume session", error); } } @@ -4109,6 +4133,22 @@ export class InteractiveMode { this.renderCurrentSessionState(); this.showStatus(`Session imported from: ${inputPath}`); } catch (error: unknown) { + if (error instanceof MissingSessionCwdError) { + const selectedCwd = await this.promptForMissingSessionCwd(error); + if (!selectedCwd) { + this.showStatus("Import cancelled"); + return; + } + const result = await this.runtimeHost.importFromJsonl(inputPath, selectedCwd); + if (result.cancelled) { + this.showStatus("Import cancelled"); + return; + } + await this.handleRuntimeSessionChange(); + this.renderCurrentSessionState(); + this.showStatus(`Session imported from: ${inputPath}`); + return; + } await this.handleFatalRuntimeError("Failed to import session", error); } } diff --git a/packages/coding-agent/test/session-cwd.test.ts b/packages/coding-agent/test/session-cwd.test.ts new file mode 100644 index 00000000..72ce6014 --- /dev/null +++ b/packages/coding-agent/test/session-cwd.test.ts @@ -0,0 +1,91 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "../src/core/agent-session-runtime.js"; +import { getMissingSessionCwdIssue, MissingSessionCwdError } from "../src/core/session-cwd.js"; +import { SessionManager } from "../src/core/session-manager.js"; + +function createTempDir(name: string): string { + const dir = join(tmpdir(), `${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeSessionFile(path: string, cwd: string): void { + writeFileSync( + path, + `${JSON.stringify({ + type: "session", + version: 3, + id: "session-id", + timestamp: new Date().toISOString(), + cwd, + })}\n`, + ); +} + +describe("session cwd handling", () => { + const cleanupPaths: string[] = []; + + afterEach(() => { + for (const path of cleanupPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } + }); + + it("detects missing session cwd from persisted sessions", () => { + const fallbackCwd = createTempDir("pi-session-cwd-fallback"); + const missingCwd = join(fallbackCwd, "does-not-exist"); + const sessionDir = createTempDir("pi-session-cwd-session-dir"); + const sessionFile = join(sessionDir, "session.jsonl"); + cleanupPaths.push(fallbackCwd, sessionDir); + writeSessionFile(sessionFile, missingCwd); + + const sessionManager = SessionManager.open(sessionFile); + const issue = getMissingSessionCwdIssue(sessionManager, fallbackCwd); + expect(issue).toEqual({ + sessionFile: sessionManager.getSessionFile(), + sessionCwd: missingCwd, + fallbackCwd, + }); + }); + + it("supports overriding the effective cwd when opening a session", () => { + const fallbackCwd = createTempDir("pi-session-cwd-override"); + const missingCwd = join(fallbackCwd, "does-not-exist"); + const sessionDir = createTempDir("pi-session-cwd-override-session-dir"); + const sessionFile = join(sessionDir, "session.jsonl"); + cleanupPaths.push(fallbackCwd, sessionDir); + writeSessionFile(sessionFile, missingCwd); + + const sessionManager = SessionManager.open(sessionFile, undefined, fallbackCwd); + expect(sessionManager.getCwd()).toBe(fallbackCwd); + expect(getMissingSessionCwdIssue(sessionManager, fallbackCwd)).toBeUndefined(); + }); + + it("throws a controlled error before runtime creation when the stored cwd is missing", async () => { + const fallbackCwd = createTempDir("pi-session-cwd-runtime"); + const missingCwd = join(fallbackCwd, "does-not-exist"); + const sessionDir = createTempDir("pi-session-cwd-runtime-session-dir"); + const sessionFile = join(sessionDir, "session.jsonl"); + cleanupPaths.push(fallbackCwd, sessionDir); + writeSessionFile(sessionFile, missingCwd); + + const sessionManager = SessionManager.open(sessionFile); + let createRuntimeCalled = false; + const createRuntime: CreateAgentSessionRuntimeFactory = async () => { + createRuntimeCalled = true; + throw new Error("should not be called"); + }; + + await expect( + createAgentSessionRuntime(createRuntime, { + cwd: fallbackCwd, + agentDir: fallbackCwd, + sessionManager, + }), + ).rejects.toBeInstanceOf(MissingSessionCwdError); + expect(createRuntimeCalled).toBe(false); + }); +});