From 48b4324155793049e0837fbfe49e6d87018f7485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Krzywa=C5=BCnia?= <1815898+ribelo@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:18:10 +0100 Subject: [PATCH] fix(session): improve session ID resolution with global search and fork support (#785) When using `--session `, the session lookup now: 1. Searches locally first (current project's session directory) 2. Falls back to global search across all projects 3. If found in different project, prompts user to fork the session 4. If not found anywhere, shows clear error instead of silently creating a broken session with malformed path Adds `SessionManager.forkFrom()` to create a forked session from another project, preserving full conversation history with updated cwd. --- .../coding-agent/src/core/session-manager.ts | 50 +++++++++++++ packages/coding-agent/src/main.ts | 73 ++++++++++++++++--- 2 files changed, 112 insertions(+), 11 deletions(-) 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