diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 4a5fe38d..95e2d9db 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1219,6 +1219,56 @@ export class SessionManager { return new SessionManager(cwd, "", undefined, false); } + /** + * Fork a session from another project directory into the current project. + * Creates a new session in the target cwd with the full history from the source session. + * @param sourcePath Path to the source session file + * @param targetCwd Target working directory (where the new session will be stored) + * @param sessionDir Optional session directory. If omitted, uses default for targetCwd. + */ + static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager { + const sourceEntries = loadEntriesFromFile(sourcePath); + if (sourceEntries.length === 0) { + throw new Error(`Cannot fork: source session file is empty or invalid: ${sourcePath}`); + } + + const sourceHeader = sourceEntries.find((e) => e.type === "session") as SessionHeader | undefined; + if (!sourceHeader) { + throw new Error(`Cannot fork: source session has no header: ${sourcePath}`); + } + + const dir = sessionDir ?? getDefaultSessionDir(targetCwd); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Create new session file with new ID but forked content + const newSessionId = randomUUID(); + const timestamp = new Date().toISOString(); + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`); + + // Write new header pointing to source as parent, with updated cwd + const newHeader: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: newSessionId, + timestamp, + cwd: targetCwd, + parentSession: sourcePath, + }; + appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`); + + // Copy all non-header entries from source + for (const entry of sourceEntries) { + if (entry.type !== "session") { + appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); + } + } + + return new SessionManager(targetCwd, dir, newSessionFile, true); + } + /** * List all sessions for a directory. * @param cwd Working directory (used to compute default session directory) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index abcc838c..8b5860b7 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -9,6 +9,7 @@ import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/ import chalk from "chalk"; import { existsSync } from "fs"; import { join } from "path"; +import { createInterface } from "readline"; import { type Args, parseArgs, printHelp } from "./cli/args.js"; import { processFileArguments } from "./cli/file-processor.js"; import { listModels } from "./cli/list-models.js"; @@ -80,26 +81,56 @@ async function prepareInitialMessage( }; } +/** Result from resolving a session argument */ +type ResolvedSession = + | { type: "path"; path: string } // Direct file path + | { type: "local"; path: string } // Found in current project + | { type: "global"; path: string; cwd: string } // Found in different project + | { type: "not_found"; arg: string }; // Not found anywhere + /** * Resolve a session argument to a file path. * If it looks like a path, use as-is. Otherwise try to match as session ID prefix. */ -async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise { +async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise { // If it looks like a file path, use as-is if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) { - return sessionArg; + return { type: "path", path: sessionArg }; } - // Try to match as session ID (full or partial UUID) - const sessions = await SessionManager.list(cwd, sessionDir); - const matches = sessions.filter((s) => s.id.startsWith(sessionArg)); + // Try to match as session ID in current project first + const localSessions = await SessionManager.list(cwd, sessionDir); + const localMatches = localSessions.filter((s) => s.id.startsWith(sessionArg)); - if (matches.length >= 1) { - return matches[0].path; // Already sorted by modified time (most recent first) + if (localMatches.length >= 1) { + return { type: "local", path: localMatches[0].path }; } - // No match - return original (will create new session) - return sessionArg; + // Try global search across all projects + const allSessions = await SessionManager.listAll(); + const globalMatches = allSessions.filter((s) => s.id.startsWith(sessionArg)); + + if (globalMatches.length >= 1) { + const match = globalMatches[0]; + return { type: "global", path: match.path, cwd: match.cwd }; + } + + // Not found anywhere + return { type: "not_found", arg: sessionArg }; +} + +/** Prompt user for yes/no confirmation */ +async function promptConfirm(message: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(`${message} [y/N] `, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + }); + }); } async function createSessionManager(parsed: Args, cwd: string): Promise { @@ -107,8 +138,28 @@ async function createSessionManager(parsed: Args, cwd: string): Promise