diff --git a/packages/coding-agent/src/core/session-cwd.ts b/packages/coding-agent/src/core/session-cwd.ts index 79960df1f..9fe3bca54 100644 --- a/packages/coding-agent/src/core/session-cwd.ts +++ b/packages/coding-agent/src/core/session-cwd.ts @@ -1,4 +1,7 @@ -import { existsSync } from "node:fs"; +import { closeSync, existsSync, fsyncSync, openSync, readSync, renameSync, rmSync, statSync, writeSync } from "node:fs"; +import { APP_NAME } from "../config.ts"; +import { resolvePath } from "../utils/paths.ts"; +import type { SessionHeader } from "./session-manager.ts"; export interface SessionCwdIssue { sessionFile?: string; @@ -57,3 +60,105 @@ export function assertSessionCwdExists(sessionManager: SessionCwdSource, fallbac throw new MissingSessionCwdError(issue); } } + +const SESSION_CWD_COPY_BUFFER_SIZE = 1024 * 1024; + +interface LocatedSessionHeader { + header: SessionHeader; + /** Byte offset where the header line starts. */ + start: number; + /** Byte offset just past the header line, including its newline. */ + end: number; +} + +function parseSessionHeaderCandidate(line: string): SessionHeader | null | undefined { + if (!line.trim()) return undefined; + try { + const entry = JSON.parse(line) as SessionHeader; + if (entry.type !== "session" || typeof entry.id !== "string") return null; + return entry; + } catch { + return undefined; + } +} + +/** + * Locate the session header line, matching loadEntriesFromFile(): blank and malformed + * leading lines are skipped and the first parseable entry must be the session header. + */ +function locateSessionHeader(fd: number): LocatedSessionHeader | null { + const buffer = Buffer.allocUnsafe(SESSION_CWD_COPY_BUFFER_SIZE); + const pending: Buffer[] = []; + let lineStart = 0; + let offset = 0; + + while (true) { + const bytesRead = readSync(fd, buffer, 0, buffer.length, offset); + if (bytesRead === 0) { + if (pending.length === 0) return null; + const header = parseSessionHeaderCandidate(Buffer.concat(pending).toString("utf8")); + return header ? { header, start: lineStart, end: offset } : null; + } + + let chunkLineStart = 0; + for (let i = 0; i < bytesRead; i++) { + if (buffer[i] !== 0x0a) continue; + pending.push(Buffer.from(buffer.subarray(chunkLineStart, i))); + const header = parseSessionHeaderCandidate(Buffer.concat(pending).toString("utf8")); + pending.length = 0; + if (header === null) return null; + if (header) return { header, start: lineStart, end: offset + i + 1 }; + lineStart = offset + i + 1; + chunkLineStart = i + 1; + } + if (chunkLineStart < bytesRead) { + pending.push(Buffer.from(buffer.subarray(chunkLineStart, bytesRead))); + } + offset += bytesRead; + } +} + +function copyFileRange(srcFd: number, dstFd: number, start: number, end?: number): void { + const buffer = Buffer.allocUnsafe(SESSION_CWD_COPY_BUFFER_SIZE); + let position = start; + while (end === undefined || position < end) { + const length = end === undefined ? buffer.length : Math.min(buffer.length, end - position); + const bytesRead = readSync(srcFd, buffer, 0, length, position); + if (bytesRead === 0) break; + writeSync(dstFd, buffer, 0, bytesRead); + position += bytesRead; + } +} + +/** + * Store a new working directory in a session file's header. Every other byte is kept + * unchanged: the header line is replaced in a temporary copy that is then renamed over + * the original, so a crash cannot leave a truncated session behind. + */ +export function updateSessionFileCwd(sessionFile: string, cwd: string): void { + const resolvedFile = resolvePath(sessionFile); + const tempFile = `${resolvedFile}.${process.pid}.tmp`; + const srcFd = openSync(resolvedFile, "r"); + let renamed = false; + try { + const located = locateSessionHeader(srcFd); + if (!located) { + throw new Error(`Session file is not a valid ${APP_NAME} session: ${resolvedFile}`); + } + const headerLine = Buffer.from(`${JSON.stringify({ ...located.header, cwd })}\n`, "utf8"); + const tmpFd = openSync(tempFile, "w", statSync(resolvedFile).mode); + try { + copyFileRange(srcFd, tmpFd, 0, located.start); + writeSync(tmpFd, headerLine, 0, headerLine.length); + copyFileRange(srcFd, tmpFd, located.end); + fsyncSync(tmpFd); + } finally { + closeSync(tmpFd); + } + renameSync(tempFile, resolvedFile); + renamed = true; + } finally { + closeSync(srcFd); + if (!renamed) rmSync(tempFile, { force: true }); + } +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 723fc910b..04ccff35d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -55,6 +55,7 @@ import { getMissingSessionCwdIssue, MissingSessionCwdError, type SessionCwdIssue, + updateSessionFileCwd, } from "./core/session-cwd.ts"; import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; import { collectSettingsDiagnostics, deduplicateDiagnostics } from "./core/settings-diagnostics.ts"; @@ -701,6 +702,12 @@ export async function main(args: string[], options?: MainOptions) { if (!selectedCwd) { process.exit(0); } + try { + updateSessionFileCwd(missingSessionCwdIssue.sessionFile!, selectedCwd); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error(chalk.yellow(`Warning: could not save the session cwd: ${message}`)); + } sessionManager = SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd); } else { console.error(chalk.red(new MissingSessionCwdError(missingSessionCwdIssue).message)); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f6b0f7a58..e3b0f460c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -106,7 +106,7 @@ import { CredentialSynchronizationError } from "../../core/model-runtime.ts"; import { DefaultPackageManager } from "../../core/package-manager.ts"; import { RADIUS_MCP_URL, RADIUS_PROVIDER_ID } from "../../core/radius.ts"; import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; -import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; +import { formatMissingSessionCwdPrompt, MissingSessionCwdError, updateSessionFileCwd } from "../../core/session-cwd.ts"; import { type SessionEntry, SessionManager, @@ -5771,6 +5771,11 @@ export class InteractiveMode { this.showStatus("Resume cancelled"); return { cancelled: true }; } + try { + updateSessionFileCwd(error.issue.sessionFile ?? sessionPath, selectedCwd); + } catch { + this.showWarning("Could not save the session working directory; pi will ask again on the next resume."); + } const result = await this.runtimeHost.switchSession(sessionPath, { cwdOverride: selectedCwd, withSession: options?.withSession, @@ -6598,6 +6603,16 @@ export class InteractiveMode { this.showStatus("Import cancelled"); return; } + const importedFile = this.sessionManager.getSessionFile(); + if (importedFile) { + try { + updateSessionFileCwd(importedFile, selectedCwd); + } catch { + this.showWarning( + "Could not save the session working directory; pi will ask again on the next resume.", + ); + } + } this.showStatus(`Session imported from: ${inputPath}`); return; } diff --git a/packages/coding-agent/test/session-cwd.test.ts b/packages/coding-agent/test/session-cwd.test.ts index 8a7d23fca..670cb2a1e 100644 --- a/packages/coding-agent/test/session-cwd.test.ts +++ b/packages/coding-agent/test/session-cwd.test.ts @@ -1,10 +1,10 @@ -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, 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.ts"; -import { getMissingSessionCwdIssue, MissingSessionCwdError } from "../src/core/session-cwd.ts"; -import { SessionManager } from "../src/core/session-manager.ts"; +import { getMissingSessionCwdIssue, MissingSessionCwdError, updateSessionFileCwd } from "../src/core/session-cwd.ts"; +import { loadEntriesFromFile, SessionManager } from "../src/core/session-manager.ts"; function createTempDir(name: string): string { const dir = join(tmpdir(), `${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -88,4 +88,65 @@ describe("session cwd handling", () => { ).rejects.toBeInstanceOf(MissingSessionCwdError); expect(createRuntimeCalled).toBe(false); }); + + // #11060: once the user picks a folder for a moved session, later resumes must not ask again + it("persists the chosen cwd in the session file header", () => { + const fallbackCwd = createTempDir("pi-session-cwd-save"); + const missingCwd = join(fallbackCwd, "does-not-exist"); + const sessionDir = createTempDir("pi-session-cwd-save-session-dir"); + const sessionFile = join(sessionDir, "session.jsonl"); + cleanupPaths.push(fallbackCwd, sessionDir); + writeSessionFile(sessionFile, missingCwd); + + updateSessionFileCwd(sessionFile, fallbackCwd); + + const sessionManager = SessionManager.open(sessionFile); + expect(sessionManager.getCwd()).toBe(fallbackCwd); + expect(getMissingSessionCwdIssue(sessionManager, fallbackCwd)).toBeUndefined(); + }); + + it("keeps leading non-header lines and later entries byte-identical", () => { + const fallbackCwd = createTempDir("pi-session-cwd-entries"); + const missingCwd = join(fallbackCwd, "does-not-exist"); + const sessionDir = createTempDir("pi-session-cwd-entries-session-dir"); + const sessionFile = join(sessionDir, "session.jsonl"); + cleanupPaths.push(fallbackCwd, sessionDir); + + const messageLine = + '{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}'; + writeFileSync( + sessionFile, + `junk line\n\n${JSON.stringify({ + type: "session", + version: 3, + id: "session-id", + timestamp: "2025-01-01T00:00:00Z", + cwd: missingCwd, + })}\n${messageLine}\n`, + ); + + updateSessionFileCwd(sessionFile, fallbackCwd); + + expect(readFileSync(sessionFile, "utf8")).toBe( + `junk line\n\n${JSON.stringify({ + type: "session", + version: 3, + id: "session-id", + timestamp: "2025-01-01T00:00:00Z", + cwd: fallbackCwd, + })}\n${messageLine}\n`, + ); + expect(loadEntriesFromFile(sessionFile)).toHaveLength(2); + }); + + it("rejects files whose first entry is not a session header", () => { + const sessionDir = createTempDir("pi-session-cwd-invalid"); + const sessionFile = join(sessionDir, "not-a-session.jsonl"); + cleanupPaths.push(sessionDir); + writeFileSync(sessionFile, '{"type":"message","id":"1"}\n'); + + expect(() => updateSessionFileCwd(sessionFile, sessionDir)).toThrow("not a valid"); + expect(readFileSync(sessionFile, "utf8")).toBe('{"type":"message","id":"1"}\n'); + expect(existsSync(`${sessionFile}.${process.pid}.tmp`)).toBe(false); + }); });