From f11641d5a8ce2af1961ba50be7314a585d836d2f Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 30 Jul 2026 00:28:40 +0200 Subject: [PATCH] feat(coding-agent): enabled importing foreign sessions from claude and codex - Implemented session stores and metadata converters to import Claude and Codex sessions into OMP. - Added `--from-claude` and `--from-codex` CLI flags and `/resume` command arguments for foreign session resolution. - Updated session selector components and controllers to support listing and picking external agent sessions. - Added comprehensive unit tests and documentation covering foreign session import functionality. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/cli/args.ts | 6 + packages/coding-agent/src/cli/flag-tables.ts | 2 + .../coding-agent/src/cli/session-picker.ts | 51 +- packages/coding-agent/src/commands/launch.ts | 6 + packages/coding-agent/src/main.ts | 127 +++- .../src/modes/components/session-selector.ts | 22 +- .../modes/controllers/selector-controller.ts | 130 +++- .../src/modes/interactive-mode.ts | 5 +- packages/coding-agent/src/modes/types.ts | 3 +- .../src/session/claude-session-store.ts | 426 ++++++++++++ .../src/session/codex-session-store.ts | 620 ++++++++++++++++++ .../src/session/foreign-session-import.ts | 52 ++ .../src/session/foreign-session-jsonl.ts | 29 + .../src/session/foreign-session-store.ts | 26 + .../src/session/session-manager.ts | 24 + .../src/slash-commands/builtin-registry.ts | 7 +- .../coding-agent/test/flag-tables.test.ts | 14 + .../test/foreign-session-stores.test.ts | 268 ++++++++ .../test/slash-commands/resume.test.ts | 17 + 20 files changed, 1756 insertions(+), 80 deletions(-) create mode 100644 packages/coding-agent/src/session/claude-session-store.ts create mode 100644 packages/coding-agent/src/session/codex-session-store.ts create mode 100644 packages/coding-agent/src/session/foreign-session-import.ts create mode 100644 packages/coding-agent/src/session/foreign-session-jsonl.ts create mode 100644 packages/coding-agent/src/session/foreign-session-store.ts create mode 100644 packages/coding-agent/test/foreign-session-stores.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7d7803943..e7a230e7e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added server-name autocomplete for `/mcp` commands (`enable`, `disable`, `test`, `remove`, `reconnect`, `reauth`, `unauth`) using configured and runtime-discovered MCP servers. +- Added `--from-claude` and `--from-codex` session imports, also available from `/resume @claude` and `/resume @codex`. ### Changed diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 5733b692b..da606c156 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -44,6 +44,8 @@ export interface Args { advisor?: boolean; continue?: boolean; resume?: string | true; + fromClaude?: boolean; + fromCodex?: boolean; help?: boolean; version?: boolean; mode?: Mode; @@ -227,6 +229,10 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map = new Set([ "--version", "--allow-home", "--continue", + "--from-claude", + "--from-codex", "--no-session", "--no-tools", "--no-lsp", diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index 0debd5274..409a67b38 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -6,16 +6,26 @@ import type { SessionInfo } from "../session/session-listing"; import { SessionManager } from "../session/session-manager"; import { FileSessionStorage } from "../session/session-storage"; +/** Presentation and capability controls for the standalone session picker. */ +export interface SessionPickerOptions { + allSessions?: SessionInfo[]; + title?: string; + scopeLabel?: string | false; + showCwd?: boolean; + allowDelete?: boolean; + allowGlobalScope?: boolean; + historySearch?: boolean; +} + /** * Show the TUI session selector and return the selected session, or null if - * cancelled. Rendered as a fullscreen overlay on the terminal's alternate - * screen, so the list scrolls and rows are clickable with the mouse. Tab - * toggles between current-folder and all-projects scope; the all-projects list - * is loaded lazily via `SessionManager.listAll`. + * cancelled. The default OMP picker supports deletion, transcript-history + * search, and an all-projects scope; foreign import pickers disable those + * source-owned capabilities. */ export async function selectSession( sessions: SessionInfo[], - options?: { allSessions?: SessionInfo[] }, + options: SessionPickerOptions = {}, ): Promise { const { promise, resolve } = Promise.withResolvers(); const ui = new TUI(new ProcessTerminal()); @@ -26,11 +36,13 @@ export async function selectSession( // session-list prefix never sees. Best-effort: a missing/locked history.db // must not break the picker. let historyMatcher: ((query: string) => string[]) | undefined; - try { - const history = HistoryStorage.open(); - historyMatcher = (query: string) => history.matchingSessionIds(query); - } catch (error) { - logger.warn("History storage unavailable for session ranking", { error: String(error) }); + if (options.historySearch !== false) { + try { + const history = HistoryStorage.open(); + historyMatcher = (query: string) => history.matchingSessionIds(query); + } catch (error) { + logger.warn("History storage unavailable for session ranking", { error: String(error) }); + } } const showSelector = () => { @@ -58,16 +70,21 @@ export async function selectSession( } }, { - onDelete: async (session: SessionInfo) => { - // Delete handler - SessionList will show confirmation internally - await storage.deleteSessionWithArtifacts(session.path); - return true; - }, + onDelete: + options.allowDelete === false + ? undefined + : async (session: SessionInfo) => { + await storage.deleteSessionWithArtifacts(session.path); + return true; + }, historyMatcher, - loadAllSessions: () => SessionManager.listAll(storage), - allSessions: options?.allSessions, + loadAllSessions: options.allowGlobalScope === false ? undefined : () => SessionManager.listAll(storage), + allSessions: options.allSessions, getTerminalRows: () => ui.terminal.rows, fillHeight: true, + title: options.title, + scopeLabel: options.scopeLabel, + showCwd: options.showCwd, }, ); return selector; diff --git a/packages/coding-agent/src/commands/launch.ts b/packages/coding-agent/src/commands/launch.ts index 291d3747a..8019bf4e6 100644 --- a/packages/coding-agent/src/commands/launch.ts +++ b/packages/coding-agent/src/commands/launch.ts @@ -99,6 +99,12 @@ export default class Index extends Command { char: "r", description: "Resume a session (by ID prefix, path, or picker if omitted)", }), + "from-claude": Flags.boolean({ + description: "Import a Claude Code session into OMP", + }), + "from-codex": Flags.boolean({ + description: "Import a Codex session into OMP", + }), "session-dir": Flags.string({ description: "Directory for session storage and lookup", }), diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 92a2382aa..7b344e040 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -74,6 +74,13 @@ import { import type { AgentSession } from "./session/agent-session"; import type { AuthStorage } from "./session/auth-storage"; import { describePendingToolCalls } from "./session/exit-diagnostics"; +import { + createForeignSessionStore, + foreignSessionInfoToSessionInfo, + foreignSessionSourceName, + persistForeignSession, +} from "./session/foreign-session-import"; +import type { ForeignSessionInfo, ForeignSessionSource, ForeignSessionStore } from "./session/foreign-session-store"; import { resolveResumableSession, type SessionInfo } from "./session/session-listing"; import { SessionManager } from "./session/session-manager"; import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry"; @@ -565,6 +572,27 @@ export class SessionResolutionError extends Error { } } +function resolveForeignSessionSource( + parsed: Pick, +): ForeignSessionSource | undefined { + if (parsed.fromClaude && parsed.fromCodex) { + throw new SessionResolutionError("--from-claude and --from-codex cannot be used together"); + } + const source = parsed.fromClaude ? "claude" : parsed.fromCodex ? "codex" : undefined; + if (!source) return undefined; + if (parsed.noSession) { + throw new SessionResolutionError(`--from-${source} requires session persistence`); + } + if (parsed.continue || parsed.resume || parsed.fork) { + throw new SessionResolutionError(`--from-${source} cannot be combined with --continue, --resume, or --fork`); + } + return source; +} + +function isForeignSessionImport(parsed: Pick): boolean { + return parsed.fromClaude === true || parsed.fromCodex === true; +} + type MissingCwdMoveResult = | { status: "not-needed" } | { status: "declined" } @@ -652,7 +680,7 @@ async function resolveScopedModels( } async function getChangelogForDisplay(parsed: Args): Promise { - if (parsed.continue || parsed.resume) { + if (parsed.continue || parsed.resume || isForeignSessionImport(parsed)) { return undefined; } @@ -852,6 +880,7 @@ export async function buildSessionOptions( cwd: parsed.cwd ?? getProjectDir(), autoApprove: parsed.autoApprove ?? false, }; + const restoringSession = Boolean(parsed.continue || parsed.resume || isForeignSessionImport(parsed)); const cliDirs = parsed.addDir ?? []; const settingsDirs = activeSettings.get("workspace.additionalDirectories"); if (cliDirs.length > 0 || settingsDirs.length > 0) { @@ -882,7 +911,7 @@ export async function buildSessionOptions( options.providerPromptCacheKeySource = "explicit"; } else { const header = sessionManager?.getHeader(); - const scopedModelOverride = scopedModels.length > 0 && !parsed.continue && !parsed.resume; + const scopedModelOverride = scopedModels.length > 0 && !restoringSession; const forkCacheShapeChanged = scopedModelOverride || parsed.model !== undefined || @@ -939,7 +968,7 @@ export async function buildSessionOptions( options.thinkingLevel = resolved.thinkingLevel; } } - } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) { + } else if (scopedModels.length > 0 && !restoringSession) { const remembered = activeSettings.getModelRole("default"); if (remembered) { const rememberedSpec = resolveModelRoleValue( @@ -1042,8 +1071,7 @@ export async function buildSessionOptions( // thinking suffix) after extensions register; seeding the fallback // scoped model's level here would override it in createAgentSession. !deferredDefaultRole && - !parsed.continue && - !parsed.resume + !restoringSession ) { options.thinkingLevel = scopedModels[0].thinkingLevel; } @@ -1119,6 +1147,7 @@ interface RunRootCommandDependencies { discoverAuthStorage?: typeof discoverAuthStorage; selectSession?: typeof selectSession; runAcpMode?: RunAcpMode; + createForeignSessionStore?: (source: ForeignSessionSource) => ForeignSessionStore; settings?: Settings; forceSetupWizard?: boolean; } @@ -1282,19 +1311,77 @@ export async function runRootCommand( // id from UUID-shaped values owned by later extension flags. normalizeContinueSessionArgs(parsedArgs, rawArgs); - // Create session manager based on CLI flags. SessionResolutionError signals a - // user-facing failure (unknown --resume/--fork id, non-interactive fork - // prompt, --fork with --no-session): print + exit cleanly instead of letting - // it surface as `[Uncaught Exception]` (see issue #2084). + // Resolve native resume/fork flags or import one foreign transcript into a + // fresh persisted OMP session before constructing the AgentSession. let sessionManager: SessionManager | undefined; + let foreignSource: ForeignSessionSource | undefined; try { - sessionManager = await logger.time( - "createSessionManager", - createSessionManager, - parsedArgs, - cwd, - settingsInstance, - ); + foreignSource = resolveForeignSessionSource(parsedArgs); + if (foreignSource) { + if (isProtocolMode) { + throw new SessionResolutionError(`--from-${foreignSource} is not supported in ${mode} mode`); + } + const sourceName = foreignSessionSourceName(foreignSource); + const store = (deps.createForeignSessionStore ?? createForeignSessionStore)(foreignSource); + let foreignSessions: ForeignSessionInfo[]; + try { + foreignSessions = await logger.time(`list${sourceName}Sessions`, () => store.list()); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new SessionResolutionError(`Failed to list ${sourceName} sessions: ${message}`); + } + if (foreignSessions.length === 0) { + writeStartupNotice(parsedArgs, `${chalk.dim(`No ${sourceName} sessions found`)}\n`); + stopStartupWatchdog(); + process.exit(0); + } + const choices = foreignSessions.map(foreignSessionInfoToSessionInfo); + pauseStartupWatchdog(); + let selected: SessionInfo | null; + try { + selected = await logger.time(`select${sourceName}Session`, deps.selectSession ?? selectSession, choices, { + title: `Import ${sourceName} Session`, + scopeLabel: false, + showCwd: true, + allowDelete: false, + allowGlobalScope: false, + historySearch: false, + }); + } finally { + resumeStartupWatchdog(); + } + if (!selected) { + writeStartupNotice(parsedArgs, `${chalk.dim(`No ${sourceName} session selected`)}\n`); + stopStartupWatchdog(); + process.exit(0); + } + const foreignSession = foreignSessions.find( + session => session.id === selected.id && session.path === selected.path, + ); + if (!foreignSession) { + throw new SessionResolutionError(`Selected ${sourceName} session is no longer available`); + } + try { + sessionManager = await logger.time( + `import${sourceName}Session`, + persistForeignSession, + store, + foreignSession, + { fallbackCwd: cwd, sessionDir: parsedArgs.sessionDir }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new SessionResolutionError(`Failed to import ${sourceName} session: ${message}`); + } + } else { + sessionManager = await logger.time( + "createSessionManager", + createSessionManager, + parsedArgs, + cwd, + settingsInstance, + ); + } } catch (error: unknown) { if (error instanceof SessionResolutionError) { process.stderr.write(`${chalk.red(`Error: ${error.message}`)}\n`); @@ -1306,7 +1393,7 @@ export async function runRootCommand( throw error; } - if (typeof parsedArgs.resume === "string" && sessionManager) { + if ((typeof parsedArgs.resume === "string" || foreignSource) && sessionManager) { const previousCwd = cwd; cwd = await switchToResumedProject(sessionManager.getCwd(), settingsInstance, pluginPreloadPromise); if (cwd !== previousCwd) { @@ -1372,7 +1459,7 @@ export async function runRootCommand( sessionManager = await SessionManager.open(selected.path); } - if (sessionManager && (parsedArgs.continue || parsedArgs.resume || parsedArgs.fork)) { + if (sessionManager && (parsedArgs.continue || parsedArgs.resume || parsedArgs.fork || foreignSource)) { const pendingToolWarning = describePendingToolCalls(sessionManager.getBranch()); if (pendingToolWarning) { logger.warn("Resumed session has pending tool calls", { @@ -1515,7 +1602,7 @@ export async function runRootCommand( const showStartupSplash = shouldShowStartupSplash({ configured: settingsInstance.get("startup.showSplash"), isInteractive, - resuming: Boolean(parsedArgs.continue || parsedArgs.resume || parsedArgs.fork), + resuming: Boolean(parsedArgs.continue || parsedArgs.resume || parsedArgs.fork || foreignSource), quiet: settingsInstance.get("startup.quiet"), timing: Boolean($env.PI_TIMING), stdinIsTTY: process.stdin.isTTY, @@ -1612,7 +1699,7 @@ export async function runRootCommand( setToolUIContext, lspServers, mcpManager, - Boolean(parsedArgs.continue || parsedArgs.resume || parsedArgs.fork), + Boolean(parsedArgs.continue || parsedArgs.resume || parsedArgs.fork || foreignSource), deps.forceSetupWizard === true, showStartupSplash, eventBus, diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index 95f3bf1b9..e0d555882 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -724,6 +724,12 @@ export interface SessionSelectorOptions { loadAllSessions?: () => Promise; /** Preloaded all-projects list; cached so the first Tab toggle is instant. */ allSessions?: SessionInfo[]; + /** Picker heading; defaults to "Resume Session". */ + title?: string; + /** Fixed scope label, or false to omit the scope suffix. */ + scopeLabel?: string | false; + /** Show each session's working directory in the list. */ + showCwd?: boolean; /** * Reads the live terminal height so the visible window fits the viewport. * Omitted only in tests; defaults to a conservative 24 rows. @@ -773,6 +779,8 @@ export class SessionSelectorComponent extends Container { readonly #getTerminalRows: () => number; readonly #fillHeight: boolean; readonly #bottomBorder = new DynamicBorder(); + readonly #title: string; + readonly #scopeLabel: string | false | undefined; constructor( sessions: SessionInfo[], @@ -790,6 +798,8 @@ export class SessionSelectorComponent extends Container { this.#globalSessions = options.allSessions ?? null; this.#getTerminalRows = options.getTerminalRows ?? (() => 24); this.#fillHeight = options.fillHeight ?? false; + this.#title = options.title ?? "Resume Session"; + this.#scopeLabel = options.scopeLabel; // Add header this.addChild(new Spacer(1)); this.#headerText = new Text(this.#headerLabel(), 1, 0); @@ -801,7 +811,12 @@ export class SessionSelectorComponent extends Container { // Create session list in folder scope; the empty-state hint invites the // user to Tab into all-projects rather than silently surfacing other // projects' history (issue #3099). - this.#sessionList = new SessionList(sessions, false, options.historyMatcher, options.getTerminalRows); + this.#sessionList = new SessionList( + sessions, + options.showCwd ?? false, + options.historyMatcher, + options.getTerminalRows, + ); // Every exit path cancels the list's pending history merge, so a stale // debounce timer can never run its SQLite lookup after the picker closed. this.#sessionList.onSelect = session => { @@ -831,8 +846,9 @@ export class SessionSelectorComponent extends Container { } #headerLabel(): string { - const scopeLabel = this.#scope === "all" ? "all projects" : "current folder"; - return `${theme.bold("Resume Session")} ${theme.fg("muted", `(${scopeLabel})`)}`; + if (this.#scopeLabel === false) return theme.bold(this.#title); + const scopeLabel = this.#scopeLabel ?? (this.#scope === "all" ? "all projects" : "current folder"); + return `${theme.bold(this.#title)} ${theme.fg("muted", `(${scopeLabel})`)}`; } /** diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index b4b64dbc5..39a2ed106 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -42,6 +42,13 @@ import { import type { InteractiveModeContext } from "../../modes/types"; import type { SessionOAuthAccountList } from "../../session/agent-session-types"; import type { ResetCreditAccountStatus, ResetCreditRedeemOutcome } from "../../session/auth-storage"; +import { + createForeignSessionStore, + foreignSessionInfoToSessionInfo, + foreignSessionSourceName, + persistForeignSession, +} from "../../session/foreign-session-import"; +import type { ForeignSessionInfo, ForeignSessionSource } from "../../session/foreign-session-store"; import type { SessionInfo } from "../../session/session-listing"; import { SessionManager } from "../../session/session-manager"; import { FileSessionStorage } from "../../session/session-storage"; @@ -87,7 +94,7 @@ import { PluginSelectorComponent } from "../components/plugin-selector"; import { ResetUsageSelectorComponent } from "../components/reset-usage-selector"; import { renderSegmentTrack } from "../components/segment-track"; import { SessionAccountSelectorComponent } from "../components/session-account-selector"; -import { SessionSelectorComponent } from "../components/session-selector"; +import { SessionSelectorComponent, type SessionSelectorOptions } from "../components/session-selector"; import { SettingsSelectorComponent } from "../components/settings-selector"; import { ToolExecutionComponent } from "../components/tool-execution"; import { TranscriptBlock } from "../components/transcript-container"; @@ -1358,20 +1365,87 @@ export class SelectorController { return result; } - async showSessionSelector(): Promise { - const sessions = await SessionManager.list( - this.ctx.sessionManager.getCwd(), - this.ctx.sessionManager.getSessionDir(), - ); - // Always open in current-folder scope; the empty-state hint in SessionList - // invites the user to Tab into all-projects rather than silently surfacing - // every project's history when the cwd has nothing to resume. See #3099. - const historyStorage = this.ctx.historyStorage; - const historyMatcher = historyStorage ? (query: string) => historyStorage.matchingSessionIds(query) : undefined; + async showSessionSelector(source?: ForeignSessionSource): Promise { + let sessions: SessionInfo[]; + let onSelectSession: (session: SessionInfo) => Promise; + let selectorOptions: SessionSelectorOptions; + + if (source) { + const sourceName = foreignSessionSourceName(source); + const store = createForeignSessionStore(source); + let foreignSessions: ForeignSessionInfo[]; + try { + foreignSessions = await store.list(); + } catch (error) { + this.ctx.showError( + `Failed to list ${sourceName} sessions: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + if (foreignSessions.length === 0) { + this.ctx.showWarning(`No ${sourceName} sessions found`); + return; + } + const foreignByPath = new Map(foreignSessions.map(session => [session.path, session])); + sessions = foreignSessions.map(foreignSessionInfoToSessionInfo); + onSelectSession = async session => { + try { + await this.ctx.settings.flush(); + } catch (error) { + this.ctx.showError( + `Failed to save pending settings: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } + const foreignSession = foreignByPath.get(session.path); + if (!foreignSession) throw new Error(`Selected ${sourceName} session is no longer available`); + const imported = await persistForeignSession(store, foreignSession, { + fallbackCwd: this.ctx.sessionManager.getCwd(), + suppressBreadcrumb: true, + }); + const sessionFile = imported.getSessionFile(); + if (!sessionFile) throw new Error(`Failed to persist ${sourceName} session`); + await imported.close(); + return await this.handleResumeSession(sessionFile, { settingsFlushed: true }); + }; + selectorOptions = { + title: `Import ${sourceName} Session`, + scopeLabel: false, + showCwd: true, + }; + } else { + sessions = await SessionManager.list( + this.ctx.sessionManager.getCwd(), + this.ctx.sessionManager.getSessionDir(), + ); + const historyStorage = this.ctx.historyStorage; + const historyMatcher = historyStorage + ? (query: string) => historyStorage.matchingSessionIds(query) + : undefined; + onSelectSession = session => this.handleResumeSession(session.path); + selectorOptions = { + onDelete: async (session: SessionInfo) => { + if (!(await this.#detachActiveSessionBeforeDeletion(session.path))) { + return false; + } + const storage = new FileSessionStorage(); + try { + await storage.deleteSessionWithArtifacts(session.path); + return true; + } catch (error) { + throw new Error( + `Failed to delete session: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + }, + historyMatcher, + loadAllSessions: () => SessionManager.listAll(), + }; + } + // Keep the fullscreen picker on the alternate buffer while a selected - // session is loaded and its transcript is rebuilt. Closing it first exposes - // the stale normal buffer for the entire async switch on terminals without - // effective synchronized output. + // session is loaded and its transcript is rebuilt. let overlayHandle: OverlayHandle | undefined; const done = () => { overlayHandle?.hide(); @@ -1384,43 +1458,27 @@ export class SelectorController { selector.lockInput(); let keepOpen = false; try { - const success = await this.handleResumeSession(session.path); + const success = await onSelectSession(session); if (!success) { keepOpen = true; selector.unlockInput(); this.ctx.ui.requestRender(); } + } catch (error) { + this.ctx.showError(error instanceof Error ? error.message : String(error)); } finally { if (!keepOpen) done(); } }, - () => { - done(); - }, + done, () => { // Release the alt buffer before teardown: shutdown() awaits flush/save/ - // dispose/drain before stop() leaves the alt screen, so without this the - // fullscreen picker would freeze on screen for that window on Ctrl+C. + // dispose/drain before stop() leaves the alt screen. done(); void this.ctx.shutdown(); }, { - onDelete: async (session: SessionInfo) => { - if (!(await this.#detachActiveSessionBeforeDeletion(session.path))) { - return false; - } - const storage = new FileSessionStorage(); - try { - await storage.deleteSessionWithArtifacts(session.path); - return true; - } catch (err) { - throw new Error(`Failed to delete session: ${err instanceof Error ? err.message : String(err)}`, { - cause: err, - }); - } - }, - historyMatcher, - loadAllSessions: () => SessionManager.listAll(), + ...selectorOptions, getTerminalRows: () => this.ctx.ui.terminal.rows, fillHeight: true, }, diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 4a86b51b0..be28c0f27 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -103,6 +103,7 @@ import { SHUTDOWN_CONSOLIDATE_BUDGET_MS, } from "../session/agent-session"; import type { CompactMode } from "../session/compact-modes"; +import type { ForeignSessionSource } from "../session/foreign-session-store"; import { HistoryStorage } from "../session/history-storage"; import type { SessionContext } from "../session/session-context"; import { getRecentSessions } from "../session/session-listing"; @@ -4685,8 +4686,8 @@ export class InteractiveMode implements InteractiveModeContext { this.#selectorController.showTreeSelector(); } - showSessionSelector(): void { - this.#selectorController.showSessionSelector(); + showSessionSelector(source?: ForeignSessionSource): void { + void this.#selectorController.showSessionSelector(source); } async handleResumeSession(sessionPath: string): Promise { diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index ae3066867..29d83aaef 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -20,6 +20,7 @@ import type { MCPManager } from "../mcp"; import type { PlanApprovalDetails } from "../plan-mode/approved-plan"; import type { AgentSession } from "../session/agent-session"; import type { CompactMode } from "../session/compact-modes"; +import type { ForeignSessionSource } from "../session/foreign-session-store"; import type { HistoryStorage } from "../session/history-storage"; import type { SessionContext } from "../session/session-context"; import type { SessionManager } from "../session/session-manager"; @@ -384,7 +385,7 @@ export interface InteractiveModeContext { showUserMessageSelector(): void; showCopySelector(): void; showTreeSelector(): void; - showSessionSelector(): void; + showSessionSelector(source?: ForeignSessionSource): void; handleResumeSession(sessionPath: string): Promise; handleSessionDeleteCommand(): Promise; showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise; diff --git a/packages/coding-agent/src/session/claude-session-store.ts b/packages/coding-agent/src/session/claude-session-store.ts new file mode 100644 index 000000000..4861981b3 --- /dev/null +++ b/packages/coding-agent/src/session/claude-session-store.ts @@ -0,0 +1,426 @@ +import type * as fsTypes from "node:fs"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { + AssistantMessage, + ImageContent, + TextContent, + ThinkingContent, + ToolCall, + ToolResultMessage, + Usage, + UserMessage, +} from "@oh-my-pi/pi-ai"; +import { isRecord } from "@oh-my-pi/pi-utils"; +import { collectForeignJsonRecords, type ForeignJsonRecord, readForeignJsonRecords } from "./foreign-session-jsonl"; +import type { ForeignSessionInfo, ForeignSessionStore } from "./foreign-session-store"; +import type { ModelChangeEntry, SessionMessageEntry } from "./session-entries"; +import { SessionManager } from "./session-manager"; + +interface ClaudeHistoryMetadata { + firstMessage?: string; + cwd?: string; + created: number; + modified: number; + messageCount: number; +} + +interface ConvertedMessage { + readonly message: UserMessage | AssistantMessage | ToolResultMessage; + readonly suffix: string; +} + +const EMPTY_USAGE: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function numberField(record: Record, key: string): number { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function timestampMs(value: unknown, fallback: number): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +function isoTimestamp(value: unknown, fallback: number): string { + return new Date(timestampMs(value, fallback)).toISOString(); +} + +function cleanPreview(value: string): string | undefined { + const cleaned = value.replace(/\s+/g, " ").trim(); + return cleaned.length > 0 ? cleaned : undefined; +} + +async function readHistoryIndex(file: string): Promise> { + const metadata = new Map(); + try { + for await (const { value } of readForeignJsonRecords(file)) { + const id = stringField(value, "sessionId") ?? stringField(value, "session_id"); + const timestamp = timestampMs(value.timestamp ?? value.ts, 0); + if (!id || timestamp <= 0) continue; + const previous = metadata.get(id); + const rawText = stringField(value, "display") ?? stringField(value, "text"); + const text = rawText ? cleanPreview(rawText) : undefined; + const cwd = stringField(value, "project"); + if (!previous) { + metadata.set(id, { created: timestamp, modified: timestamp, firstMessage: text, cwd, messageCount: 1 }); + continue; + } + if (timestamp < previous.created) { + previous.created = timestamp; + if (text) previous.firstMessage = text; + } + previous.modified = Math.max(previous.modified, timestamp); + if (!previous.firstMessage && text) previous.firstMessage = text; + previous.messageCount += 1; + if (!previous.cwd && cwd) previous.cwd = cwd; + } + } catch { + // History is an optional index; project files remain independently discoverable. + } + return metadata; +} + +async function readRegisteredProjects(root: string): Promise { + const config = path.join(path.dirname(root), ".claude.json"); + try { + const parsed: unknown = await Bun.file(config).json(); + if (!isRecord(parsed) || !isRecord(parsed.projects)) return []; + const projects: string[] = []; + for (const project in parsed.projects) { + if (path.isAbsolute(project)) projects.push(project); + } + return projects; + } catch { + return []; + } +} + +function projectCwd(encoded: string, registered: readonly string[]): string { + const exact = registered.find(project => project.replaceAll(path.sep, "-") === encoded); + if (exact) return exact; + if (!encoded.startsWith("-")) return encoded; + return encoded.replaceAll("-", path.sep); +} + +async function projectFiles(root: string): Promise> { + const registered = await readRegisteredProjects(root); + const found: Array<{ file: string; cwd: string }> = []; + for (const containerName of ["projects", ".projects"]) { + const container = path.join(root, containerName); + const projects = await fs.readdir(container, { withFileTypes: true }).catch(() => []); + for (const project of projects) { + if (!project.isDirectory()) continue; + const directory = path.join(container, project.name); + const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []); + const cwd = projectCwd(project.name, registered); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(".jsonl")) { + found.push({ file: path.join(directory, entry.name), cwd }); + } + } + } + } + return found; +} + +function imageContent(value: unknown): ImageContent | undefined { + if (!isRecord(value) || value.type !== "image" || !isRecord(value.source)) return undefined; + const data = stringField(value.source, "data"); + const mimeType = stringField(value.source, "media_type"); + return data && mimeType ? { type: "image", data, mimeType } : undefined; +} + +function userContent(value: unknown): string | (TextContent | ImageContent)[] | undefined { + if (typeof value === "string") return value; + if (!Array.isArray(value)) return undefined; + const content: (TextContent | ImageContent)[] = []; + for (const block of value) { + if (!isRecord(block)) continue; + if (block.type === "text" && typeof block.text === "string") content.push({ type: "text", text: block.text }); + else { + const image = imageContent(block); + if (image) content.push(image); + } + } + return content.length > 0 ? content : undefined; +} + +function toolResultContent(value: unknown): (TextContent | ImageContent)[] { + if (typeof value === "string") return [{ type: "text", text: value }]; + if (!Array.isArray(value)) return []; + const content: (TextContent | ImageContent)[] = []; + for (const block of value) { + if (!isRecord(block)) continue; + if (block.type === "text" && typeof block.text === "string") content.push({ type: "text", text: block.text }); + else { + const image = imageContent(block); + if (image) content.push(image); + } + } + return content; +} + +function assistantContent( + value: unknown, + toolNames: Map, +): (TextContent | ThinkingContent | ToolCall)[] { + if (!Array.isArray(value)) return []; + const content: (TextContent | ThinkingContent | ToolCall)[] = []; + for (const block of value) { + if (!isRecord(block)) continue; + if (block.type === "text" && typeof block.text === "string") { + content.push({ type: "text", text: block.text }); + } else if (block.type === "thinking" && typeof block.thinking === "string") { + const thinking: ThinkingContent = { type: "thinking", thinking: block.thinking }; + if (typeof block.signature === "string") thinking.thinkingSignature = block.signature; + content.push(thinking); + } else if (block.type === "tool_use") { + const id = stringField(block, "id"); + const name = stringField(block, "name"); + if (!id || !name) continue; + const argumentsValue = isRecord(block.input) ? block.input : {}; + content.push({ type: "toolCall", id, name, arguments: argumentsValue }); + toolNames.set(id, name); + } + } + return content; +} + +function claudeUsage(value: unknown): Usage { + if (!isRecord(value)) return EMPTY_USAGE; + const input = numberField(value, "input_tokens"); + const output = numberField(value, "output_tokens"); + const cacheRead = numberField(value, "cache_read_input_tokens"); + const cacheWrite = numberField(value, "cache_creation_input_tokens"); + return { + input, + output, + cacheRead, + cacheWrite, + totalTokens: input + output + cacheRead + cacheWrite, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function stopReason(value: unknown): AssistantMessage["stopReason"] { + if (value === "tool_use") return "toolUse"; + if (value === "max_tokens") return "length"; + if (value === "end_turn" || value === "stop_sequence" || value === "pause_turn") return "stop"; + return value == null ? "stop" : "error"; +} + +function convertRecord( + record: Record, + fallbackTimestamp: number, + toolNames: Map, +): ConvertedMessage[] { + if ( + (record.type !== "user" && record.type !== "assistant") || + record.isSidechain === true || + record.isMeta === true + ) { + return []; + } + if (!isRecord(record.message)) return []; + const timestamp = timestampMs(record.timestamp, fallbackTimestamp); + if (record.type === "assistant") { + const content = assistantContent(record.message.content, toolNames); + const errorMessage = stringField(record, "error"); + if (content.length === 0 && !errorMessage) return []; + const model = stringField(record.message, "model") ?? "unknown"; + const message: AssistantMessage = { + role: "assistant", + content, + api: "anthropic-messages", + provider: "anthropic", + model, + usage: claudeUsage(record.message.usage), + stopReason: stopReason(record.message.stop_reason), + timestamp, + }; + const responseId = stringField(record.message, "id"); + if (responseId) message.responseId = responseId; + if (errorMessage) message.errorMessage = errorMessage; + if (typeof record.apiErrorStatus === "number" && Number.isFinite(record.apiErrorStatus)) { + message.errorStatus = record.apiErrorStatus; + } + return [{ message, suffix: "message" }]; + } + const rawContent = record.message.content; + if (Array.isArray(rawContent)) { + const results: ConvertedMessage[] = []; + for (let index = 0; index < rawContent.length; index += 1) { + const block = rawContent[index]; + if (!isRecord(block) || block.type !== "tool_result") continue; + const toolCallId = stringField(block, "tool_use_id"); + if (!toolCallId) continue; + const message: ToolResultMessage = { + role: "toolResult", + toolCallId, + toolName: toolNames.get(toolCallId) ?? "unknown", + content: toolResultContent(block.content), + isError: block.is_error === true, + timestamp, + }; + results.push({ message, suffix: `tool-${index}` }); + } + if (results.length > 0) return results; + } + const content = userContent(rawContent); + if (content === undefined || (typeof content === "string" && content.length === 0)) return []; + return [{ message: { role: "user", content, timestamp }, suffix: "message" }]; +} + +function uniqueEntryId(base: string, used: Set): string { + let id = base; + let suffix = 2; + while (used.has(id)) { + id = `${base}-${suffix}`; + suffix += 1; + } + used.add(id); + return id; +} + +/** Imports Claude Code JSONL sessions into non-persistent OMP session managers. */ +export class ClaudeSessionStore implements ForeignSessionStore { + readonly source = "claude"; + readonly #root: string; + + /** Creates a store rooted at Claude's data directory, or at a fixture root when supplied. */ + constructor(root: string = path.join(os.homedir(), ".claude")) { + this.#root = path.resolve(root); + } + + /** Lists indexed Claude sessions without reading transcript bodies. */ + async list(): Promise { + const [history, files] = await Promise.all([ + readHistoryIndex(path.join(this.#root, "history.jsonl")), + projectFiles(this.#root), + ]); + const sessions: ForeignSessionInfo[] = []; + for (const item of files) { + try { + const stats = await fs.stat(item.file); + const id = path.basename(item.file, ".jsonl"); + const indexed = history.get(id); + const createdMs = indexed?.created ?? (stats.birthtimeMs || stats.ctimeMs || stats.mtimeMs); + const modifiedMs = Math.max(indexed?.modified ?? 0, stats.mtimeMs); + sessions.push({ + source: this.source, + id, + path: item.file, + cwd: indexed?.cwd ?? item.cwd, + created: new Date(createdMs), + modified: new Date(modifiedMs), + firstMessage: indexed?.firstMessage, + messageCount: indexed?.messageCount, + }); + } catch { + // Files may disappear while Claude rotates its session store. + } + } + return sessions.sort( + (left, right) => right.modified.getTime() - left.modified.getTime() || left.path.localeCompare(right.path), + ); + } + + /** Loads and converts a Claude transcript while preserving its source tree and timestamps. */ + async load(info: ForeignSessionInfo): Promise { + if (info.source !== this.source) throw new Error(`Cannot load ${info.source} session with ClaudeSessionStore`); + let records: ForeignJsonRecord[]; + let stats: fsTypes.Stats; + try { + [records, stats] = await Promise.all([collectForeignJsonRecords(info.path), fs.stat(info.path)]); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Unable to read Claude session ${info.id}: ${detail}`); + } + if (records.length === 0 && stats.size > 0) + throw new Error(`Claude session ${info.id} contains no readable records`); + + const sourceParents = new Map(); + let sourceCwd: string | undefined; + let sourceTitle: string | undefined; + let aiTitle: string | undefined; + for (const { value } of records) { + const uuid = stringField(value, "uuid"); + if (uuid) sourceParents.set(uuid, stringField(value, "parentUuid") ?? null); + if (!sourceCwd) sourceCwd = stringField(value, "cwd"); + if (value.type === "custom-title") sourceTitle = stringField(value, "customTitle") ?? sourceTitle; + if (value.type === "ai-title") aiTitle = stringField(value, "aiTitle") ?? aiTitle; + } + + const manager = SessionManager.inMemory(sourceCwd ?? info.cwd); + const sourceTails = new Map(); + const usedIds = new Set(); + const toolNames = new Map(); + let lastModel: string | undefined; + let synthetic = 0; + const resolveParent = (sourceId: string | undefined): string | null => { + const seen = new Set(); + let cursor = sourceId; + while (cursor && !seen.has(cursor)) { + seen.add(cursor); + const retained = sourceTails.get(cursor); + if (retained) return retained; + cursor = sourceParents.get(cursor) ?? undefined; + } + return null; + }; + + for (const { value, line } of records) { + const converted = convertRecord(value, stats.mtimeMs, toolNames); + if (converted.length === 0) continue; + const sourceUuid = stringField(value, "uuid") ?? `line-${line}`; + let parentId = resolveParent(stringField(value, "parentUuid")); + const timestamp = isoTimestamp(value.timestamp, stats.mtimeMs); + if (value.type === "assistant" && isRecord(value.message)) { + const model = stringField(value.message, "model"); + if (model && model !== lastModel) { + const id = uniqueEntryId(`claude-${sourceUuid}-model`, usedIds); + const entry: ModelChangeEntry = { + type: "model_change", + id, + parentId, + timestamp, + model: `anthropic/${model}`, + }; + manager.ingestReplicatedEntry(entry); + parentId = id; + lastModel = model; + } + } + for (const item of converted) { + synthetic += 1; + const id = uniqueEntryId(`claude-${sourceUuid}-${item.suffix}-${synthetic}`, usedIds); + const entry: SessionMessageEntry = { type: "message", id, parentId, timestamp, message: item.message }; + manager.ingestReplicatedEntry(entry); + parentId = id; + } + if (parentId) sourceTails.set(sourceUuid, parentId); + } + + const title = sourceTitle ?? aiTitle ?? info.title; + if (title) await manager.setSessionName(title, sourceTitle ? "user" : "auto"); + return manager; + } +} diff --git a/packages/coding-agent/src/session/codex-session-store.ts b/packages/coding-agent/src/session/codex-session-store.ts new file mode 100644 index 000000000..5faa8d8f5 --- /dev/null +++ b/packages/coding-agent/src/session/codex-session-store.ts @@ -0,0 +1,620 @@ +import { Database } from "bun:sqlite"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { + AssistantMessage, + ImageContent, + TextContent, + ThinkingContent, + ToolCall, + ToolResultMessage, + UserMessage, +} from "@oh-my-pi/pi-ai"; +import { isRecord } from "@oh-my-pi/pi-utils"; +import { readForeignJsonRecords } from "./foreign-session-jsonl"; +import type { ForeignSessionInfo, ForeignSessionStore } from "./foreign-session-store"; +import type { ModelChangeEntry, SessionEntry, SessionMessageEntry } from "./session-entries"; +import { SessionManager } from "./session-manager"; + +interface CodexThreadRow { + id: string; + rollout_path: string; + created_at: number | null; + updated_at: number | null; + cwd: string; + title: string | null; + first_user_message: string | null; +} + +interface CodexIndexRow { + id: string; + thread_name: string; + updated_at: string; +} + +interface ConvertedRecord { + message?: UserMessage | AssistantMessage | ToolResultMessage; + followingMessage?: ToolResultMessage; + model?: string; + rollbackTurns?: number; + title?: string; + timestamp?: number; +} + +const EMPTY_USAGE: AssistantMessage["usage"] = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +function numberField(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function timestampMillis(value: unknown, fallback: number): number { + if (typeof value === "number" && Number.isFinite(value)) return value < 10_000_000_000 ? value * 1000 : value; + if (typeof value === "string") { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +function dateFromEpoch(value: number | null, fallback: Date): Date { + if (value === null || !Number.isFinite(value)) return fallback; + return new Date(value < 10_000_000_000 ? value * 1000 : value); +} + +function imageFromUrl(value: unknown, detail: unknown): ImageContent | undefined { + if (typeof value !== "string") return undefined; + const match = /^data:([^;,]+);base64,(.+)$/s.exec(value); + if (!match) return undefined; + const resolution = + detail === "auto" || detail === "low" || detail === "high" || detail === "original" ? detail : undefined; + return { type: "image", mimeType: match[1], data: match[2], detail: resolution }; +} + +function responseContent(value: unknown): Array { + if (!Array.isArray(value)) return []; + const content: Array = []; + for (const item of value) { + if (!isRecord(item)) continue; + const type = stringField(item, "type"); + const text = stringField(item, "text"); + if ((type === "input_text" || type === "output_text" || type === "text") && text !== undefined) { + content.push({ type: "text", text }); + continue; + } + if (type === "input_image") { + const image = imageFromUrl(item.image_url, item.detail); + if (image) content.push(image); + } + } + return content; +} + +function textFromContent(value: unknown): string { + return responseContent(value) + .filter((part): part is TextContent => part.type === "text") + .map(part => part.text) + .join(""); +} + +function toolArguments(value: unknown): Record { + if (isRecord(value)) return value; + if (typeof value !== "string") return {}; + try { + const parsed: unknown = JSON.parse(value); + if (isRecord(parsed)) return parsed; + } catch { + // Custom tools intentionally carry non-JSON input. + } + return { input: value }; +} + +function toolOutputContent(value: unknown): Array { + const structured = responseContent(value); + if (structured.length > 0) return structured; + if (typeof value === "string") return [{ type: "text", text: value }]; + if (value === undefined) return []; + try { + return [{ type: "text", text: JSON.stringify(value) }]; + } catch { + return [{ type: "text", text: String(value) }]; + } +} + +function reasoningContent(payload: Record): ThinkingContent[] { + const parts: ThinkingContent[] = []; + for (const key of ["summary", "content"]) { + const value = payload[key]; + if (!Array.isArray(value)) continue; + for (const item of value) { + if (!isRecord(item)) continue; + const text = stringField(item, "text"); + if (text) parts.push({ type: "thinking", thinking: text }); + } + } + return parts; +} + +function assistantMessage( + content: AssistantMessage["content"], + timestamp: number, + model: string, + stopReason: AssistantMessage["stopReason"], +): AssistantMessage { + return { + role: "assistant", + content, + api: "openai-codex-responses", + provider: "openai-codex", + model, + usage: EMPTY_USAGE, + stopReason, + timestamp, + }; +} + +async function firstJsonRecord(filePath: string): Promise | undefined> { + for await (const { value } of readForeignJsonRecords(filePath)) return value; + return undefined; +} + +async function readJsonLines(filePath: string): Promise[]> { + const records: Record[] = []; + for await (const { value } of readForeignJsonRecords(filePath)) records.push(value); + return records; +} + +async function rolloutFiles(directory: string): Promise { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(directory, { withFileTypes: true }); + } catch { + return []; + } + const files: string[] = []; + for (const entry of entries) { + const child = path.join(directory, entry.name); + if (entry.isDirectory()) files.push(...(await rolloutFiles(child))); + else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(child); + } + return files; +} + +function rolloutId(filePath: string): string { + const match = /([0-9a-f]{8}-[0-9a-f-]{27})\.jsonl$/i.exec(filePath); + return match?.[1] ?? path.basename(filePath, ".jsonl"); +} + +async function stateDatabasePath(root: string): Promise { + const names = await fs.promises.readdir(root).catch(() => []); + return names + .map(name => ({ name, version: /^state_(\d+)\.sqlite$/.exec(name) })) + .filter(item => item.version !== null) + .sort((left, right) => Number(right.version?.[1]) - Number(left.version?.[1])) + .map(item => path.join(root, item.name)) + .at(0); +} + +async function loadIndex(root: string): Promise> { + const index = new Map(); + const filePath = path.join(root, "session_index.jsonl"); + let records: Record[]; + try { + records = await readJsonLines(filePath); + } catch { + return index; + } + for (const record of records) { + const id = stringField(record, "id"); + const threadName = stringField(record, "thread_name"); + const updatedAt = stringField(record, "updated_at"); + if (id && threadName && updatedAt) index.set(id, { id, thread_name: threadName, updated_at: updatedAt }); + } + return index; +} + +function convertedResponseItem( + payload: Record, + timestamp: number, + model: string, + toolNames: Map, +): ConvertedRecord | undefined { + const type = stringField(payload, "type"); + if (type === "message") { + const role = stringField(payload, "role"); + const content = responseContent(payload.content); + if (content.length === 0) return undefined; + if (role === "user") return { message: { role: "user", content, timestamp } }; + if (role === "assistant") return { message: assistantMessage(content, timestamp, model, "stop") }; + return undefined; + } + if (type === "reasoning") { + const content = reasoningContent(payload); + return content.length > 0 ? { message: assistantMessage(content, timestamp, model, "stop") } : undefined; + } + if (type === "function_call" || type === "custom_tool_call") { + const callId = stringField(payload, "call_id") ?? stringField(payload, "id"); + const name = stringField(payload, "name"); + if (!callId || !name) return undefined; + toolNames.set(callId, name); + const call: ToolCall = { + type: "toolCall", + id: callId, + name, + arguments: toolArguments(type === "custom_tool_call" ? payload.input : payload.arguments), + customWireName: type === "custom_tool_call" ? name : undefined, + }; + return { message: assistantMessage([call], timestamp, model, "toolUse") }; + } + if (type === "function_call_output" || type === "custom_tool_call_output") { + const callId = stringField(payload, "call_id"); + if (!callId) return undefined; + return { + message: { + role: "toolResult", + toolCallId: callId, + toolName: toolNames.get(callId) ?? "unknown", + content: toolOutputContent(payload.output), + isError: false, + timestamp, + }, + }; + } + if (type === "web_search_call" || type === "tool_search_call") { + const callId = stringField(payload, "call_id") ?? stringField(payload, "id"); + if (!callId) return undefined; + const name = type === "web_search_call" ? "web_search" : "tool_search"; + toolNames.set(callId, name); + const input = type === "web_search_call" ? payload.action : payload.arguments; + const call: ToolCall = { type: "toolCall", id: callId, name, arguments: toolArguments(input) }; + return { message: assistantMessage([call], timestamp, model, "toolUse") }; + } + if (type === "tool_search_output") { + const callId = stringField(payload, "call_id"); + if (!callId) return undefined; + return { + message: { + role: "toolResult", + toolCallId: callId, + toolName: toolNames.get(callId) ?? "tool_search", + content: toolOutputContent(payload.tools), + isError: payload.status === "failed", + timestamp, + }, + }; + } + return undefined; +} + +function convertedEvent( + payload: Record, + timestamp: number, + model: string, + canonicalUserText: Set, + canonicalAssistantText: Set, + canonicalToolCalls: Set, + toolNames: Map, +): ConvertedRecord | undefined { + const type = stringField(payload, "type"); + if (type === "user_message") { + const text = stringField(payload, "message"); + if (!text || canonicalUserText.has(text)) return undefined; + return { message: { role: "user", content: text, timestamp } }; + } + if (type === "agent_message") { + const text = stringField(payload, "message"); + if (!text || canonicalAssistantText.has(text)) return undefined; + return { message: assistantMessage([{ type: "text", text }], timestamp, model, "stop") }; + } + if (type === "agent_reasoning") { + const text = stringField(payload, "text"); + if (!text || canonicalAssistantText.has(text)) return undefined; + return { message: assistantMessage([{ type: "thinking", thinking: text }], timestamp, model, "stop") }; + } + if (type === "dynamic_tool_call_request") { + const callId = stringField(payload, "callId") ?? stringField(payload, "call_id"); + const name = stringField(payload, "tool"); + if (!callId || !name || canonicalToolCalls.has(callId)) return undefined; + toolNames.set(callId, name); + const call: ToolCall = { type: "toolCall", id: callId, name, arguments: toolArguments(payload.arguments) }; + return { message: assistantMessage([call], timestamp, model, "toolUse") }; + } + if (type === "dynamic_tool_call_response") { + const callId = stringField(payload, "call_id") ?? stringField(payload, "callId"); + if (!callId || canonicalToolCalls.has(callId)) return undefined; + const error = stringField(payload, "error"); + return { + message: { + role: "toolResult", + toolCallId: callId, + toolName: toolNames.get(callId) ?? stringField(payload, "tool") ?? "unknown", + content: error ? [{ type: "text", text: error }] : toolOutputContent(payload.content_items), + isError: error !== undefined || payload.success === false, + timestamp, + }, + }; + } + if (type === "web_search_end") { + const callId = stringField(payload, "call_id"); + if (!callId) return undefined; + const name = toolNames.get(callId) ?? "web_search"; + const result: ToolResultMessage = { + role: "toolResult", + toolCallId: callId, + toolName: name, + content: toolOutputContent(payload.results ?? payload.query), + isError: false, + timestamp, + }; + if (canonicalToolCalls.has(callId) || toolNames.has(callId)) return { message: result }; + toolNames.set(callId, name); + const call: ToolCall = { + type: "toolCall", + id: callId, + name, + arguments: toolArguments(payload.action ?? payload.query), + }; + return { + message: assistantMessage([call], timestamp, model, "toolUse"), + followingMessage: result, + }; + } + if (type === "mcp_tool_call_end") { + const callId = stringField(payload, "call_id"); + if (!callId || canonicalToolCalls.has(callId) || !isRecord(payload.invocation)) return undefined; + const server = stringField(payload.invocation, "server"); + const tool = stringField(payload.invocation, "tool"); + if (!server || !tool) return undefined; + const name = `${server}/${tool}`; + toolNames.set(callId, name); + const call: ToolCall = { + type: "toolCall", + id: callId, + name, + arguments: toolArguments(payload.invocation.arguments), + }; + const result = isRecord(payload.result) && isRecord(payload.result.Ok) ? payload.result.Ok : payload.result; + const error = isRecord(payload.result) && typeof payload.result.Err === "string" ? payload.result.Err : undefined; + return { + message: assistantMessage([call], timestamp, model, "toolUse"), + followingMessage: { + role: "toolResult", + toolCallId: callId, + toolName: name, + content: error + ? [{ type: "text", text: error }] + : toolOutputContent(isRecord(result) ? result.content : result), + isError: error !== undefined || (isRecord(result) && result.isError === true), + timestamp, + }, + }; + } + if (type === "thread_name_updated") return { title: stringField(payload, "thread_name") }; + if (type === "thread_rolled_back") return { rollbackTurns: numberField(payload, "num_turns") ?? 0 }; + return undefined; +} + +function canonicalTexts(records: Record[]): { + users: Set; + assistants: Set; + toolCalls: Set; +} { + const users = new Set(); + const assistants = new Set(); + const toolCalls = new Set(); + for (const record of records) { + if (record.type !== "response_item" || !isRecord(record.payload)) continue; + const callId = stringField(record.payload, "call_id") ?? stringField(record.payload, "id"); + if (callId && typeof record.payload.type === "string" && record.payload.type.includes("call")) + toolCalls.add(callId); + if (record.payload.type === "message") { + const text = textFromContent(record.payload.content); + if (!text) continue; + if (record.payload.role === "user") users.add(text); + else if (record.payload.role === "assistant") assistants.add(text); + } else if (record.payload.type === "reasoning") { + for (const part of reasoningContent(record.payload)) assistants.add(part.thinking); + } + } + return { users, assistants, toolCalls }; +} + +function rollback(records: ConvertedRecord[], turns: number): void { + for (let remaining = turns; remaining > 0; remaining--) { + let userIndex = -1; + for (let index = records.length - 1; index >= 0; index--) { + if (records[index].message?.role === "user") { + userIndex = index; + break; + } + } + if (userIndex < 0) return; + records.splice(userIndex); + } +} + +/** Imports locally stored OpenAI Codex sessions into OMP's in-memory session format. */ +export class CodexSessionStore implements ForeignSessionStore { + /** Foreign-session source discriminator. */ + readonly source = "codex"; + readonly #root: string; + + /** Uses the supplied Codex data root, or ~/.codex by default. */ + constructor(rootDirectory: string = path.join(os.homedir(), ".codex")) { + this.#root = path.resolve(rootDirectory); + } + + /** Lists Codex sessions from its state index without reading transcript bodies. */ + async list(): Promise { + const databasePath = await stateDatabasePath(this.#root); + if (databasePath) { + try { + const database = new Database(databasePath, { readonly: true }); + try { + const rows = database + .query( + "SELECT id, rollout_path, created_at, updated_at, cwd, title, first_user_message FROM threads", + ) + .all(); + const sessions: ForeignSessionInfo[] = []; + for (const row of rows) { + if (!row.id || !row.rollout_path || !row.cwd) continue; + const rolloutPath = path.isAbsolute(row.rollout_path) + ? row.rollout_path + : path.join(this.#root, row.rollout_path); + const modified = dateFromEpoch(row.updated_at, new Date(0)); + const created = dateFromEpoch(row.created_at, modified); + sessions.push({ + source: "codex", + id: row.id, + path: rolloutPath, + cwd: row.cwd, + title: row.title ?? undefined, + created, + modified, + firstMessage: row.first_user_message ?? undefined, + }); + } + sessions.sort( + (left, right) => + right.modified.getTime() - left.modified.getTime() || left.id.localeCompare(right.id), + ); + if (sessions.length > 0) return sessions; + } finally { + database.close(); + } + } catch { + // Older Codex state databases fall back to the rollout metadata path. + } + } + + const index = await loadIndex(this.#root); + const roots = ["sessions", ".sessions", "archived_sessions"].map(name => path.join(this.#root, name)); + const files = (await Promise.all(roots.map(rolloutFiles))).flat(); + const sessions: ForeignSessionInfo[] = []; + for (const filePath of files) { + const first = await firstJsonRecord(filePath); + if (first?.type !== "session_meta" || !isRecord(first.payload)) continue; + const id = stringField(first.payload, "id") ?? rolloutId(filePath); + const cwd = stringField(first.payload, "cwd"); + if (!cwd) continue; + const stat = await fs.promises.stat(filePath); + const indexed = index.get(id); + const sourceCreated = stringField(first.payload, "timestamp"); + const created = new Date(timestampMillis(sourceCreated, stat.birthtimeMs)); + const modified = indexed ? new Date(timestampMillis(indexed.updated_at, stat.mtimeMs)) : stat.mtime; + sessions.push({ + source: "codex", + id, + path: filePath, + cwd, + title: indexed?.thread_name, + created, + modified, + }); + } + sessions.sort( + (left, right) => right.modified.getTime() - left.modified.getTime() || left.id.localeCompare(right.id), + ); + return sessions; + } + + /** Converts one Codex rollout into a non-persistent OMP session. */ + async load(info: ForeignSessionInfo): Promise { + if (info.source !== "codex") throw new Error(`Cannot load ${info.source} session with CodexSessionStore`); + let records: Record[]; + try { + records = await readJsonLines(info.path); + } catch (error) { + throw new Error(`Unable to read Codex session ${info.id} at ${info.path}`, { cause: error }); + } + if (records.length === 0) throw new Error(`Codex session ${info.id} at ${info.path} is empty or malformed`); + + const metadata = records.find(record => record.type === "session_meta" && isRecord(record.payload)); + const cwd = + metadata && isRecord(metadata.payload) ? (stringField(metadata.payload, "cwd") ?? info.cwd) : info.cwd; + const manager = SessionManager.inMemory(cwd); + const canonical = canonicalTexts(records); + const converted: ConvertedRecord[] = []; + const toolNames = new Map(); + let model = "codex"; + let fallbackTimestamp = info.created.getTime(); + let title = info.title; + + for (const record of records) { + const timestamp = timestampMillis(record.timestamp, fallbackTimestamp); + fallbackTimestamp = Math.max(fallbackTimestamp + 1, timestamp); + if (!isRecord(record.payload)) continue; + let item: ConvertedRecord | undefined; + if (record.type === "turn_context") { + const nextModel = stringField(record.payload, "model"); + if (nextModel && nextModel !== model) { + model = nextModel; + item = { model, timestamp }; + } + } else if (record.type === "response_item") { + item = convertedResponseItem(record.payload, timestamp, model, toolNames); + } else if (record.type === "event_msg") { + item = convertedEvent( + record.payload, + timestamp, + model, + canonical.users, + canonical.assistants, + canonical.toolCalls, + toolNames, + ); + } + if (!item) continue; + if (item.rollbackTurns) rollback(converted, item.rollbackTurns); + else converted.push(item); + if (item.followingMessage) converted.push({ message: item.followingMessage }); + if (item.title) title = item.title; + } + + let parentId: string | null = null; + let ordinal = 0; + for (const item of converted) { + const id = `codex-${(++ordinal).toString(36)}`; + const timestamp = new Date(item.message?.timestamp ?? item.timestamp ?? fallbackTimestamp).toISOString(); + let entry: SessionEntry | undefined; + if (item.message) { + const messageEntry: SessionMessageEntry = { + type: "message", + id, + parentId, + timestamp, + message: item.message, + }; + entry = messageEntry; + } else if (item.model) { + const modelEntry: ModelChangeEntry = { + type: "model_change", + id, + parentId, + timestamp, + model: `openai-codex/${item.model}`, + }; + entry = modelEntry; + } + if (!entry) continue; + manager.ingestReplicatedEntry(entry); + parentId = id; + } + if (title) await manager.setSessionName(title, "auto", "codex-import"); + return manager; + } +} diff --git a/packages/coding-agent/src/session/foreign-session-import.ts b/packages/coding-agent/src/session/foreign-session-import.ts new file mode 100644 index 000000000..b6f0ca0af --- /dev/null +++ b/packages/coding-agent/src/session/foreign-session-import.ts @@ -0,0 +1,52 @@ +import { directoryExists } from "@oh-my-pi/pi-utils"; +import { ClaudeSessionStore } from "./claude-session-store"; +import { CodexSessionStore } from "./codex-session-store"; +import type { ForeignSessionInfo, ForeignSessionSource, ForeignSessionStore } from "./foreign-session-store"; +import type { SessionInfo } from "./session-listing"; +import type { SessionManager } from "./session-manager"; + +/** Construct the importer for a supported foreign session source. */ +export function createForeignSessionStore(source: ForeignSessionSource): ForeignSessionStore { + return source === "claude" ? new ClaudeSessionStore() : new CodexSessionStore(); +} + +/** Display name for a supported foreign session source. */ +export function foreignSessionSourceName(source: ForeignSessionSource): string { + return source === "claude" ? "Claude" : "Codex"; +} + +/** Convert lightweight foreign metadata for the existing session picker. */ +export function foreignSessionInfoToSessionInfo(info: ForeignSessionInfo): SessionInfo { + const firstMessage = info.firstMessage ?? "(no messages)"; + return { + path: info.path, + id: info.id, + cwd: info.cwd, + title: info.title, + created: info.created, + modified: info.modified, + messageCount: info.messageCount ?? 0, + size: 0, + firstMessage, + allMessagesText: firstMessage, + }; +} + +/** Import and persist one foreign session under a fresh OMP session identity. */ +export async function persistForeignSession( + store: ForeignSessionStore, + info: ForeignSessionInfo, + options?: { fallbackCwd?: string; sessionDir?: string; suppressBreadcrumb?: boolean }, +): Promise { + const imported = await store.load(info); + imported.appendCustomEntry("foreign_session_import", { + source: info.source, + sourceId: info.id, + sourcePath: info.path, + sourceCwd: info.cwd, + }); + if (options?.fallbackCwd && !(await directoryExists(imported.getCwd()))) { + await imported.moveTo(options.fallbackCwd); + } + return await imported.persistCopy(options); +} diff --git a/packages/coding-agent/src/session/foreign-session-jsonl.ts b/packages/coding-agent/src/session/foreign-session-jsonl.ts new file mode 100644 index 000000000..94b10bd6d --- /dev/null +++ b/packages/coding-agent/src/session/foreign-session-jsonl.ts @@ -0,0 +1,29 @@ +import { isRecord, readLines } from "@oh-my-pi/pi-utils"; + +/** One readable object record from a foreign JSONL transcript. */ +export interface ForeignJsonRecord { + readonly value: Record; + readonly line: number; +} + +/** Stream valid object records while tolerating malformed or truncated lines. */ +export async function* readForeignJsonRecords(filePath: string): AsyncGenerator { + const decoder = new TextDecoder(); + let line = 0; + for await (const bytes of readLines(Bun.file(filePath).stream())) { + line += 1; + try { + const value: unknown = JSON.parse(decoder.decode(bytes)); + if (isRecord(value)) yield { value, line }; + } catch { + // A partially-written line must not hide the readable transcript prefix. + } + } +} + +/** Read every valid object record from a foreign JSONL transcript. */ +export async function collectForeignJsonRecords(filePath: string): Promise { + const records: ForeignJsonRecord[] = []; + for await (const record of readForeignJsonRecords(filePath)) records.push(record); + return records; +} diff --git a/packages/coding-agent/src/session/foreign-session-store.ts b/packages/coding-agent/src/session/foreign-session-store.ts new file mode 100644 index 000000000..c6a11a238 --- /dev/null +++ b/packages/coding-agent/src/session/foreign-session-store.ts @@ -0,0 +1,26 @@ +import type { SessionManager } from "./session-manager"; + +/** External coding-agent session source supported by OMP imports. */ +export type ForeignSessionSource = "claude" | "codex"; + +/** Lightweight source metadata used to choose a foreign session before loading its transcript. */ +export interface ForeignSessionInfo { + readonly source: ForeignSessionSource; + readonly id: string; + readonly path: string; + readonly cwd: string; + readonly title?: string; + readonly created: Date; + readonly modified: Date; + readonly messageCount?: number; + readonly firstMessage?: string; +} + +/** Lists and converts sessions owned by another coding agent. */ +export interface ForeignSessionStore { + readonly source: ForeignSessionSource; + /** Lists source sessions without parsing complete transcripts. */ + list(): Promise; + /** Converts one source session into a non-persistent OMP session. */ + load(session: ForeignSessionInfo): Promise; +} diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 4c971bab7..083d6cd2a 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1420,6 +1420,30 @@ export class SessionManager { await this.#rewriteAtomically(); } + /** Persist this session's transcript as a newly identified OMP session. */ + async persistCopy( + options?: { sessionDir?: string; suppressBreadcrumb?: boolean }, + storage: SessionStorage = new FileSessionStorage(), + ): Promise { + const sessionDir = options?.sessionDir ?? SessionManager.getDefaultSessionDir(this.#cwd, undefined, storage); + const manager = new SessionManager(this.#cwd, sessionDir, true, storage); + manager.#suppressBreadcrumb = options?.suppressBreadcrumb === true; + manager.#resetToNewSession(); + manager.#sessionName = this.#sessionName; + manager.#titleSource = this.#titleSource; + manager.#titleUpdatedAt = this.#titleUpdatedAt; + manager.#header.title = this.#sessionName; + manager.#header.titleSource = this.#titleSource; + manager.#additionalDirectories = [...this.#additionalDirectories]; + manager.#header.additionalDirectories = + manager.#additionalDirectories.length > 0 ? [...manager.#additionalDirectories] : undefined; + manager.#entries = structuredClone(this.#entries); + manager.#index.rebuild(manager.#entries); + manager.#forceFileCreation = true; + await manager.#rewriteAtomically(); + return manager; + } + /** * Stage a synchronous group of entry appends and publish the resulting full * journal with one atomic replace. A failed publish removes only the staged diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index 044e77b11..27f469884 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -1792,11 +1792,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "resume", description: "Resume a different session", - inlineHint: "[session id]", + inlineHint: "[session id|@claude|@codex]", allowArgs: true, handleTui: async (command, runtime) => { const sessionArg = command.args.trim(); runtime.ctx.editor.setText(""); + const foreignSource = sessionArg === "@claude" ? "claude" : sessionArg === "@codex" ? "codex" : undefined; + if (foreignSource) { + runtime.ctx.showSessionSelector(foreignSource); + return; + } if (!sessionArg) { runtime.ctx.showSessionSelector(); return; diff --git a/packages/coding-agent/test/flag-tables.test.ts b/packages/coding-agent/test/flag-tables.test.ts index 3a359bfd8..b1f6b1a6a 100644 --- a/packages/coding-agent/test/flag-tables.test.ts +++ b/packages/coding-agent/test/flag-tables.test.ts @@ -130,3 +130,17 @@ describe("parseArgs @file parsing with quotes", () => { expect(result.fileArgs).toEqual(["foo bar.png"]); }); }); + +describe("foreign session import flags", () => { + it("parses each source flag without consuming the initial message", () => { + const claude = parseArgs(["--from-claude", "continue this session"]); + const codex = parseArgs(["--from-codex", "continue this session"]); + + expect(claude.fromClaude).toBe(true); + expect(claude.messages).toEqual(["continue this session"]); + expect(claude.unrecognizedFlags).toEqual([]); + expect(codex.fromCodex).toBe(true); + expect(codex.messages).toEqual(["continue this session"]); + expect(codex.unrecognizedFlags).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/foreign-session-stores.test.ts b/packages/coding-agent/test/foreign-session-stores.test.ts new file mode 100644 index 000000000..52eb0c97c --- /dev/null +++ b/packages/coding-agent/test/foreign-session-stores.test.ts @@ -0,0 +1,268 @@ +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { ClaudeSessionStore } from "../src/session/claude-session-store"; +import { CodexSessionStore } from "../src/session/codex-session-store"; +import { persistForeignSession } from "../src/session/foreign-session-import"; +import type { ForeignSessionInfo } from "../src/session/foreign-session-store"; +import { SessionManager } from "../src/session/session-manager"; +import { FileSessionStorage } from "../src/session/session-storage"; + +let tempRoot: string; + +beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-foreign-sessions-")); +}); + +afterEach(async () => { + await fs.rm(tempRoot, { recursive: true, force: true }); +}); + +async function writeJsonl(filePath: string, records: Record[]): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await Bun.write(filePath, `${records.map(record => JSON.stringify(record)).join("\n")}\n`); +} + +async function createClaudeFixture(): Promise<{ info: ForeignSessionInfo; store: ClaudeSessionStore }> { + const root = path.join(tempRoot, ".claude"); + const cwd = path.join(tempRoot, "project-with-hyphen"); + const id = "11111111-1111-4111-8111-111111111111"; + const projectDirectory = cwd.replaceAll(path.sep, "-"); + const sessionPath = path.join(root, "projects", projectDirectory, `${id}.jsonl`); + await writeJsonl(path.join(root, "history.jsonl"), [ + { sessionId: id, timestamp: 1_767_225_600_000, display: "First prompt", project: cwd }, + { sessionId: id, timestamp: 1_767_225_603_000, display: "Second prompt", project: cwd }, + ]); + await writeJsonl(sessionPath, [ + { + type: "user", + uuid: "claude-user", + parentUuid: null, + timestamp: "2026-01-01T00:00:00.000Z", + cwd, + message: { content: "First prompt" }, + }, + { + type: "assistant", + uuid: "claude-assistant", + parentUuid: "claude-user", + timestamp: "2026-01-01T00:00:01.000Z", + message: { + id: "msg_claude", + model: "claude-sonnet-4-5", + stop_reason: "tool_use", + usage: { input_tokens: 10, output_tokens: 5 }, + content: [ + { type: "thinking", thinking: "Inspect the file", signature: "sig" }, + { type: "text", text: "I will inspect it." }, + { type: "tool_use", id: "tool-claude", name: "read", input: { path: "file.ts" } }, + ], + }, + }, + { + type: "user", + uuid: "claude-result", + parentUuid: "claude-assistant", + timestamp: "2026-01-01T00:00:02.000Z", + message: { + content: [{ type: "tool_result", tool_use_id: "tool-claude", content: "file contents" }], + }, + }, + { type: "custom-title", customTitle: "Imported Claude", timestamp: "2026-01-01T00:00:03.000Z" }, + ]); + const store = new ClaudeSessionStore(root); + const info = (await store.list())[0]; + if (!info) throw new Error("Claude fixture was not listed"); + return { info, store }; +} + +describe("ClaudeSessionStore", () => { + it("uses current history metadata and converts linked messages in memory", async () => { + const { info, store } = await createClaudeFixture(); + + expect(info.firstMessage).toBe("First prompt"); + expect(info.messageCount).toBe(2); + expect(info.cwd).toBe(path.join(tempRoot, "project-with-hyphen")); + const manager = await store.load(info); + expect(manager.getSessionFile()).toBeUndefined(); + expect(manager.getSessionName()).toBe("Imported Claude"); + const entries = manager.getEntries(); + const messages = entries.filter(entry => entry.type === "message"); + expect(messages.map(entry => entry.message.role)).toEqual(["user", "assistant", "toolResult"]); + const assistant = messages.find(entry => entry.message.role === "assistant"); + if (assistant?.message.role !== "assistant") throw new Error("Missing imported Claude assistant"); + const call = assistant.message.content.find(block => block.type === "toolCall"); + expect(call).toMatchObject({ id: "tool-claude", name: "read", arguments: { path: "file.ts" } }); + const result = messages.find(entry => entry.message.role === "toolResult"); + if (result?.message.role !== "toolResult") throw new Error("Missing imported Claude tool result"); + expect(result.message.toolCallId).toBe("tool-claude"); + expect( + entries.some(entry => entry.type === "model_change" && entry.model === "anthropic/claude-sonnet-4-5"), + ).toBe(true); + expect(messages[0]?.timestamp).toBe("2026-01-01T00:00:00.000Z"); + }); + + it("recognizes legacy history keys and .projects storage", async () => { + const root = path.join(tempRoot, ".claude"); + const cwd = path.join(tempRoot, "legacy"); + const id = "22222222-2222-4222-8222-222222222222"; + const sessionPath = path.join(root, ".projects", cwd.replaceAll(path.sep, "-"), `${id}.jsonl`); + await writeJsonl(path.join(root, "history.jsonl"), [ + { session_id: id, ts: 1_735_689_600_000, text: "Legacy prompt" }, + ]); + await writeJsonl(sessionPath, [ + { + type: "user", + uuid: "legacy-user", + parentUuid: null, + timestamp: "2025-01-01T00:00:00.000Z", + cwd, + message: { content: "Legacy prompt" }, + }, + ]); + + const sessions = await new ClaudeSessionStore(root).list(); + + expect(sessions).toHaveLength(1); + expect(sessions[0]?.firstMessage).toBe("Legacy prompt"); + expect(sessions[0]?.created.toISOString()).toBe("2025-01-01T00:00:00.000Z"); + }); +}); + +describe("CodexSessionStore", () => { + it("lists current indexed sessions without opening rollout files", async () => { + const root = path.join(tempRoot, ".codex"); + await fs.mkdir(root, { recursive: true }); + const database = new Database(path.join(root, "state_5.sqlite")); + try { + database.run( + "CREATE TABLE threads (id TEXT, rollout_path TEXT, created_at INTEGER, updated_at INTEGER, cwd TEXT, title TEXT, first_user_message TEXT)", + ); + database.run("INSERT INTO threads VALUES (?, ?, ?, ?, ?, ?, ?)", [ + "33333333-3333-4333-8333-333333333333", + "sessions/missing.jsonl", + 1_767_225_600, + 1_767_225_660, + path.join(tempRoot, "codex-project"), + "Indexed Codex", + "Indexed prompt", + ]); + } finally { + database.close(); + } + + const sessions = await new CodexSessionStore(root).list(); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + id: "33333333-3333-4333-8333-333333333333", + title: "Indexed Codex", + firstMessage: "Indexed prompt", + }); + expect(sessions[0]?.modified.toISOString()).toBe("2026-01-01T00:01:00.000Z"); + }); + + it("lists and converts legacy rollouts with complete tool linkage", async () => { + const root = path.join(tempRoot, ".codex"); + const cwd = path.join(tempRoot, "codex-project"); + const id = "44444444-4444-4444-8444-444444444444"; + const sessionPath = path.join(root, ".sessions", "2025", "01", "01", `rollout-${id}.jsonl`); + await writeJsonl(path.join(root, "session_index.jsonl"), [ + { id, thread_name: "Legacy Codex", updated_at: "2025-01-01T00:00:09.000Z" }, + ]); + await writeJsonl(sessionPath, [ + { type: "session_meta", timestamp: "2025-01-01T00:00:00.000Z", payload: { id, cwd } }, + { type: "turn_context", timestamp: "2025-01-01T00:00:01.000Z", payload: { model: "gpt-5.3-codex" } }, + { + type: "response_item", + timestamp: "2025-01-01T00:00:02.000Z", + payload: { type: "message", role: "user", content: [{ type: "input_text", text: "Inspect" }] }, + }, + { + type: "response_item", + timestamp: "2025-01-01T00:00:03.000Z", + payload: { type: "reasoning", summary: [{ type: "summary_text", text: "Plan" }] }, + }, + { + type: "response_item", + timestamp: "2025-01-01T00:00:04.000Z", + payload: { type: "function_call", call_id: "call-codex", name: "read", arguments: '{"path":"file.ts"}' }, + }, + { + type: "response_item", + timestamp: "2025-01-01T00:00:05.000Z", + payload: { type: "function_call_output", call_id: "call-codex", output: "file contents" }, + }, + { + type: "event_msg", + timestamp: "2025-01-01T00:00:06.000Z", + payload: { type: "web_search_end", call_id: "call-web", query: "docs", action: { query: "docs" } }, + }, + { + type: "response_item", + timestamp: "2025-01-01T00:00:07.000Z", + payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: "Done" }] }, + }, + { + type: "event_msg", + timestamp: "2025-01-01T00:00:08.000Z", + payload: { type: "thread_name_updated", thread_name: "Imported Codex" }, + }, + ]); + const store = new CodexSessionStore(root); + const info = (await store.list())[0]; + if (!info) throw new Error("Codex fixture was not listed"); + + const manager = await store.load(info); + + expect(info.title).toBe("Legacy Codex"); + expect(manager.getSessionFile()).toBeUndefined(); + expect(manager.getSessionName()).toBe("Imported Codex"); + const entries = manager.getEntries(); + expect(entries.some(entry => entry.type === "model_change" && entry.model === "openai-codex/gpt-5.3-codex")).toBe( + true, + ); + const calls = new Set(); + for (const entry of entries) { + if (entry.type !== "message" || entry.message.role !== "assistant") continue; + for (const block of entry.message.content) if (block.type === "toolCall") calls.add(block.id); + } + let resultCount = 0; + for (const entry of entries) { + if (entry.type !== "message" || entry.message.role !== "toolResult") continue; + resultCount += 1; + expect(calls.has(entry.message.toolCallId)).toBe(true); + } + expect(calls).toEqual(new Set(["call-codex", "call-web"])); + expect(resultCount).toBe(2); + }); +}); + +describe("foreign session persistence", () => { + it("writes a fresh OMP identity with source provenance", async () => { + const { info, store } = await createClaudeFixture(); + const sessionDir = path.join(tempRoot, "omp-sessions"); + + const persisted = await persistForeignSession(store, info, { sessionDir, suppressBreadcrumb: true }); + const sessionFile = persisted.getSessionFile(); + if (!sessionFile) throw new Error("Imported session was not persisted"); + await persisted.close(); + const reopened = await SessionManager.open(sessionFile, sessionDir, new FileSessionStorage(), { + suppressBreadcrumb: true, + }); + try { + expect(reopened.getSessionId()).not.toBe(info.id); + expect(reopened.getSessionName()).toBe("Imported Claude"); + const provenance = reopened + .getEntries() + .find(entry => entry.type === "custom" && entry.customType === "foreign_session_import"); + expect(provenance).toMatchObject({ + data: { source: "claude", sourceId: info.id, sourcePath: info.path, sourceCwd: info.cwd }, + }); + } finally { + await reopened.close(); + } + }); +}); diff --git a/packages/coding-agent/test/slash-commands/resume.test.ts b/packages/coding-agent/test/slash-commands/resume.test.ts index c164f48be..8e41a8cd5 100644 --- a/packages/coding-agent/test/slash-commands/resume.test.ts +++ b/packages/coding-agent/test/slash-commands/resume.test.ts @@ -79,6 +79,23 @@ describe("/resume slash command", () => { expect(harness.handleResumeSession).not.toHaveBeenCalled(); }); + it("opens foreign session selectors for @claude and @codex", async () => { + const sources: Array<{ argument: string; source: "claude" | "codex" }> = [ + { argument: "@claude", source: "claude" }, + { argument: "@codex", source: "codex" }, + ]; + for (const { argument, source } of sources) { + const harness = createRuntime(); + + const handled = await executeBuiltinSlashCommand(`/resume ${argument}`, harness.runtime); + + expect(handled).toBe(true); + expect(harness.showSessionSelector).toHaveBeenCalledWith(source); + expect(harness.handleResumeSession).not.toHaveBeenCalled(); + expect(harness.showError).not.toHaveBeenCalled(); + } + }); + it("resumes a matching session id prefix", async () => { const sessionPath = await writeSession("019ed676-02fb-7000-8dac-396e2f84d484"); const harness = createRuntime();