diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8438f0fc3..aeeb8a157 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,16 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Changed `SessionManager.create()` to require explicit `sessionDir` parameter instead of optional—callers must now pass `SessionManager.getDefaultSessionDir(cwd)` to use default behavior +- Changed `SessionManager.continueRecent()` to require explicit `sessionDir` parameter instead of optional—callers must now pass `SessionManager.getDefaultSessionDir(cwd)` to use default behavior +- Changed `SessionManager.forkFrom()` to require explicit `sessionDir` parameter instead of optional—callers must now pass `SessionManager.getDefaultSessionDir(cwd)` to use default behavior +- Changed `SessionManager.list()` signature to accept only `sessionDir` parameter instead of `cwd` and optional `sessionDir`—callers must now compute and pass the session directory explicitly ### Added +- Added `SessionManager.getDefaultSessionDir()` static method to explicitly resolve the canonical default session directory for a working directory - Added support for quoted paths in grep, ast_grep, and find tools to handle directory names with spaces - Added `normalizePathLikeInput` utility function to consistently handle quoted and whitespace-trimmed path inputs @@ -14,6 +21,7 @@ ### Fixed +- Fixed SDK-created default sessions to honor the configured `agentDir` for session storage, preventing tests from writing stray session directories into the real `~/.omp/agent/sessions` root - Fixed session directory resolution to correctly handle symlink-equivalent paths, ensuring aliased home and temp directories resolve to the same session storage location as their real targets ## [13.12.7] - 2026-03-16 diff --git a/packages/coding-agent/examples/sdk/11-sessions.ts b/packages/coding-agent/examples/sdk/11-sessions.ts index 17c9c0c19..3249bd9e8 100644 --- a/packages/coding-agent/examples/sdk/11-sessions.ts +++ b/packages/coding-agent/examples/sdk/11-sessions.ts @@ -13,19 +13,19 @@ console.log("In-memory session:", inMemory.sessionFile ?? "(none)"); // New persistent session const { session: newSession } = await createAgentSession({ - sessionManager: SessionManager.create(process.cwd()), + sessionManager: SessionManager.create(process.cwd(), SessionManager.getDefaultSessionDir(process.cwd())), }); console.log("New session file:", newSession.sessionFile); // Continue most recent session (or create new if none) const { session: continued, modelFallbackMessage } = await createAgentSession({ - sessionManager: await SessionManager.continueRecent(process.cwd()), + sessionManager: await SessionManager.continueRecent(process.cwd(), SessionManager.getDefaultSessionDir(process.cwd())), }); if (modelFallbackMessage) console.log("Note:", modelFallbackMessage); console.log("Continued session:", continued.sessionFile); // List and open specific session -const sessions = await SessionManager.list(process.cwd()); +const sessions = await SessionManager.list(SessionManager.getDefaultSessionDir(process.cwd())); console.log(`\nFound ${sessions.length} sessions:`); for (const info of sessions.slice(0, 3)) { console.log(` ${info.id.slice(0, 8)}… - "${info.firstMessage.slice(0, 30)}…"`); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index cfe6577ad..8e2c13893 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -233,6 +233,7 @@ async function createSessionManager(parsed: Args, cwd: string): Promise - SessionManager.list(cwd, parsedArgs.sessionDir), + SessionManager.list(parsedArgs.sessionDir ?? SessionManager.getDefaultSessionDir(cwd)), ); if (sessions.length === 0) { process.stdout.write(`${chalk.dim("No sessions found")}\n`); diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index b409c21e4..75b7fb7a2 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -191,7 +191,7 @@ export interface CreateAgentSessionOptions { /** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */ parentTaskPrefix?: string; - /** Session manager. Default: SessionManager.create(cwd) */ + /** Session manager. Default: SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd)) */ sessionManager?: SessionManager; /** Settings instance. Default: Settings.init({ cwd, agentDir }) */ @@ -656,7 +656,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} setPreferredImageProvider(imageProvider); } - const sessionManager = options.sessionManager ?? logger.time("sessionManager", SessionManager.create, cwd); + const sessionManager = + options.sessionManager ?? + logger.time("sessionManager", () => SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd))); const sessionId = sessionManager.getSessionId(); const modelApiKeyAvailability = new Map(); const getModelAvailabilityKey = (candidate: Model): string => diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 89168a5eb..9e408739a 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -630,7 +630,7 @@ export function buildSessionContext( * Classifies cwd by canonical location so symlink/alias paths resolve to the * same home-relative or temp-root directory names as their real targets. */ -function getDefaultSessionDir(cwd: string, storage: SessionStorage): string { +function computeDefaultSessionDir(cwd: string, storage: SessionStorage): string { const resolvedCwd = path.resolve(cwd); const canonicalCwd = resolveEquivalentPath(resolvedCwd); const home = resolveEquivalentPath(os.homedir()); @@ -1310,7 +1310,8 @@ export async function resolveResumableSession( sessionDir?: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { - const localSessions = await SessionManager.list(cwd, sessionDir, storage); + const localSessionDir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, storage); + const localSessions = await SessionManager.list(localSessionDir, storage); const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg)); if (localMatch) { return { session: localMatch, scope: "local" }; @@ -1477,7 +1478,7 @@ export class SessionManager { const resolvedCwd = path.resolve(newCwd); if (resolvedCwd === this.cwd) return; - const newSessionDir = getDefaultSessionDir(resolvedCwd, this.storage); + const newSessionDir = computeDefaultSessionDir(resolvedCwd, this.storage); let hadSessionFile = false; if (this.persist && this.#sessionFile) { @@ -2434,14 +2435,21 @@ export class SessionManager { return undefined; } + /** + * Resolve the canonical default session directory for a cwd. + * Callers must opt in explicitly instead of silently falling back to the global agent root. + */ + static getDefaultSessionDir(cwd: string, storage: SessionStorage = new FileSessionStorage()): string { + return computeDefaultSessionDir(cwd, storage); + } + /** * Create a new session. * @param cwd Working directory (stored in session header) - * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions//). + * @param sessionDir Explicit session directory for persistence. */ - static create(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionManager { - const dir = sessionDir ?? getDefaultSessionDir(cwd, storage); - const manager = new SessionManager(cwd, dir, true, storage); + static create(cwd: string, sessionDir: string, storage: SessionStorage = new FileSessionStorage()): SessionManager { + const manager = new SessionManager(cwd, sessionDir, true, storage); manager.#initNewSession(); return manager; } @@ -2453,11 +2461,10 @@ export class SessionManager { static async forkFrom( sourcePath: string, cwd: string, - sessionDir?: string, + sessionDir: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { - const dir = sessionDir ?? getDefaultSessionDir(cwd, storage); - const manager = new SessionManager(cwd, dir, true, storage); + const manager = new SessionManager(cwd, sessionDir, true, storage); const forkEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[]; migrateToCurrentVersion(forkEntries); await resolveBlobRefsInEntries(forkEntries, manager.#blobStore); @@ -2497,18 +2504,17 @@ export class SessionManager { /** * Continue the most recent session, or create new if none. * @param cwd Working directory - * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions//). + * @param sessionDir Explicit session directory for persistence. */ static async continueRecent( cwd: string, - sessionDir?: string, + sessionDir: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { - const dir = sessionDir ?? getDefaultSessionDir(cwd, storage); // Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly) const terminalSession = await readTerminalBreadcrumb(cwd); - const mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage)); - const manager = new SessionManager(cwd, dir, true, storage); + const mostRecent = terminalSession ?? (await findMostRecentSession(sessionDir, storage)); + const manager = new SessionManager(cwd, sessionDir, true, storage); if (mostRecent) { await manager.#initSessionFile(mostRecent); } else { @@ -2528,18 +2534,14 @@ export class SessionManager { } /** - * List all sessions. - * @param cwd Working directory (used to compute default session directory) - * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions//). + * List all sessions in an explicit session directory. */ static async list( - cwd: string, - sessionDir?: string, + sessionDir: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { - const dir = sessionDir ?? getDefaultSessionDir(cwd, storage); try { - const files = storage.listFilesSync(dir, "*.jsonl"); + const files = storage.listFilesSync(sessionDir, "*.jsonl"); return await collectSessionsFromFiles(files, storage); } catch { return []; diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index 9273a03a1..d55e0dd13 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -247,7 +247,7 @@ describe("SessionManager temp cwd session dirs", () => { fs.symlinkSync(os.homedir(), homeAlias, "dir"); const aliasedCwd = path.join(homeAlias, "Projects", path.basename(realProjectDir), "nested"); - const session = SessionManager.create(aliasedCwd); + const session = SessionManager.create(aliasedCwd, SessionManager.getDefaultSessionDir(aliasedCwd)); const sessionFile = session.getSessionFile(); if (!sessionFile) throw new Error("Expected session file path"); @@ -266,7 +266,7 @@ describe("SessionManager temp cwd session dirs", () => { const tempCwd = path.join(testAgentDir, `temp-cwd-${Snowflake.next()}`); fs.mkdirSync(tempCwd, { recursive: true }); - const session = SessionManager.create(tempCwd); + const session = SessionManager.create(tempCwd, SessionManager.getDefaultSessionDir(tempCwd)); const sessionFile = session.getSessionFile(); if (!sessionFile) throw new Error("Expected session file path"); @@ -282,7 +282,7 @@ describe("SessionManager temp cwd session dirs", () => { fs.mkdirSync(legacyDir, { recursive: true }); fs.writeFileSync(markerFile, "marker\n"); - const session = SessionManager.create(tempCwd); + const session = SessionManager.create(tempCwd, SessionManager.getDefaultSessionDir(tempCwd)); const sessionFile = session.getSessionFile(); if (!sessionFile) throw new Error("Expected session file path"); diff --git a/packages/coding-agent/test/session-manager/move-to.test.ts b/packages/coding-agent/test/session-manager/move-to.test.ts index 7c776e9cd..1f0fbf9a8 100644 --- a/packages/coding-agent/test/session-manager/move-to.test.ts +++ b/packages/coding-agent/test/session-manager/move-to.test.ts @@ -106,7 +106,7 @@ describe("SessionManager.moveTo", () => { }); it("moves session file and updates header cwd (baseline)", async () => { - const session = SessionManager.create(cwdA); + const session = SessionManager.create(cwdA, SessionManager.getDefaultSessionDir(cwdA)); session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); session.appendMessage(makeAssistantMessage()); await session.flush(); @@ -130,7 +130,7 @@ describe("SessionManager.moveTo", () => { }); it("succeeds on fresh session without ENOENT, then deferred persistence works", async () => { - const session = SessionManager.create(cwdA); + const session = SessionManager.create(cwdA, SessionManager.getDefaultSessionDir(cwdA)); // No messages — file never written to disk const oldFile = session.getSessionFile()!; expect(fs.existsSync(oldFile)).toBe(false); @@ -154,7 +154,7 @@ describe("SessionManager.moveTo", () => { }); it("recreates file from memory when old file is deleted (assistant exists)", async () => { - const session = SessionManager.create(cwdA); + const session = SessionManager.create(cwdA, SessionManager.getDefaultSessionDir(cwdA)); session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); session.appendMessage(makeAssistantMessage()); await session.flush(); @@ -223,7 +223,7 @@ describe("SessionManager.moveTo", () => { }); it("moves artifact dir independently when session file does not exist", async () => { - const session = SessionManager.create(cwdA); + const session = SessionManager.create(cwdA, SessionManager.getDefaultSessionDir(cwdA)); // Allocate an artifact — creates dir via ArtifactManager const { path: artifactPath } = await session.allocateArtifactPath("bash"); if (!artifactPath) throw new Error("Expected artifact path");