Created
April 15, 2026 13:06
-
-
Save iRonin/1e3efb5353d5159e95bf634d8f538e7b to your computer and use it in GitHub Desktop.
PR #2962: add /clone command to duplicate current session
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts | |
| index 58e1fd57..b9866ed7 100644 | |
| --- a/packages/coding-agent/src/core/agent-session-runtime.ts | |
| +++ b/packages/coding-agent/src/core/agent-session-runtime.ts | |
| @@ -248,6 +248,65 @@ export class AgentSessionRuntime { | |
| return { cancelled: false, selectedText }; | |
| } | |
| + /** | |
| + * Fork the current session at the current leaf position ("clone session"). | |
| + * Unlike fork() which goes back to a user message's parent, | |
| + * this creates a new session with the full branch up to and | |
| + * including the current leaf, with an empty editor. | |
| + */ | |
| + async forkCurrent(): Promise<{ cancelled: boolean }> { | |
| + const beforeResult = await this.emitBeforeFork("current"); | |
| + if (beforeResult.cancelled) { | |
| + return { cancelled: true }; | |
| + } | |
| + | |
| + const previousSessionFile = this.session.sessionFile; | |
| + if (this.session.sessionManager.isPersisted()) { | |
| + const currentSessionFile = this.session.sessionFile; | |
| + if (!currentSessionFile) { | |
| + throw new Error("Persisted session is missing a session file"); | |
| + } | |
| + const sessionDir = this.session.sessionManager.getSessionDir(); | |
| + const sourceManager = SessionManager.open(currentSessionFile, sessionDir); | |
| + const leafId = sourceManager.getLeafId(); | |
| + if (!leafId) { | |
| + throw new Error("Cannot clone: session has no messages"); | |
| + } | |
| + const forkedSessionPath = sourceManager.createBranchedSession(leafId); | |
| + if (!forkedSessionPath) { | |
| + throw new Error("Failed to create forked session"); | |
| + } | |
| + const sessionManager = SessionManager.open(forkedSessionPath, sessionDir); | |
| + await this.teardownCurrent(); | |
| + this.apply( | |
| + await this.createRuntime({ | |
| + cwd: sessionManager.getCwd(), | |
| + agentDir: this.services.agentDir, | |
| + sessionManager, | |
| + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, | |
| + }), | |
| + ); | |
| + return { cancelled: false }; | |
| + } | |
| + | |
| + const sessionManager = this.session.sessionManager; | |
| + const leafId = sessionManager.getLeafId(); | |
| + if (!leafId) { | |
| + throw new Error("Cannot clone: session has no messages"); | |
| + } | |
| + sessionManager.createBranchedSession(leafId); | |
| + await this.teardownCurrent(); | |
| + this.apply( | |
| + await this.createRuntime({ | |
| + cwd: this.cwd, | |
| + agentDir: this.services.agentDir, | |
| + sessionManager, | |
| + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, | |
| + }), | |
| + ); | |
| + return { cancelled: false }; | |
| + } | |
| + | |
| async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { | |
| const resolvedPath = resolve(inputPath); | |
| if (!existsSync(resolvedPath)) { | |
| diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts | |
| index f2257faa..141ba0ae 100644 | |
| --- a/packages/coding-agent/src/core/extensions/runner.ts | |
| +++ b/packages/coding-agent/src/core/extensions/runner.ts | |
| @@ -144,6 +144,7 @@ export type NewSessionHandler = (options?: { | |
| }) => Promise<{ cancelled: boolean }>; | |
| export type ForkHandler = (entryId: string) => Promise<{ cancelled: boolean }>; | |
| +export type ForkCurrentHandler = () => Promise<{ cancelled: boolean }>; | |
| export type NavigateTreeHandler = ( | |
| targetId: string, | |
| @@ -218,6 +219,7 @@ export class ExtensionRunner { | |
| private getSystemPromptFn: () => string = () => ""; | |
| private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); | |
| private forkHandler: ForkHandler = async () => ({ cancelled: false }); | |
| + private forkCurrentHandler: ForkCurrentHandler = async () => ({ cancelled: false }); | |
| private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); | |
| private switchSessionHandler: SwitchSessionHandler = async () => ({ cancelled: false }); | |
| private reloadHandler: ReloadHandler = async () => {}; | |
| @@ -317,6 +319,7 @@ export class ExtensionRunner { | |
| this.waitForIdleFn = actions.waitForIdle; | |
| this.newSessionHandler = actions.newSession; | |
| this.forkHandler = actions.fork; | |
| + this.forkCurrentHandler = actions.forkCurrent; | |
| this.navigateTreeHandler = actions.navigateTree; | |
| this.switchSessionHandler = actions.switchSession; | |
| this.reloadHandler = actions.reload; | |
| @@ -326,6 +329,7 @@ export class ExtensionRunner { | |
| this.waitForIdleFn = async () => {}; | |
| this.newSessionHandler = async () => ({ cancelled: false }); | |
| this.forkHandler = async () => ({ cancelled: false }); | |
| + this.forkCurrentHandler = async () => ({ cancelled: false }); | |
| this.navigateTreeHandler = async () => ({ cancelled: false }); | |
| this.switchSessionHandler = async () => ({ cancelled: false }); | |
| this.reloadHandler = async () => {}; | |
| @@ -560,6 +564,7 @@ export class ExtensionRunner { | |
| waitForIdle: () => this.waitForIdleFn(), | |
| newSession: (options) => this.newSessionHandler(options), | |
| fork: (entryId) => this.forkHandler(entryId), | |
| + forkCurrent: () => this.forkCurrentHandler(), | |
| navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options), | |
| switchSession: (sessionPath) => this.switchSessionHandler(sessionPath), | |
| reload: () => this.reloadHandler(), | |
| diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts | |
| index 2c20469f..a0663dee 100644 | |
| --- a/packages/coding-agent/src/core/extensions/types.ts | |
| +++ b/packages/coding-agent/src/core/extensions/types.ts | |
| @@ -310,6 +310,9 @@ export interface ExtensionCommandContext extends ExtensionContext { | |
| /** Fork from a specific entry, creating a new session file. */ | |
| fork(entryId: string): Promise<{ cancelled: boolean }>; | |
| + /** Clone the current session at the current leaf position into a new session file. */ | |
| + forkCurrent(): Promise<{ cancelled: boolean }>; | |
| + | |
| /** Navigate to a different point in the session tree. */ | |
| navigateTree( | |
| targetId: string, | |
| @@ -1403,6 +1406,7 @@ export interface ExtensionCommandContextActions { | |
| setup?: (sessionManager: SessionManager) => Promise<void>; | |
| }) => Promise<{ cancelled: boolean }>; | |
| fork: (entryId: string) => Promise<{ cancelled: boolean }>; | |
| + forkCurrent: () => Promise<{ cancelled: boolean }>; | |
| navigateTree: ( | |
| targetId: string, | |
| options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, | |
| diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts | |
| index 803f20a8..7263b08e 100644 | |
| --- a/packages/coding-agent/src/core/slash-commands.ts | |
| +++ b/packages/coding-agent/src/core/slash-commands.ts | |
| @@ -27,6 +27,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [ | |
| { name: "changelog", description: "Show changelog entries" }, | |
| { name: "hotkeys", description: "Show all keyboard shortcuts" }, | |
| { name: "fork", description: "Create a new fork from a previous message" }, | |
| + { name: "clone", description: "Clone current session into a new session file" }, | |
| { name: "tree", description: "Navigate session tree (switch branches)" }, | |
| { name: "login", description: "Login with OAuth provider" }, | |
| { name: "logout", description: "Logout from OAuth provider" }, | |
| diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts | |
| index a1aaadaa..d6ccd014 100644 | |
| --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts | |
| +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts | |
| @@ -1246,6 +1246,19 @@ export class InteractiveMode { | |
| return this.handleFatalRuntimeError("Failed to fork session", error); | |
| } | |
| }, | |
| + forkCurrent: async () => { | |
| + try { | |
| + const result = await this.runtimeHost.forkCurrent(); | |
| + if (!result.cancelled) { | |
| + await this.handleRuntimeSessionChange(); | |
| + this.renderCurrentSessionState(); | |
| + this.showStatus("Cloned session"); | |
| + } | |
| + return { cancelled: result.cancelled }; | |
| + } catch (error: unknown) { | |
| + return this.handleFatalRuntimeError("Failed to clone session", error); | |
| + } | |
| + }, | |
| navigateTree: async (targetId, options) => { | |
| const result = await this.session.navigateTree(targetId, { | |
| summarize: options?.summarize, | |
| @@ -2207,6 +2220,11 @@ export class InteractiveMode { | |
| this.editor.setText(""); | |
| return; | |
| } | |
| + if (text === "/clone") { | |
| + await this.handleCloneCommand(); | |
| + this.editor.setText(""); | |
| + return; | |
| + } | |
| if (text === "/tree") { | |
| this.showTreeSelector(); | |
| this.editor.setText(""); | |
| @@ -4594,6 +4612,17 @@ export class InteractiveMode { | |
| } | |
| } | |
| + private async handleCloneCommand(): Promise<void> { | |
| + const result = await this.runtimeHost.forkCurrent(); | |
| + if (result.cancelled) { | |
| + this.ui.requestRender(); | |
| + return; | |
| + } | |
| + await this.handleRuntimeSessionChange(); | |
| + this.renderCurrentSessionState(); | |
| + this.showStatus("Cloned session"); | |
| + } | |
| + | |
| private handleDebugCommand(): void { | |
| const width = this.ui.terminal.columns; | |
| const height = this.ui.terminal.rows; | |
| diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts | |
| index 235bffa7..ada5ba37 100644 | |
| --- a/packages/coding-agent/src/modes/print-mode.ts | |
| +++ b/packages/coding-agent/src/modes/print-mode.ts | |
| @@ -53,6 +53,13 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr | |
| } | |
| return { cancelled: result.cancelled }; | |
| }, | |
| + forkCurrent: async () => { | |
| + const result = await runtimeHost.forkCurrent(); | |
| + if (!result.cancelled) { | |
| + await rebindSession(); | |
| + } | |
| + return { cancelled: result.cancelled }; | |
| + }, | |
| navigateTree: async (targetId, navigateOptions) => { | |
| const result = await session.navigateTree(targetId, { | |
| summarize: navigateOptions?.summarize, | |
| diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts | |
| index fce737bf..b711b69e 100644 | |
| --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts | |
| +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts | |
| @@ -342,6 +342,14 @@ export class RpcClient { | |
| return this.getData(response); | |
| } | |
| + /** | |
| + * Clone the current session at the current leaf position. | |
| + */ | |
| + async clone(): Promise<{ cancelled: boolean }> { | |
| + const response = await this.send({ type: "clone" }); | |
| + return this.getData(response); | |
| + } | |
| + | |
| /** | |
| * Get messages available for forking. | |
| */ | |
| diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts | |
| index be562e69..e77292bd 100644 | |
| --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts | |
| +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts | |
| @@ -302,6 +302,13 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve | |
| } | |
| return { cancelled: result.cancelled }; | |
| }, | |
| + forkCurrent: async () => { | |
| + const result = await runtimeHost.forkCurrent(); | |
| + if (!result.cancelled) { | |
| + await rebindSession(); | |
| + } | |
| + return { cancelled: result.cancelled }; | |
| + }, | |
| navigateTree: async (targetId, options) => { | |
| const result = await session.navigateTree(targetId, { | |
| summarize: options?.summarize, | |
| @@ -537,6 +544,14 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve | |
| return success(id, "fork", { text: result.selectedText, cancelled: result.cancelled }); | |
| } | |
| + case "clone": { | |
| + const result = await runtimeHost.forkCurrent(); | |
| + if (!result.cancelled) { | |
| + await rebindSession(); | |
| + } | |
| + return success(id, "clone", { cancelled: result.cancelled }); | |
| + } | |
| + | |
| case "get_fork_messages": { | |
| const messages = session.getUserMessagesForForking(); | |
| return success(id, "get_fork_messages", { messages }); | |
| diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts | |
| index 5612f370..54e0b9dd 100644 | |
| --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts | |
| +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts | |
| @@ -57,6 +57,7 @@ export type RpcCommand = | |
| | { id?: string; type: "export_html"; outputPath?: string } | |
| | { id?: string; type: "switch_session"; sessionPath: string } | |
| | { id?: string; type: "fork"; entryId: string } | |
| + | { id?: string; type: "clone" } | |
| | { id?: string; type: "get_fork_messages" } | |
| | { id?: string; type: "get_last_assistant_text" } | |
| | { id?: string; type: "set_session_name"; name: string } | |
| @@ -172,6 +173,7 @@ export type RpcResponse = | |
| | { id?: string; type: "response"; command: "export_html"; success: true; data: { path: string } } | |
| | { id?: string; type: "response"; command: "switch_session"; success: true; data: { cancelled: boolean } } | |
| | { id?: string; type: "response"; command: "fork"; success: true; data: { text: string; cancelled: boolean } } | |
| + | { id?: string; type: "response"; command: "clone"; success: true; data: { cancelled: boolean } } | |
| | { | |
| id?: string; | |
| type: "response"; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment