fix(coding-agent): handle missing session cwd

This commit is contained in:
Mario Zechner
2026-04-05 21:24:15 +02:00
parent 127547f2b3
commit 080af6fc06
6 changed files with 259 additions and 12 deletions

View File

@@ -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<AgentSessionRuntime> {
assertSessionCwdExists(options.sessionManager, options.cwd);
const result = await createRuntime(options);
if (process.cwd() !== result.services.cwd) {
process.chdir(result.services.cwd);

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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<string | undefined> {
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);

View File

@@ -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<string | undefined> {
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);
}
}

View File

@@ -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);
});
});