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.
This commit is contained in:
can1357
2026-07-30 00:28:40 +02:00
parent 47d3317d32
commit f11641d5a8
20 changed files with 1756 additions and 80 deletions
@@ -724,6 +724,12 @@ export interface SessionSelectorOptions {
loadAllSessions?: () => Promise<SessionInfo[]>;
/** 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})`)}`;
}
/**
@@ -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<void> {
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<void> {
let sessions: SessionInfo[];
let onSelectSession: (session: SessionInfo) => Promise<boolean>;
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,
},
@@ -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<void> {
+2 -1
View File
@@ -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<void>;
handleSessionDeleteCommand(): Promise<void>;
showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void>;