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
@@ -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,
},