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:
@@ -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> {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user