feat(coding-agent): add JSONL export/import for sessions (#2356)

fixes #2274
This commit is contained in:
Helmut Januschka
2026-03-18 22:37:11 +01:00
committed by GitHub
parent ce095e2846
commit fd385ecf06
3 changed files with 123 additions and 6 deletions

View File

@@ -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<boolean> {
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
// =========================================================================

View File

@@ -19,7 +19,8 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ 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" },

View File

@@ -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<void> {
const parts = text.split(/\s+/);
if (parts.length < 2 || !parts[1]) {
this.showError("Usage: /import <path.jsonl>");
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<void> {
// Check if gh is available and logged in
try {