diff --git a/CHANGELOG.md b/CHANGELOG.md index b3f50705..04e6cfbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Repo: https://github.com/openclaw/acpx ### Changes +- CLI/sessions: add `sessions export` and `sessions import` for moving portable session archives between machines. + ### Breaking ### Fixes diff --git a/README.md b/README.md index 5dc548ef..9879a53f 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ One command surface for Pi, OpenClaw ACP, Codex, Claude, and other ACP-compatibl - **Prompt from file/stdin**: `--file ` or piped stdin for prompt content - **Config files**: global + project JSON config with `acpx config show|init` - **Session inspect/history**: `sessions show` and `sessions history --limit ` +- **Session export/import**: move portable session archives between machines - **Local status checks**: `status` reports running/idle/dead/no-session, pid, uptime, last prompt - **Client methods**: stable `fs/*` and `terminal/*` handlers with permission controls and cwd sandboxing - **Auth handshake**: stable `authenticate` support via env/config credentials diff --git a/docs/sessions.md b/docs/sessions.md index d01c7ccf..eb5843b3 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -30,6 +30,8 @@ acpx codex sessions show # metadata for the cwd-scoped default acpx codex sessions show api # metadata for the named session acpx codex sessions history # last 20 turn previews acpx codex sessions history --limit 50 +acpx codex sessions export api --output api-session.json +acpx codex sessions import api-session.json --name api-restored acpx codex sessions close # soft-close cwd default acpx codex sessions close api # soft-close named session acpx codex sessions prune --dry-run @@ -88,6 +90,22 @@ Named sessions are independent. They do not share state, queue owners, or histor - Closed sessions can still be loaded explicitly through embedding APIs. - `sessions prune` is the explicit way to delete closed records. +## Export / import + +`acpx` persists sessions per cwd in `~/.acpx/sessions/`. To move a session between machines or share one with a teammate: + +```bash +# On the source machine: +acpx codex sessions export my-debug-session --output debug.json + +# On the destination machine: +acpx codex sessions import debug.json --name debug-on-laptop +``` + +Export refuses to run if the session is locked by a live queue owner. Run `acpx codex sessions close my-debug-session` first. + +The archive is plain JSON. Paths are rewritten relative to home, so an imported session lands at `~/` on the destination machine. Override with `--cwd`. + ## Prune `sessions prune` removes closed records once you actually want them gone: diff --git a/src/cli/command-handlers.ts b/src/cli/command-handlers.ts index 0aa0b413..f08b76aa 100644 --- a/src/cli/command-handlers.ts +++ b/src/cli/command-handlers.ts @@ -9,6 +9,8 @@ import { PromptInputValidationError, textPrompt, } from "../prompt-content.js"; +import { exportSession } from "../session/export.js"; +import { importSession } from "../session/import.js"; import { findGitRepositoryRoot, findSession, @@ -33,7 +35,9 @@ import { resolveSessionNameFromFlags, type ExecFlags, type GlobalFlags, + type SessionsExportFlags, type PromptFlags, + type SessionsImportFlags, type SessionsHistoryFlags, type SessionsNewFlags, type SessionsPruneFlags, @@ -927,6 +931,73 @@ export async function handleSessionsHistory( printSessionHistoryByFormat(record, flags.limit, globalFlags.format); } +export async function handleSessionsExport( + explicitAgentName: string | undefined, + sessionName: string | undefined, + flags: SessionsExportFlags, + command: Command, + config: ResolvedAcpxConfig, +): Promise { + const globalFlags = resolveGlobalFlags(command, config); + const agent = resolveAgentInvocation(explicitAgentName, globalFlags, config); + const cwd = flags.cwd ? path.resolve(flags.cwd) : agent.cwd; + + await exportSession( + { + agentCommand: agent.agentCommand, + cwd, + name: sessionName, + }, + flags.output, + ); + + if ( + emitJsonResult(globalFlags.format, { + action: "session_exported", + output: flags.output, + }) + ) { + return; + } + + if (globalFlags.format === "quiet") { + process.stdout.write(`${flags.output}\n`); + return; + } + + process.stdout.write(`exported session to ${flags.output}\n`); +} + +export async function handleSessionsImport( + archivePath: string, + flags: SessionsImportFlags, + command: Command, + config: ResolvedAcpxConfig, +): Promise { + const globalFlags = resolveGlobalFlags(command, config); + const result = await importSession(archivePath, { + name: flags.name, + newCwd: flags.cwd, + }); + + if ( + emitJsonResult(globalFlags.format, { + action: "session_imported", + record_id: result.record_id, + cwd: result.cwd, + }) + ) { + return; + } + + if (globalFlags.format === "quiet") { + process.stdout.write(`${result.record_id}\n`); + return; + } + + process.stdout.write(`imported session ${result.record_id} at ${result.cwd}\n`); +} + export async function handleSessionsPrune( explicitAgentName: string | undefined, flags: SessionsPruneFlags, diff --git a/src/cli/command-registration.ts b/src/cli/command-registration.ts index bfda66ff..ce9d95b5 100644 --- a/src/cli/command-registration.ts +++ b/src/cli/command-registration.ts @@ -6,7 +6,9 @@ import { handlePrompt, handleSessionsClose, handleSessionsEnsure, + handleSessionsExport, handleSessionsHistory, + handleSessionsImport, handleSessionsList, handleSessionsNew, handleSessionsPrune, @@ -26,7 +28,9 @@ import { parsePruneBeforeDate, parseSessionName, type PromptFlags, + type SessionsExportFlags, type SessionsHistoryFlags, + type SessionsImportFlags, type SessionsNewFlags, type SessionsPruneFlags, type StatusFlags, @@ -139,6 +143,34 @@ export function registerSessionsCommand( ); }); + sessionsCommand + .command("export") + .description("Export a portable session archive") + .argument("[name]", "Session name", parseSessionName) + .requiredOption("--output ", "Output archive path", (value: string) => + parseNonEmptyValue("Output path", value), + ) + .option("--cwd ", "Session cwd to export", (value: string) => + parseNonEmptyValue("Session cwd", value), + ) + .action(async function (this: Command, name: string | undefined, flags: SessionsExportFlags) { + await handleSessionsExport(explicitAgentName, name, flags, this, config); + }); + + sessionsCommand + .command("import") + .description("Import a portable session archive") + .argument("", "Archive path", (value: string) => + parseNonEmptyValue("Archive path", value), + ) + .option("--name ", "Imported session name", parseSessionName) + .option("--cwd ", "Imported session cwd", (value: string) => + parseNonEmptyValue("Imported session cwd", value), + ) + .action(async function (this: Command, archivePath: string, flags: SessionsImportFlags) { + await handleSessionsImport(archivePath, flags, this, config); + }); + sessionsCommand .command("prune") .description("Delete closed sessions and free disk space") diff --git a/src/cli/flags.ts b/src/cli/flags.ts index 20ce7188..ff7b81d6 100644 --- a/src/cli/flags.ts +++ b/src/cli/flags.ts @@ -68,6 +68,16 @@ export type SessionsHistoryFlags = { limit: number; }; +export type SessionsExportFlags = { + output: string; + cwd?: string; +}; + +export type SessionsImportFlags = { + name?: string; + cwd?: string; +}; + export type StatusFlags = { session?: string; }; diff --git a/src/session/__tests__/export-import.test.ts b/src/session/__tests__/export-import.test.ts new file mode 100644 index 00000000..2ad3b46d --- /dev/null +++ b/src/session/__tests__/export-import.test.ts @@ -0,0 +1,281 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { AGENT_REGISTRY } from "../../agent-registry.js"; +import type { SessionRecord } from "../../types.js"; +import { exportSession } from "../export.js"; +import { importSession } from "../import.js"; +import { resolveSessionRecord, serializeSessionRecordForDisk } from "../persistence.js"; + +function makeSessionRecord( + overrides: Partial & { + acpxRecordId: string; + acpSessionId: string; + agentCommand: string; + cwd: string; + }, +): SessionRecord { + const timestamp = "2026-01-01T00:00:00.000Z"; + return { + schema: "acpx.session.v1", + acpxRecordId: overrides.acpxRecordId, + acpSessionId: overrides.acpSessionId, + agentSessionId: overrides.agentSessionId, + agentCommand: overrides.agentCommand, + cwd: path.resolve(overrides.cwd), + name: overrides.name ?? overrides.acpxRecordId, + createdAt: overrides.createdAt ?? timestamp, + lastUsedAt: overrides.lastUsedAt ?? timestamp, + lastSeq: overrides.lastSeq ?? 0, + lastRequestId: overrides.lastRequestId, + eventLog: overrides.eventLog ?? { + active_path: ".stream.ndjson", + segment_count: 1, + max_segment_bytes: 1024, + max_segments: 1, + last_write_at: overrides.lastUsedAt ?? timestamp, + last_write_error: null, + }, + closed: overrides.closed ?? false, + closedAt: overrides.closedAt, + pid: overrides.pid, + agentStartedAt: overrides.agentStartedAt, + lastPromptAt: overrides.lastPromptAt, + lastAgentExitCode: overrides.lastAgentExitCode, + lastAgentExitSignal: overrides.lastAgentExitSignal, + lastAgentExitAt: overrides.lastAgentExitAt, + lastAgentDisconnectReason: overrides.lastAgentDisconnectReason, + protocolVersion: overrides.protocolVersion, + agentCapabilities: overrides.agentCapabilities, + title: overrides.title ?? null, + messages: overrides.messages ?? [], + updated_at: overrides.updated_at ?? overrides.lastUsedAt ?? timestamp, + cumulative_token_usage: overrides.cumulative_token_usage ?? {}, + request_token_usage: overrides.request_token_usage ?? {}, + acpx: overrides.acpx ?? {}, + importedFrom: overrides.importedFrom, + }; +} + +async function withTempHome(prefix: string, run: (homeDir: string) => Promise): Promise { + const originalHome = process.env.HOME; + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + process.env.HOME = tempHome; + + try { + return await run(tempHome); + } finally { + if (originalHome == null) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + await fs.rm(tempHome, { recursive: true, force: true }); + } +} + +function sessionFilePath(homeDir: string, acpxRecordId: string): string { + return path.join(homeDir, ".acpx", "sessions", `${encodeURIComponent(acpxRecordId)}.json`); +} + +async function writeSessionRecordFile(homeDir: string, record: SessionRecord): Promise { + const filePath = sessionFilePath(homeDir, record.acpxRecordId); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + `${JSON.stringify(serializeSessionRecordForDisk(record), null, 2)}\n`, + "utf8", + ); +} + +function streamPath(homeDir: string, recordId: string): string { + return path.join(homeDir, ".acpx", "sessions", `${encodeURIComponent(recordId)}.stream.ndjson`); +} + +async function writeHistory(homeDir: string, recordId: string, entries: unknown[]): Promise { + const filePath = streamPath(homeDir, recordId); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, + "utf8", + ); +} + +async function readHistory(homeDir: string, recordId: string): Promise { + const payload = await fs.readFile(streamPath(homeDir, recordId), "utf8"); + return payload + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as unknown); +} + +test("exportSession and importSession round-trip session state with a fresh record id", async () => { + await withTempHome("acpx-export-import-", async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + const archivePath = path.join(homeDir, "archive.json"); + await fs.mkdir(cwd, { recursive: true }); + + const source = makeSessionRecord({ + acpxRecordId: "source-record", + acpSessionId: "provider-session", + agentCommand: AGENT_REGISTRY.codex, + cwd, + name: "debug", + messages: [ + { + User: { + id: "user-1", + content: [{ Text: "hello" }], + }, + }, + ], + }); + await writeSessionRecordFile(homeDir, source); + + const history = [ + { jsonrpc: "2.0", method: "session/update", params: { text: "one" } }, + { jsonrpc: "2.0", method: "session/update", params: { text: "two" } }, + ]; + await writeHistory(homeDir, source.acpxRecordId, history); + + await exportSession( + { + agentCommand: AGENT_REGISTRY.codex, + cwd, + name: "debug", + }, + archivePath, + ); + + await fs.rm(sessionFilePath(homeDir, source.acpxRecordId)); + await fs.rm(streamPath(homeDir, source.acpxRecordId)); + + const imported = await importSession(archivePath); + const record = await resolveSessionRecord(imported.record_id); + + assert.notEqual(record.acpxRecordId, source.acpxRecordId); + assert.equal(record.acpSessionId, source.acpSessionId); + assert.equal(record.agentCommand, source.agentCommand); + assert.equal(record.name, source.name); + assert.equal(record.cwd, source.cwd); + assert.deepEqual(record.messages, source.messages); + assert.deepEqual(await readHistory(homeDir, imported.record_id), history); + assert.deepEqual(record.importedFrom, { + recordId: source.acpxRecordId, + cwdOriginal: source.cwd, + exportedBy: record.importedFrom?.exportedBy, + exportedAt: record.importedFrom?.exportedAt, + }); + }); +}); + +test("importSession rewrites cwd and name when requested", async () => { + await withTempHome("acpx-export-import-", async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + const newCwd = path.join(homeDir, "other"); + const archivePath = path.join(homeDir, "archive.json"); + await fs.mkdir(cwd, { recursive: true }); + + await writeSessionRecordFile( + homeDir, + makeSessionRecord({ + acpxRecordId: "source-record", + acpSessionId: "provider-session", + agentCommand: AGENT_REGISTRY.codex, + cwd, + name: "debug", + }), + ); + + await exportSession({ agentCommand: AGENT_REGISTRY.codex, cwd, name: "debug" }, archivePath); + + const imported = await importSession(archivePath, { + name: "debug-on-laptop", + newCwd, + }); + const record = await resolveSessionRecord(imported.record_id); + + assert.equal(imported.cwd, newCwd); + assert.equal(record.cwd, newCwd); + assert.equal(record.name, "debug-on-laptop"); + }); +}); + +test("exportSession refuses a session locked by a live pid", async () => { + await withTempHome("acpx-export-import-", async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + const recordId = "locked-record"; + await fs.mkdir(cwd, { recursive: true }); + await writeSessionRecordFile( + homeDir, + makeSessionRecord({ + acpxRecordId: recordId, + acpSessionId: recordId, + agentCommand: AGENT_REGISTRY.codex, + cwd, + }), + ); + const lockPath = path.join( + homeDir, + ".acpx", + "sessions", + `${encodeURIComponent(recordId)}.stream.lock`, + ); + await fs.writeFile( + lockPath, + `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`, + "utf8", + ); + + await assert.rejects( + exportSession( + { agentCommand: AGENT_REGISTRY.codex, cwd, name: recordId }, + path.join(homeDir, "archive.json"), + ), + (error: unknown) => { + assert.equal((error as { code?: unknown }).code, "session-locked"); + assert.equal((error as { exitCode?: unknown }).exitCode, 2); + return true; + }, + ); + }); +}); + +test("importSession rejects unsupported archive format versions", async () => { + await withTempHome("acpx-export-import-", async (homeDir) => { + const archivePath = path.join(homeDir, "bad.json"); + await fs.writeFile(archivePath, `${JSON.stringify({ format_version: 2 })}\n`, "utf8"); + + await assert.rejects( + importSession(archivePath), + /Unsupported session export format_version 2; supported version is 1/, + ); + }); +}); + +test("importSession generates a new record id when the source still exists locally", async () => { + await withTempHome("acpx-export-import-", async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + const archivePath = path.join(homeDir, "archive.json"); + await fs.mkdir(cwd, { recursive: true }); + + const source = makeSessionRecord({ + acpxRecordId: "source-record", + acpSessionId: "provider-session", + agentCommand: AGENT_REGISTRY.codex, + cwd, + name: "debug", + }); + await writeSessionRecordFile(homeDir, source); + await exportSession({ agentCommand: AGENT_REGISTRY.codex, cwd, name: "debug" }, archivePath); + + const imported = await importSession(archivePath); + + assert.notEqual(imported.record_id, source.acpxRecordId); + assert.ok(await fs.stat(sessionFilePath(homeDir, source.acpxRecordId))); + assert.ok(await fs.stat(sessionFilePath(homeDir, imported.record_id))); + }); +}); diff --git a/src/session/export.ts b/src/session/export.ts new file mode 100644 index 00000000..eec2f897 --- /dev/null +++ b/src/session/export.ts @@ -0,0 +1,190 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { isProcessAlive } from "../cli/queue/lease-store.js"; +import { AcpxOperationalError } from "../errors.js"; +import type { AcpJsonRpcMessage, SessionRecord } from "../types.js"; +import { + sessionEventActivePath, + sessionEventLockPath, + sessionEventSegmentPath, +} from "./event-log.js"; +import { findSession, listSessions, normalizeName } from "./persistence.js"; +import { serializeSessionRecordForDisk } from "./persistence/serialize.js"; + +export type ExportedSession = { + format_version: 1; + exported_at: string; + exported_by: string; + session: { + record_id: string; + name: string | null; + agent: string; + cwd_relative: string; + cwd_absolute_original: string; + created_at: string; + updated_at: string; + state: Record; + }; + history: AcpJsonRpcMessage[]; +}; + +export type SessionExportLookup = { + agentCommand?: string; + cwd?: string; + name?: string; +}; + +class SessionExportError extends AcpxOperationalError { + readonly code: string; + readonly exitCode = 2; + + constructor(message: string, code: string) { + super(message, { + outputCode: "USAGE", + detailCode: code, + origin: "cli", + }); + this.code = code; + } +} + +function sessionLookupError(message: string, code: string): SessionExportError { + return new SessionExportError(message, code); +} + +async function loadSessionRecord( + sessionLookup: SessionExportLookup, +): Promise { + const cwd = path.resolve(sessionLookup.cwd ?? process.cwd()); + const name = normalizeName(sessionLookup.name); + + if (sessionLookup.agentCommand) { + return await findSession({ + agentCommand: sessionLookup.agentCommand, + cwd, + name, + includeClosed: true, + }); + } + + const matches = (await listSessions()).filter((session) => { + if (session.cwd !== cwd) { + return false; + } + if (name == null) { + return session.name == null; + } + return session.name === name; + }); + + if (matches.length > 1) { + throw sessionLookupError("multiple sessions match export lookup", "ambiguous-session"); + } + + return matches[0]; +} + +type EventLockPayload = { + pid?: number; +}; + +function parseEventLockPayload(raw: string): EventLockPayload { + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + const record = parsed as Record; + return { + pid: typeof record.pid === "number" ? record.pid : undefined, + }; + } catch { + return {}; + } +} + +export async function isSessionLocked(recordId: string): Promise { + try { + const payload = await fs.readFile(sessionEventLockPath(recordId), "utf8"); + return isProcessAlive(parseEventLockPayload(payload).pid); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + +async function readHistoryFile(filePath: string): Promise { + const payload = await fs.readFile(filePath, "utf8").catch((error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return ""; + } + throw error; + }); + return payload + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as AcpJsonRpcMessage); +} + +async function readSessionHistory(record: SessionRecord): Promise { + const history: AcpJsonRpcMessage[] = []; + const maxSegments = Number.isInteger(record.eventLog.max_segments) + ? record.eventLog.max_segments + : 0; + + for (let segment = maxSegments; segment >= 1; segment -= 1) { + history.push(...(await readHistoryFile(sessionEventSegmentPath(record.acpxRecordId, segment)))); + } + + history.push(...(await readHistoryFile(sessionEventActivePath(record.acpxRecordId)))); + return history; +} + +function cwdRelativeToHome(cwd: string, home: string): string { + const relative = path.relative(home, cwd); + if (relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative)) { + return relative; + } + return cwd; +} + +export async function exportSession( + sessionLookup: SessionExportLookup, + outputPath: string, +): Promise { + const record = await loadSessionRecord(sessionLookup); + if (!record) { + throw sessionLookupError("session not found", "not-found"); + } + + if (await isSessionLocked(record.acpxRecordId)) { + throw sessionLookupError( + "session is currently locked by a running queue owner; close it first with `acpx sessions close`", + "session-locked", + ); + } + + const home = os.homedir(); + const exported: ExportedSession = { + format_version: 1, + exported_at: new Date().toISOString(), + exported_by: os.userInfo().username, + session: { + record_id: record.acpxRecordId, + name: record.name ?? null, + agent: record.agentCommand, + cwd_relative: cwdRelativeToHome(record.cwd, home), + cwd_absolute_original: record.cwd, + created_at: record.createdAt, + updated_at: record.lastUsedAt, + state: serializeSessionRecordForDisk(record), + }, + history: await readSessionHistory(record), + }; + + await fs.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true }); + await fs.writeFile(outputPath, `${JSON.stringify(exported, null, 2)}\n`, "utf8"); +} diff --git a/src/session/import.ts b/src/session/import.ts new file mode 100644 index 00000000..992dfa1d --- /dev/null +++ b/src/session/import.ts @@ -0,0 +1,175 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { z, ZodError } from "zod"; +import { AcpxOperationalError } from "../errors.js"; +import type { AcpJsonRpcMessage, SessionRecord } from "../types.js"; +import { defaultSessionEventLog, sessionEventActivePath } from "./event-log.js"; +import { parseSessionRecord, writeSessionRecord } from "./persistence.js"; + +const SUPPORTED_FORMAT_VERSION = 1; + +const exportedSessionSchema = z.object({ + format_version: z.literal(SUPPORTED_FORMAT_VERSION), + exported_at: z.string(), + exported_by: z.string(), + session: z.object({ + record_id: z.string(), + name: z.string().nullable(), + agent: z.string(), + cwd_relative: z.string(), + cwd_absolute_original: z.string(), + created_at: z.string(), + updated_at: z.string(), + state: z.unknown(), + }), + history: z.array(z.unknown()), +}); + +type ParsedExportedSession = z.infer; + +export type ImportSessionOptions = { + name?: string; + newCwd?: string; +}; + +class SessionImportError extends AcpxOperationalError { + readonly code: string; + readonly exitCode = 2; + + constructor(message: string, code: string) { + super(message, { + outputCode: "USAGE", + detailCode: code, + origin: "cli", + }); + this.code = code; + } +} + +function importError(message: string, code: string): SessionImportError { + return new SessionImportError(message, code); +} + +function parseArchive(raw: string): ParsedExportedSession { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw importError( + `Invalid session export archive JSON: ${error instanceof Error ? error.message : String(error)}`, + "invalid-archive", + ); + } + + const record = parsed && typeof parsed === "object" ? (parsed as Record) : {}; + if (record.format_version !== SUPPORTED_FORMAT_VERSION) { + throw importError( + `Unsupported session export format_version ${String(record.format_version)}; supported version is ${SUPPORTED_FORMAT_VERSION}`, + "unsupported-format-version", + ); + } + + try { + return exportedSessionSchema.parse(parsed); + } catch (error) { + if (error instanceof ZodError) { + throw importError( + `Invalid session export archive: ${error.issues[0]?.message}`, + "invalid-archive", + ); + } + throw error; + } +} + +async function generateRecordId(sessionsDir: string): Promise { + for (;;) { + const recordId = randomUUID(); + const filePath = path.join(sessionsDir, `${encodeURIComponent(recordId)}.json`); + try { + await fs.access(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return recordId; + } + throw error; + } + } +} + +function resolveImportedCwd(cwdRelative: string, newCwd: string | undefined): string { + if (newCwd) { + return path.resolve(newCwd); + } + if (path.isAbsolute(cwdRelative)) { + return cwdRelative; + } + return path.join(os.homedir(), cwdRelative); +} + +function buildImportedRecord( + parsed: ParsedExportedSession, + sourceRecord: SessionRecord, + options: { newRecordId: string; cwd: string; name?: string }, +): SessionRecord { + const eventLog = { + ...defaultSessionEventLog(options.newRecordId), + max_segment_bytes: sourceRecord.eventLog.max_segment_bytes, + max_segments: sourceRecord.eventLog.max_segments, + segment_count: parsed.history.length > 0 ? 1 : sourceRecord.eventLog.segment_count, + }; + + return { + ...sourceRecord, + acpxRecordId: options.newRecordId, + cwd: options.cwd, + name: options.name ?? parsed.session.name ?? undefined, + eventLog, + importedFrom: { + recordId: parsed.session.record_id, + cwdOriginal: parsed.session.cwd_absolute_original, + exportedBy: parsed.exported_by, + exportedAt: parsed.exported_at, + }, + }; +} + +export async function importSession( + archivePath: string, + options: ImportSessionOptions = {}, +): Promise<{ record_id: string; cwd: string }> { + const parsed = parseArchive(await fs.readFile(archivePath, "utf8")); + const sourceRecord = parseSessionRecord(parsed.session.state); + if (!sourceRecord) { + throw importError( + "Invalid session export archive: session.state is not a session record", + "invalid-archive", + ); + } + + const sessionsDir = path.join(os.homedir(), ".acpx", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + const cwd = resolveImportedCwd(parsed.session.cwd_relative, options.newCwd); + const newRecordId = await generateRecordId(sessionsDir); + const newRecord = buildImportedRecord(parsed, sourceRecord, { + newRecordId, + cwd, + name: options.name, + }); + + await writeSessionRecord(newRecord); + + if (parsed.history.length > 0) { + const history = parsed.history as AcpJsonRpcMessage[]; + await fs.writeFile( + sessionEventActivePath(newRecordId), + `${history.map((entry) => JSON.stringify(entry)).join("\n")}\n`, + "utf8", + ); + } + + return { record_id: newRecordId, cwd }; +} diff --git a/src/session/persistence.ts b/src/session/persistence.ts index 2d1eba55..4295b59f 100644 --- a/src/session/persistence.ts +++ b/src/session/persistence.ts @@ -1,4 +1,5 @@ export { serializeSessionRecordForDisk } from "./persistence/serialize.js"; +export { parseSessionRecord } from "./persistence/parse.js"; export { DEFAULT_HISTORY_LIMIT, absolutePath, diff --git a/src/session/persistence/parse.ts b/src/session/persistence/parse.ts index c71baa8f..6cbd9516 100644 --- a/src/session/persistence/parse.ts +++ b/src/session/persistence/parse.ts @@ -392,6 +392,30 @@ function parseEventLog(raw: unknown, sessionId: string): SessionEventLog { }; } +function parseImportedFrom(raw: unknown): SessionRecord["importedFrom"] | null | undefined { + if (raw == null) { + return undefined; + } + + const record = asRecord(raw); + if ( + !record || + typeof record.record_id !== "string" || + typeof record.cwd_original !== "string" || + typeof record.exported_by !== "string" || + typeof record.exported_at !== "string" + ) { + return null; + } + + return { + recordId: record.record_id, + cwdOriginal: record.cwd_original, + exportedBy: record.exported_by, + exportedAt: record.exported_at, + }; +} + function normalizeOptionalName(value: unknown): string | undefined | null { if (value == null) { return undefined; @@ -509,7 +533,8 @@ export function parseSessionRecord(raw: unknown): SessionRecord | null { const eventLog = parseEventLog(record.event_log, record.acpx_record_id); const lastRequestId = normalizeOptionalString(record.last_request_id); - if (lastRequestId === null) { + const importedFrom = parseImportedFrom(record.imported_from); + if (lastRequestId === null || importedFrom === null) { return null; } @@ -544,5 +569,6 @@ export function parseSessionRecord(raw: unknown): SessionRecord | null { cumulative_token_usage: conversation.cumulative_token_usage, request_token_usage: conversation.request_token_usage, acpx: parseAcpxState(record.acpx), + importedFrom, }; } diff --git a/src/session/persistence/serialize.ts b/src/session/persistence/serialize.ts index 9d6395d7..f32a045d 100644 --- a/src/session/persistence/serialize.ts +++ b/src/session/persistence/serialize.ts @@ -38,5 +38,13 @@ export function serializeSessionRecordForDisk(record: SessionRecord): Record; acpx?: SessionAcpxState; + importedFrom?: SessionImportedFrom; }; export type RunPromptResult = {