diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2a861aee..b63404da 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- **SessionManager API**: The second parameter of `create()`, `continueRecent()`, and `list()` changed from `agentDir` to `sessionDir`. When provided, it specifies the session directory directly (no cwd encoding). When omitted, uses default (`~/.pi/agent/sessions//`). `open()` no longer takes `agentDir`. ([#313](https://github.com/badlogic/pi-mono/pull/313)) + +### Added + +- **`--session-dir` flag**: Use a custom directory for sessions instead of the default `~/.pi/agent/sessions//`. Works with `-c` (continue) and `-r` (resume) flags. ([#313](https://github.com/badlogic/pi-mono/pull/313) by [@scutifer](https://github.com/scutifer)) + ## [0.29.1] - 2025-12-25 ### Added diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 63fefa79..3b93d26b 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -768,6 +768,7 @@ pi [options] [@files...] [messages...] | `--print`, `-p` | Non-interactive: process prompt and exit | | `--no-session` | Don't save session | | `--session ` | Use specific session file | +| `--session-dir ` | Directory for session storage and lookup | | `--continue`, `-c` | Continue most recent session | | `--resume`, `-r` | Select session to resume | | `--models ` | Comma-separated patterns for Ctrl+P cycling (e.g., `sonnet:high,haiku:low`) | diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 72e3e6d1..1ae0548c 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -592,11 +592,14 @@ for (const info of sessions) { console.log(`${info.id}: ${info.firstMessage} (${info.messageCount} messages)`); } -// Custom agentDir for sessions +// Custom session directory (no cwd encoding) +const customDir = "/path/to/my-sessions"; const { session } = await createAgentSession({ - agentDir: "/custom/agent", - sessionManager: SessionManager.create(process.cwd(), "/custom/agent"), + sessionManager: SessionManager.create(process.cwd(), customDir), }); +// Also works with list and continueRecent: +// SessionManager.list(process.cwd(), customDir); +// SessionManager.continueRecent(process.cwd(), customDir); ``` > See [examples/sdk/11-sessions.ts](../examples/sdk/11-sessions.ts) diff --git a/packages/coding-agent/examples/sdk/11-sessions.ts b/packages/coding-agent/examples/sdk/11-sessions.ts index 8a7052db..7a883fb4 100644 --- a/packages/coding-agent/examples/sdk/11-sessions.ts +++ b/packages/coding-agent/examples/sdk/11-sessions.ts @@ -39,8 +39,10 @@ if (sessions.length > 0) { console.log(`\nOpened: ${opened.sessionId}`); } -// Custom session directory +// Custom session directory (no cwd encoding) +// const customDir = "/path/to/my-sessions"; // const { session } = await createAgentSession({ -// agentDir: "/custom/agent", -// sessionManager: SessionManager.create(process.cwd(), "/custom/agent"), +// sessionManager: SessionManager.create(process.cwd(), customDir), // }); +// SessionManager.list(process.cwd(), customDir); +// SessionManager.continueRecent(process.cwd(), customDir); diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index c846ee95..dda8b805 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -23,6 +23,7 @@ export interface Args { mode?: Mode; noSession?: boolean; session?: string; + sessionDir?: string; models?: string[]; tools?: ToolName[]; hooks?: string[]; @@ -78,6 +79,8 @@ export function parseArgs(args: string[]): Args { result.noSession = true; } else if (arg === "--session" && i + 1 < args.length) { result.session = args[++i]; + } else if (arg === "--session-dir" && i + 1 < args.length) { + result.sessionDir = args[++i]; } else if (arg === "--models" && i + 1 < args.length) { result.models = args[++i].split(",").map((s) => s.trim()); } else if (arg === "--tools" && i + 1 < args.length) { @@ -153,6 +156,7 @@ ${chalk.bold("Options:")} --continue, -c Continue previous session --resume, -r Select a session to resume --session Use specific session file + --session-dir Directory for session storage and lookup --no-session Don't save session (ephemeral) --models Comma-separated model patterns for quick cycling with Ctrl+P --tools Comma-separated list of tools to enable (default: read,bash,edit,write) diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index e4a80f71..dd118afd 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -171,9 +171,13 @@ export function buildSessionContext(entries: SessionEntry[]): SessionContext { return { messages, thinkingLevel, model }; } -function getSessionDirectory(cwd: string, agentDir: string): string { +/** + * Compute the default session directory for a cwd. + * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. + */ +function getDefaultSessionDir(cwd: string): string { const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - const sessionDir = join(agentDir, "sessions", safePath); + const sessionDir = join(getDefaultAgentDir(), "sessions", safePath); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } @@ -225,9 +229,12 @@ export class SessionManager { private flushed: boolean = false; private inMemoryEntries: SessionEntry[] = []; - private constructor(cwd: string, agentDir: string, sessionFile: string | null, persist: boolean) { + private constructor(cwd: string, sessionDir: string, sessionFile: string | null, persist: boolean) { this.cwd = cwd; - this.sessionDir = getSessionDirectory(cwd, agentDir); + this.sessionDir = sessionDir; + if (persist && sessionDir && !existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } this.persist = persist; if (sessionFile) { @@ -235,7 +242,7 @@ export class SessionManager { } else { this.sessionId = uuidv4(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); + const sessionFile = join(this.getSessionDir(), `${timestamp}_${this.sessionId}.jsonl`); this.setSessionFile(sessionFile); } } @@ -270,6 +277,10 @@ export class SessionManager { return this.cwd; } + getSessionDir(): string { + return this.sessionDir; + } + getSessionId(): string { return this.sessionId; } @@ -282,7 +293,7 @@ export class SessionManager { this.sessionId = uuidv4(); this.flushed = false; const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`); + this.sessionFile = join(this.getSessionDir(), `${timestamp}_${this.sessionId}.jsonl`); this.inMemoryEntries = [ { type: "session", @@ -365,7 +376,7 @@ export class SessionManager { createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string | null { const newSessionId = uuidv4(); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`); + const newSessionFile = join(this.getSessionDir(), `${timestamp}_${newSessionId}.jsonl`); const newEntries: SessionEntry[] = []; for (let i = 0; i < branchBeforeIndex; i++) { @@ -394,65 +405,90 @@ export class SessionManager { return null; } - /** Create a new session for the given directory */ - static create(cwd: string, agentDir: string = getDefaultAgentDir()): SessionManager { - return new SessionManager(cwd, agentDir, null, true); + /** + * Create a new session. + * @param cwd Working directory (stored in session header) + * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). + */ + static create(cwd: string, sessionDir?: string): SessionManager { + const dir = sessionDir ?? getDefaultSessionDir(cwd); + return new SessionManager(cwd, dir, null, true); } - /** Open a specific session file */ - static open(path: string, agentDir: string = getDefaultAgentDir()): 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. + */ + static open(path: string, sessionDir?: 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(); - return new SessionManager(cwd, agentDir, path, true); + // If no sessionDir provided, derive from file's parent directory + const dir = sessionDir ?? resolve(path, ".."); + return new SessionManager(cwd, dir, path, true); } - /** Continue the most recent session for the given directory, or create new if none */ - static continueRecent(cwd: string, agentDir: string = getDefaultAgentDir()): SessionManager { - const sessionDir = getSessionDirectory(cwd, agentDir); - const mostRecent = findMostRecentSession(sessionDir); + /** + * Continue the most recent session, or create new if none. + * @param cwd Working directory + * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). + */ + static continueRecent(cwd: string, sessionDir?: string): SessionManager { + const dir = sessionDir ?? getDefaultSessionDir(cwd); + const mostRecent = findMostRecentSession(dir); if (mostRecent) { - return new SessionManager(cwd, agentDir, mostRecent, true); + return new SessionManager(cwd, dir, mostRecent, true); } - return new SessionManager(cwd, agentDir, null, true); + return new SessionManager(cwd, dir, null, true); } /** Create an in-memory session (no file persistence) */ - static inMemory(): SessionManager { - return new SessionManager(process.cwd(), getDefaultAgentDir(), null, false); + static inMemory(cwd: string = process.cwd()): SessionManager { + return new SessionManager(cwd, "", null, false); } - /** List all sessions for a directory */ - static list(cwd: string, agentDir: string = getDefaultAgentDir()): SessionInfo[] { - const sessionDir = getSessionDirectory(cwd, agentDir); + /** + * List all sessions. + * @param cwd Working directory (used to compute default session directory) + * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). + */ + static list(cwd: string, sessionDir?: string): SessionInfo[] { + const dir = sessionDir ?? getDefaultSessionDir(cwd); const sessions: SessionInfo[] = []; try { - const files = readdirSync(sessionDir) + const files = readdirSync(dir) .filter((f) => f.endsWith(".jsonl")) - .map((f) => join(sessionDir, f)); + .map((f) => join(dir, f)); for (const file of files) { try { - const stats = statSync(file); const content = readFileSync(file, "utf8"); const lines = content.trim().split("\n"); + if (lines.length === 0) continue; - let sessionId = ""; - let created = stats.birthtime; + // Check first line for valid session header + let header: { type: string; id: string; timestamp: string } | null = null; + try { + const first = JSON.parse(lines[0]); + if (first.type === "session" && first.id) { + header = first; + } + } catch { + // Not valid JSON + } + if (!header) continue; + + const stats = statSync(file); let messageCount = 0; let firstMessage = ""; const allMessages: string[] = []; - for (const line of lines) { + for (let i = 1; i < lines.length; i++) { try { - const entry = JSON.parse(line); - - if (entry.type === "session" && !sessionId) { - sessionId = entry.id; - created = new Date(entry.timestamp); - } + const entry = JSON.parse(lines[i]); if (entry.type === "message") { messageCount++; @@ -479,8 +515,8 @@ export class SessionManager { sessions.push({ path: file, - id: sessionId || "unknown", - created, + id: header.id, + created: new Date(header.timestamp), modified: stats.mtime, messageCount, firstMessage: firstMessage || "(no messages)", diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5619bd13..fbae4fbe 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -173,12 +173,16 @@ function createSessionManager(parsed: Args, cwd: string): SessionManager | null return SessionManager.inMemory(); } if (parsed.session) { - return SessionManager.open(parsed.session); + return SessionManager.open(parsed.session, parsed.sessionDir); } if (parsed.continue) { - return SessionManager.continueRecent(cwd); + return SessionManager.continueRecent(cwd, parsed.sessionDir); } // --resume is handled separately (needs picker UI) + // If --session-dir provided without --continue/--resume, create new session there + if (parsed.sessionDir) { + return SessionManager.create(cwd, parsed.sessionDir); + } // Default case (new session) returns null, SDK will create one return null; } @@ -348,7 +352,7 @@ export async function main(args: string[]) { // Handle --resume: show session picker if (parsed.resume) { - const sessions = SessionManager.list(cwd); + const sessions = SessionManager.list(cwd, parsed.sessionDir); time("SessionManager.list"); if (sessions.length === 0) { console.log(chalk.dim("No sessions found")); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 018d366f..1041b931 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1519,7 +1519,7 @@ export class InteractiveMode { private showSessionSelector(): void { this.showSelector((done) => { - const sessions = SessionManager.list(this.sessionManager.getCwd()); + const sessions = SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir()); const selector = new SessionSelectorComponent( sessions, async (sessionPath) => {