From b135c4567dc19cc0c422fd9cabddeef14058df50 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 28 Jan 2026 01:17:58 +0100 Subject: [PATCH] feat(coding-agent): added /fork command to duplicate current session Creates a new session file with the exact same state as the current session, including all entries (messages, model changes, compactions, etc.) and artifacts. The new session's parentSession field links to the original. - Added fork() method to SessionManager that creates new session file with copied entries - Added fork() method to AgentSession that handles artifact directory copying - Added /fork command handler in input-controller and command-controller - Extended session switch event reason type to include 'fork' --- packages/coding-agent/CHANGELOG.md | 3 + .../src/extensibility/extensions/types.ts | 4 +- .../src/extensibility/hooks/types.ts | 4 +- .../modes/controllers/command-controller.ts | 25 +++++++ .../src/modes/controllers/input-controller.ts | 5 ++ .../src/modes/interactive-mode.ts | 4 ++ packages/coding-agent/src/modes/types.ts | 1 + .../coding-agent/src/session/agent-session.ts | 69 +++++++++++++++++++ .../src/session/session-manager.ts | 49 +++++++++++++ 9 files changed, 160 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 05c9bc75e..c4039fb63 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added +- Added `/fork` command to create a new session with the exact same state (entries and artifacts) as the current session + ## [8.6.0] - 2026-01-27 ### Added diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 58664c77a..667b719bc 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -268,14 +268,14 @@ export interface SessionStartEvent { /** Fired before switching to another session (can be cancelled) */ export interface SessionBeforeSwitchEvent { type: "session_before_switch"; - reason: "new" | "resume"; + reason: "new" | "resume" | "fork"; targetSessionFile?: string; } /** Fired after switching to another session */ export interface SessionSwitchEvent { type: "session_switch"; - reason: "new" | "resume"; + reason: "new" | "resume" | "fork"; previousSessionFile: string | undefined; } diff --git a/packages/coding-agent/src/extensibility/hooks/types.ts b/packages/coding-agent/src/extensibility/hooks/types.ts index 8dd72d3fb..73de9419e 100644 --- a/packages/coding-agent/src/extensibility/hooks/types.ts +++ b/packages/coding-agent/src/extensibility/hooks/types.ts @@ -220,7 +220,7 @@ export interface SessionStartEvent { export interface SessionBeforeSwitchEvent { type: "session_before_switch"; /** Reason for the switch */ - reason: "new" | "resume"; + reason: "new" | "resume" | "fork"; /** Session file we're switching to (only for "resume") */ targetSessionFile?: string; } @@ -229,7 +229,7 @@ export interface SessionBeforeSwitchEvent { export interface SessionSwitchEvent { type: "session_switch"; /** Reason for the switch */ - reason: "new" | "resume"; + reason: "new" | "resume" | "fork"; /** Session file we came from */ previousSessionFile: string | undefined; } diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 3fb9fc919..aa64cd50f 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -418,6 +418,31 @@ export class CommandController { this.ctx.ui.requestRender(); } + async handleForkCommand(): Promise { + if (this.ctx.loadingAnimation) { + this.ctx.loadingAnimation.stop(); + this.ctx.loadingAnimation = undefined; + } + this.ctx.statusContainer.clear(); + + const success = await this.ctx.session.fork(); + if (!success) { + this.ctx.showError("Fork failed (session not persisted or cancelled)"); + return; + } + + this.ctx.statusLine.invalidate(); + this.ctx.updateEditorTopBorder(); + + const sessionFile = this.ctx.session.sessionFile; + const shortPath = sessionFile ? sessionFile.split("/").pop() : "new session"; + this.ctx.chatContainer.addChild(new Spacer(1)); + this.ctx.chatContainer.addChild( + new Text(`${theme.fg("accent", `${theme.status.success} Session forked to ${shortPath}`)}`, 1, 1), + ); + this.ctx.ui.requestRender(); + } + async handleDebugCommand(): Promise { const width = this.ctx.ui.terminal.columns; const allLines = this.ctx.ui.render(width); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index cd3c1dd9d..5f8ca2d63 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -269,6 +269,11 @@ export class InputController { await this.ctx.handleClearCommand(); return; } + if (text === "/fork") { + this.ctx.editor.setText(""); + await this.ctx.handleForkCommand(); + return; + } if (text === "/compact" || text.startsWith("/compact ")) { const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined; this.ctx.editor.setText(""); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 721e21141..a3d82d701 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -855,6 +855,10 @@ export class InteractiveMode implements InteractiveModeContext { return this.commandController.handleClearCommand(); } + handleForkCommand(): Promise { + return this.commandController.handleForkCommand(); + } + handleDebugCommand(): Promise { return this.commandController.handleDebugCommand(); } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 92974d392..03fb07ce8 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -143,6 +143,7 @@ export interface InteractiveModeContext { handleHotkeysCommand(): void; handleDumpCommand(): Promise; handleClearCommand(): Promise; + handleForkCommand(): Promise; handleDebugCommand(): Promise; handleArminSaysHi(): void; handleBashCommand(command: string, excludeFromContext?: boolean): Promise; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 9aa826f1b..15d43abb6 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -1658,6 +1658,75 @@ export class AgentSession { return true; } + /** + * Fork the current session, creating a new session file with the exact same state. + * Copies all entries and artifacts to the new session. + * Unlike newSession(), this preserves all messages in the agent state. + * @returns true if completed, false if cancelled by hook or not persisting + */ + async fork(): Promise { + const previousSessionFile = this.sessionFile; + + // Emit session_before_switch event with reason "fork" (can be cancelled) + if (this._extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this._extensionRunner.emit({ + type: "session_before_switch", + reason: "fork", + })) as SessionBeforeSwitchResult | undefined; + + if (result?.cancel) { + return false; + } + } + + // Flush current session to ensure all entries are written + await this.sessionManager.flush(); + + // Fork the session (creates new session file with same entries) + const forkResult = await this.sessionManager.fork(); + if (!forkResult) { + return false; + } + + // Copy artifacts directory if it exists + const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6); + const newArtifactDir = forkResult.newSessionFile.slice(0, -6); + + try { + const oldDirStat = await fs.promises.stat(oldArtifactDir); + if (oldDirStat.isDirectory()) { + await fs.promises.cp(oldArtifactDir, newArtifactDir, { recursive: true }); + } + } catch (err) { + if (!isEnoent(err)) { + logger.warn("Failed to copy artifacts during fork", { + oldArtifactDir, + newArtifactDir, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // Update agent session ID + this.agent.sessionId = this.sessionManager.getSessionId(); + + // Clear queued messages (they would be duplicated otherwise) + this._steeringMessages = []; + this._followUpMessages = []; + this._pendingNextTurnMessages = []; + + // Emit session_switch event with reason "fork" to hooks + if (this._extensionRunner) { + await this._extensionRunner.emit({ + type: "session_switch", + reason: "fork", + previousSessionFile, + }); + } + + return true; + } + // ========================================================================= // Model Management // ========================================================================= diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index bd150d45f..94d09bd9d 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1030,6 +1030,55 @@ export class SessionManager { return this._newSessionSync(options); } + /** + * Fork the current session, creating a new session file with the same entries. + * Returns both the old and new session file paths for artifact copying. + * @returns { oldSessionFile, newSessionFile } or undefined if not persisting + */ + async fork(): Promise<{ oldSessionFile: string; newSessionFile: string } | undefined> { + if (!this.persist || !this.sessionFile) { + return undefined; + } + + const oldSessionFile = this.sessionFile; + const oldSessionId = this.sessionId; + + // Close the current writer + await this._closePersistWriter(); + this.persistChain = Promise.resolve(); + this.persistError = undefined; + this.persistErrorReported = false; + + // Create new session ID and header + this.sessionId = nanoid(); + const timestamp = new Date().toISOString(); + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + this.sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); + + // Update the header with new ID but keep all entries + const oldHeader = this.fileEntries.find(e => e.type === "session") as SessionHeader | undefined; + const newHeader: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: this.sessionId, + title: oldHeader?.title ?? this.sessionTitle, + timestamp, + cwd: this.cwd, + parentSession: oldSessionId, + }; + this.sessionTitle = newHeader.title; + + // Replace the header in fileEntries + const entries = this.fileEntries.filter(e => e.type !== "session") as SessionEntry[]; + this.fileEntries = [newHeader, ...entries]; + + // Write the new session file + this.flushed = false; + await this._rewriteFile(); + + return { oldSessionFile, newSessionFile: this.sessionFile }; + } + /** Sync version for initial creation (no existing writer to close) */ private _newSessionSync(options?: NewSessionOptions): string | undefined { this.persistChain = Promise.resolve();