From fd385ecf064d5fda5d3dc0d08cfdd1ed1a3d7411 Mon Sep 17 00:00:00 2001 From: Helmut Januschka Date: Wed, 18 Mar 2026 22:37:11 +0100 Subject: [PATCH] feat(coding-agent): add JSONL export/import for sessions (#2356) fixes #2274 --- .../coding-agent/src/core/agent-session.ts | 68 ++++++++++++++++++- .../coding-agent/src/core/slash-commands.ts | 3 +- .../src/modes/interactive/interactive-mode.ts | 58 +++++++++++++++- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 713ebd45..ddca14c9 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -13,8 +13,8 @@ * Modes use this class and add their own I/O layer on top. */ -import { readFileSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; import type { Agent, AgentEvent, @@ -73,7 +73,7 @@ import type { ModelRegistry } from "./model-registry.js"; import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.js"; import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.js"; import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js"; -import { getLatestCompactionEntry } from "./session-manager.js"; +import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.js"; import type { SettingsManager } from "./settings-manager.js"; import { BUILTIN_SLASH_COMMANDS, type SlashCommandInfo, type SlashCommandLocation } from "./slash-commands.js"; import { buildSystemPrompt } from "./system-prompt.js"; @@ -3067,6 +3067,68 @@ export class AgentSession { }); } + /** + * Export the current session branch to a JSONL file. + * Writes the session header followed by all entries on the current branch path. + * @param outputPath Target file path. If omitted, generates a timestamped file in cwd. + * @returns The resolved output file path. + */ + exportToJsonl(outputPath?: string): string { + const filePath = resolve(outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`); + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const header: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: this.sessionManager.getSessionId(), + timestamp: new Date().toISOString(), + cwd: this.sessionManager.getCwd(), + }; + + const branchEntries = this.sessionManager.getBranch(); + const lines = [JSON.stringify(header)]; + + // Re-chain parentIds to form a linear sequence + let prevId: string | null = null; + for (const entry of branchEntries) { + const linear = { ...entry, parentId: prevId }; + lines.push(JSON.stringify(linear)); + prevId = entry.id; + } + + writeFileSync(filePath, `${lines.join("\n")}\n`); + return filePath; + } + + /** + * Import a JSONL session file. + * Copies the file into the session directory and switches to it (like /resume). + * @param inputPath Path to the JSONL file to import. + * @returns true if the session was switched successfully. + */ + async importFromJsonl(inputPath: string): Promise { + const resolved = resolve(inputPath); + if (!existsSync(resolved)) { + throw new Error(`File not found: ${resolved}`); + } + + // Copy into the session directory so we don't modify the original + const sessionDir = this.sessionManager.getSessionDir(); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + const destPath = join(sessionDir, basename(resolved)); + // Avoid overwriting if source and destination are the same file + if (resolve(destPath) !== resolved) { + copyFileSync(resolved, destPath); + } + + return this.switchSession(destPath); + } + // ========================================================================= // Utilities // ========================================================================= diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 01950bac..45735177 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -19,7 +19,8 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)" }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, - { name: "export", description: "Export session to HTML file" }, + { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, + { name: "import", description: "Import and resume a session from a JSONL file" }, { name: "share", description: "Share session as a secret GitHub gist" }, { name: "copy", description: "Copy last agent message to clipboard" }, { name: "name", description: "Set session display name" }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 89a6cde7..cf8444f6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1977,6 +1977,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text.startsWith("/import")) { + await this.handleImportCommand(text); + this.editor.setText(""); + return; + } if (text === "/share") { await this.handleShareCommand(); this.editor.setText(""); @@ -3909,13 +3914,62 @@ export class InteractiveMode { const outputPath = parts.length > 1 ? parts[1] : undefined; try { - const filePath = await this.session.exportToHtml(outputPath); - this.showStatus(`Session exported to: ${filePath}`); + if (outputPath?.endsWith(".jsonl")) { + const filePath = this.session.exportToJsonl(outputPath); + this.showStatus(`Session exported to: ${filePath}`); + } else { + const filePath = await this.session.exportToHtml(outputPath); + this.showStatus(`Session exported to: ${filePath}`); + } } catch (error: unknown) { this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); } } + private async handleImportCommand(text: string): Promise { + const parts = text.split(/\s+/); + if (parts.length < 2 || !parts[1]) { + this.showError("Usage: /import "); + return; + } + const inputPath = parts[1]; + + const confirmed = await this.showExtensionConfirm("Import session", `Replace current session with ${inputPath}?`); + if (!confirmed) { + this.showStatus("Import cancelled"); + return; + } + + try { + // Stop loading animation + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); + + // Clear UI state + this.pendingMessagesContainer.clear(); + this.compactionQueuedMessages = []; + this.streamingComponent = undefined; + this.streamingMessage = undefined; + this.pendingTools.clear(); + + const success = await this.session.importFromJsonl(inputPath); + if (!success) { + this.showWarning("Import cancelled"); + return; + } + + // Clear and re-render the chat + this.chatContainer.clear(); + this.renderInitialMessages(); + this.showStatus(`Session imported from: ${inputPath}`); + } catch (error: unknown) { + this.showError(`Failed to import session: ${error instanceof Error ? error.message : "Unknown error"}`); + } + } + private async handleShareCommand(): Promise { // Check if gh is available and logged in try {