Skip to content

Instantly share code, notes, and snippets.

@iRonin
Created April 15, 2026 13:06
Show Gist options
  • Select an option

  • Save iRonin/1e3efb5353d5159e95bf634d8f538e7b to your computer and use it in GitHub Desktop.

Select an option

Save iRonin/1e3efb5353d5159e95bf634d8f538e7b to your computer and use it in GitHub Desktop.
PR #2962: add /clone command to duplicate current session
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