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();