From e26408cfbdb055ae059c35f3b469b162743bf9ef Mon Sep 17 00:00:00 2001 From: gdauber1 Date: Mon, 19 Oct 2026 01:07:27 +0300 Subject: [PATCH 1/3] feat(session-import): read and encode Claude Code and Codex sessions Find local sessions under ~/.claude/projects and ~/.codex/sessions, keep only user and assistant text, and fit it to what the server accepts: alternating turns that start with the user, at most 100,000 characters. The turns travel as base64(gzip(JSON)) because Cloudflare's WAF rejects raw transcripts that quote shell commands. The upload call removes the request body from the error it throws, so error reporting never receives the conversation. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/core/session-import/api.ts | 92 +++++ packages/cli/src/core/session-import/index.ts | 13 + .../cli/src/core/session-import/sessions.ts | 343 ++++++++++++++++++ .../cli/src/core/session-import/transcript.ts | 201 ++++++++++ packages/cli/src/core/utils/git.ts | 16 + .../cli/tests/core/session-import-api.spec.ts | 49 +++ .../cli/tests/core/session-import.spec.ts | 234 ++++++++++++ .../fixtures/sessions/claude-session.jsonl | 22 ++ .../sessions/codex-exec-rollout.jsonl | 3 + .../fixtures/sessions/codex-rollout.jsonl | 13 + .../sessions/codex-session-index.jsonl | 1 + .../sessions/codex-subagent-rollout.jsonl | 3 + packages/cli/tests/fixtures/sessions/plant.ts | 74 ++++ 13 files changed, 1064 insertions(+) create mode 100644 packages/cli/src/core/session-import/api.ts create mode 100644 packages/cli/src/core/session-import/index.ts create mode 100644 packages/cli/src/core/session-import/sessions.ts create mode 100644 packages/cli/src/core/session-import/transcript.ts create mode 100644 packages/cli/tests/core/session-import-api.spec.ts create mode 100644 packages/cli/tests/core/session-import.spec.ts create mode 100644 packages/cli/tests/fixtures/sessions/claude-session.jsonl create mode 100644 packages/cli/tests/fixtures/sessions/codex-exec-rollout.jsonl create mode 100644 packages/cli/tests/fixtures/sessions/codex-rollout.jsonl create mode 100644 packages/cli/tests/fixtures/sessions/codex-session-index.jsonl create mode 100644 packages/cli/tests/fixtures/sessions/codex-subagent-rollout.jsonl create mode 100644 packages/cli/tests/fixtures/sessions/plant.ts diff --git a/packages/cli/src/core/session-import/api.ts b/packages/cli/src/core/session-import/api.ts new file mode 100644 index 000000000..3a7ba655f --- /dev/null +++ b/packages/cli/src/core/session-import/api.ts @@ -0,0 +1,92 @@ +import { HTTPError } from "ky"; +import { z } from "zod"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { + encodeTurns, + type SessionSource, + type SessionTurn, +} from "@/core/session-import/transcript.js"; + +const ImportResponseSchema = z + .object({ branch_id: z.string().min(1), branch_name: z.string().min(1) }) + .transform((data) => ({ + branchId: data.branch_id, + branchName: data.branch_name, + })); + +interface ImportSessionParams { + source: SessionSource; + sessionId: string; + title: string; + turns: SessionTurn[]; +} + +interface ImportedSession { + branchId: string; + branchName: string; + /** False when an identical import already made this branch. */ + created: boolean; +} + +/** + * Error reporting attaches a failed request's body. This one is the user's + * conversation, so it is dropped before the error is built. + */ +function withoutTranscript(error: unknown): unknown { + if (error instanceof HTTPError) { + delete error.options.context.__requestBody; + } + return error; +} + +function withUnavailableHint(error: ApiError): ApiError { + if (error.statusCode !== 404) return error; + return new ApiError(error.message, { + statusCode: error.statusCode, + requestUrl: error.requestUrl, + requestMethod: error.requestMethod, + responseBody: error.responseBody, + requestId: error.requestId, + details: error.details, + hints: [ + { + message: + "Session import works on apps imported from a repository, and needs early access on your account. Check the app id.", + }, + ], + cause: error, + }); +} + +export async function importSession( + params: ImportSessionParams, +): Promise { + let response: Response; + try { + response = await getAppClient().post("imported/session-imports", { + json: { + source: params.source, + source_session_id: params.sessionId, + title: params.title, + turns_gzip_base64: encodeTurns(params.turns), + }, + timeout: 60_000, + }); + } catch (error) { + throw withUnavailableHint( + await ApiError.fromHttpError( + withoutTranscript(error), + "importing session", + ), + ); + } + const result = ImportResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid session import response from server", + result.error, + ); + } + return { ...result.data, created: response.status === 201 }; +} diff --git a/packages/cli/src/core/session-import/index.ts b/packages/cli/src/core/session-import/index.ts new file mode 100644 index 000000000..ef74114ab --- /dev/null +++ b/packages/cli/src/core/session-import/index.ts @@ -0,0 +1,13 @@ +export { importSession } from "./api.js"; +export { + defaultSessionRoots, + type LocalSession, + listSessions, + readSessionById, +} from "./sessions.js"; +export { + prepareTranscript, + SESSION_SOURCE_LABELS, + type SessionSource, + type SessionTurn, +} from "./transcript.js"; diff --git a/packages/cli/src/core/session-import/sessions.ts b/packages/cli/src/core/session-import/sessions.ts new file mode 100644 index 000000000..798b11ff8 --- /dev/null +++ b/packages/cli/src/core/session-import/sessions.ts @@ -0,0 +1,343 @@ +import { createReadStream } from "node:fs"; +import { readdir, realpath, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, sep } from "node:path"; +import { createInterface } from "node:readline"; +import { FileReadError } from "@/core/errors.js"; +import { + claudeTurnFromLine, + codexTurnFromLine, + cut, + objectField, + parseJsonObject, + type SessionSource, + type SessionTurn, + stringField, +} from "@/core/session-import/transcript.js"; + +export interface SessionRoots { + claudeProjectsDir: string; + codexHome: string; +} + +export function defaultSessionRoots(): SessionRoots { + const claudeHome = + process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"); + return { + claudeProjectsDir: join(claudeHome, "projects"), + codexHome: process.env.CODEX_HOME ?? join(homedir(), ".codex"), + }; +} + +export interface LocalSession { + source: SessionSource; + id: string; + title: string; + cwd?: string; + filePath: string; + updatedAtMs: number; +} + +interface SessionFile { + source: SessionSource; + id: string; + filePath: string; + updatedAtMs: number; +} + +interface SessionHead { + cwd?: string; + /** Not a conversation anyone resumes, such as a headless `codex exec` run. */ + skip?: boolean; +} + +interface SessionContent { + cwd?: string; + title?: string; + turns: SessionTurn[]; +} + +interface SessionAdapter { + files(): Promise; + head(filePath: string): Promise; + read(file: SessionFile): Promise; +} + +const CODEX_ROLLOUT_FILE = + /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/; +const MAX_TITLE_CHARS = 80; + +type JsonObject = NonNullable>; + +/** Streams a file's JSON lines; `onObject` returns false to stop reading. */ +async function forEachJsonObject( + filePath: string, + onObject: (object: JsonObject) => boolean | undefined, +): Promise { + const stream = createReadStream(filePath, { encoding: "utf8" }); + const lines = createInterface({ + input: stream, + crlfDelay: Number.POSITIVE_INFINITY, + }); + try { + for await (const line of lines) { + const object = parseJsonObject(line); + if (object && onObject(object) === false) break; + } + } finally { + lines.close(); + stream.destroy(); + } +} + +async function listDir(dir: string) { + try { + return await readdir(dir, { withFileTypes: true }); + } catch { + return []; + } +} + +async function withMtime( + files: Omit[], +): Promise { + const stamped = await Promise.all( + files.map(async (file) => { + try { + return { ...file, updatedAtMs: (await stat(file.filePath)).mtimeMs }; + } catch { + return undefined; + } + }), + ); + return stamped.filter((file) => file !== undefined); +} + +function oneLine(text: string): string { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length <= MAX_TITLE_CHARS + ? flat + : `${cut(flat, MAX_TITLE_CHARS - 1)}…`; +} + +/** `//.jsonl`, one level deep: `/subagents/` holds sidechains. */ +function claudeAdapter(projectsDir: string): SessionAdapter { + return { + async files() { + const found: Omit[] = []; + for (const project of await listDir(projectsDir)) { + if (!project.isDirectory()) continue; + const projectDir = join(projectsDir, project.name); + for (const entry of await listDir(projectDir)) { + if (entry.isFile() && entry.name.endsWith(".jsonl")) { + found.push({ + source: "claude_code", + id: entry.name.slice(0, -".jsonl".length), + filePath: join(projectDir, entry.name), + }); + } + } + } + return withMtime(found); + }, + async head(filePath) { + let cwd: string | undefined; + await forEachJsonObject(filePath, (line) => { + cwd = stringField(line, "cwd"); + return cwd === undefined; + }); + return { cwd }; + }, + async read(file) { + const turns: SessionTurn[] = []; + let cwd: string | undefined; + let customTitle: string | undefined; + let aiTitle: string | undefined; + let summary: string | undefined; + await forEachJsonObject(file.filePath, (line) => { + const type = stringField(line, "type"); + if (type === "custom-title") { + customTitle = stringField(line, "customTitle") ?? customTitle; + } else if (type === "ai-title") { + aiTitle = stringField(line, "aiTitle") ?? aiTitle; + } else if (type === "summary") { + summary = stringField(line, "summary") ?? summary; + } else { + cwd ??= stringField(line, "cwd"); + const turn = claudeTurnFromLine(line); + if (turn) turns.push(turn); + } + return undefined; + }); + return { cwd, title: customTitle ?? aiTitle ?? summary, turns }; + }, + }; +} + +/** `/sessions/YYYY/MM/DD/rollout--.jsonl`; names live in `session_index.jsonl`. */ +function codexAdapter(codexHome: string): SessionAdapter { + const sessionsDir = join(codexHome, "sessions"); + const titleOf = async (id: string) => { + let title: string | undefined; + try { + await forEachJsonObject( + join(codexHome, "session_index.jsonl"), + (line) => { + if (stringField(line, "id") === id) { + title = stringField(line, "thread_name") ?? title; + } + return undefined; + }, + ); + } catch { + // No index: fall back to the first prompt. + } + return title; + }; + return { + async files() { + const found: Omit[] = []; + for (const year of await listDir(sessionsDir)) { + for (const month of await listDir(join(sessionsDir, year.name))) { + const monthDir = join(sessionsDir, year.name, month.name); + for (const day of await listDir(monthDir)) { + const dayDir = join(monthDir, day.name); + for (const entry of await listDir(dayDir)) { + const id = CODEX_ROLLOUT_FILE.exec(entry.name)?.[1]; + if (id && entry.isFile()) { + found.push({ + source: "codex", + id, + filePath: join(dayDir, entry.name), + }); + } + } + } + } + } + return withMtime(found); + }, + async head(filePath) { + const head: SessionHead = {}; + await forEachJsonObject(filePath, (line) => { + const payload = objectField(line, "payload"); + if (stringField(line, "type") !== "session_meta" || !payload) { + return true; + } + head.cwd = stringField(payload, "cwd"); + // Interactive threads carry a string source ("cli", "vscode"). Headless + // `exec` runs and subagent threads (an object source) are not resumed. + const source = payload.source; + head.skip = typeof source !== "string" || source === "exec"; + return false; + }); + return head; + }, + async read(file) { + const turns: SessionTurn[] = []; + let cwd: string | undefined; + await forEachJsonObject(file.filePath, (line) => { + const payload = objectField(line, "payload"); + if (stringField(line, "type") === "session_meta" && payload) { + cwd ??= stringField(payload, "cwd"); + } + const turn = codexTurnFromLine(line); + if (turn) turns.push(turn); + return undefined; + }); + return { cwd, title: await titleOf(file.id), turns }; + }, + }; +} + +function adapters(roots: SessionRoots): SessionAdapter[] { + return [ + claudeAdapter(roots.claudeProjectsDir), + codexAdapter(roots.codexHome), + ]; +} + +async function describe( + adapter: SessionAdapter, + file: SessionFile, +): Promise<{ session: LocalSession; turns: SessionTurn[] } | undefined> { + const content = await adapter.read(file); + const firstPrompt = content.turns.find((turn) => turn.role === "user")?.text; + const title = oneLine(content.title ?? firstPrompt ?? ""); + if (content.turns.length === 0 || title === "") return undefined; + return { + session: { + source: file.source, + id: file.id, + title, + cwd: content.cwd, + filePath: file.filePath, + updatedAtMs: file.updatedAtMs, + }, + turns: content.turns, + }; +} + +async function isWithin(cwd: string, dir: string): Promise { + const roots = new Set([dir, await realpath(dir).catch(() => dir)]); + const places = new Set([cwd, await realpath(cwd).catch(() => cwd)]); + return [...roots].some((root) => + [...places].some((place) => place === root || place.startsWith(root + sep)), + ); +} + +interface ListSessionsOptions { + /** Only sessions started in this directory or below it. */ + within?: string; + limit: number; +} + +/** Newest first. Sessions with no conversation text are left out. */ +export async function listSessions( + roots: SessionRoots, + { within, limit }: ListSessionsOptions, +): Promise { + const found: LocalSession[] = []; + const files = ( + await Promise.all( + adapters(roots).map(async (adapter) => + (await adapter.files()).map((file) => ({ adapter, file })), + ), + ) + ) + .flat() + .sort((a, b) => b.file.updatedAtMs - a.file.updatedAtMs); + for (const { adapter, file } of files) { + if (found.length >= limit) break; + const head = await adapter + .head(file.filePath) + .catch(() => ({ skip: true }) as SessionHead); + if (head.skip) continue; + if (within && (!head.cwd || !(await isWithin(head.cwd, within)))) continue; + const described = await describe(adapter, file).catch(() => undefined); + if (described) found.push(described.session); + } + return found; +} + +/** The session with this exact id, from any directory. */ +export async function readSessionById( + roots: SessionRoots, + id: string, +): Promise<{ session: LocalSession; turns: SessionTurn[] } | undefined> { + for (const adapter of adapters(roots)) { + const file = (await adapter.files()).find( + (candidate) => candidate.id === id, + ); + if (!file) continue; + try { + return await describe(adapter, file); + } catch (error) { + throw new FileReadError( + `Failed to read session file ${file.filePath}: ${error instanceof Error ? error.message : "Unknown error"}`, + { cause: error instanceof Error ? error : undefined }, + ); + } + } + return undefined; +} diff --git a/packages/cli/src/core/session-import/transcript.ts b/packages/cli/src/core/session-import/transcript.ts new file mode 100644 index 000000000..aae7ba9b3 --- /dev/null +++ b/packages/cli/src/core/session-import/transcript.ts @@ -0,0 +1,201 @@ +import { gzipSync } from "node:zlib"; + +export type SessionSource = "claude_code" | "codex"; + +export const SESSION_SOURCE_LABELS: Record = { + claude_code: "Claude Code", + codex: "Codex", +}; + +export interface SessionTurn { + role: "user" | "assistant"; + text: string; +} + +export const MAX_TURNS = 120; +export const MAX_CHARS_PER_TURN = 4_000; +// The server refuses more than this much text in total. +export const MAX_TOTAL_CHARS = 100_000; +const TRUNCATION_MARK = " … [truncated]"; + +type JsonObject = Record; + +export function parseJsonObject(line: string): JsonObject | undefined { + if (line[0] !== "{") return undefined; + try { + const parsed: unknown = JSON.parse(line); + return typeof parsed === "object" && parsed !== null + ? (parsed as JsonObject) + : undefined; + } catch { + return undefined; + } +} + +export function stringField( + object: JsonObject, + key: string, +): string | undefined { + const value = object[key]; + return typeof value === "string" && value !== "" ? value : undefined; +} + +export function objectField( + object: JsonObject, + key: string, +): JsonObject | undefined { + const value = object[key]; + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : undefined; +} + +/** The joined text blocks of a message's content; tool results and images add nothing. */ +function contentText(content: unknown): string { + if (typeof content === "string") return content.trim(); + if (!Array.isArray(content)) return ""; + return content + .map((block) => + typeof block === "object" && + block !== null && + typeof (block as JsonObject).text === "string" + ? ((block as JsonObject).text as string) + : "", + ) + .filter((text) => text !== "") + .join("\n") + .trim(); +} + +/** + * Injected context (system reminders, slash-command records, environment blocks) + * opens with an angle bracket and is never part of the conversation. + */ +function conversationTurn( + role: SessionTurn["role"], + content: unknown, +): SessionTurn | undefined { + const text = contentText(content); + return text === "" || + text.startsWith("<") || + text.startsWith(INTERRUPT_MARKER) + ? undefined + : { role, text }; +} + +const NOT_CONVERSATION_FLAGS = [ + "isMeta", + "isSidechain", + "isCompactSummary", + "isVisibleInTranscriptOnly", + "isApiErrorMessage", +]; +const INTERRUPT_MARKER = "[Request interrupted by user"; +const SLASH_COMMAND_NAME = /([^<]*)<\/command-name>/; +const SLASH_COMMAND_ARGS = /([^<]*)<\/command-args>/; + +/** A slash command's record, `/name args`, is what the user typed; its expanded body is not. */ +function slashCommandText(text: string): string | undefined { + const name = SLASH_COMMAND_NAME.exec(text)?.[1]?.trim(); + if (!name) return undefined; + const args = SLASH_COMMAND_ARGS.exec(text)?.[1]?.trim(); + return args ? `${name} ${args}` : name; +} + +export function claudeTurnFromLine(line: JsonObject): SessionTurn | undefined { + const type = stringField(line, "type"); + if (type !== "user" && type !== "assistant") return undefined; + // Flags on lines Claude Code wrote itself: an expanded skill body, a compaction + // summary, an API error. None of them is something the user or model said. + if (NOT_CONVERSATION_FLAGS.some((flag) => line[flag] === true)) { + return undefined; + } + const message = objectField(line, "message"); + if (message?.model === "") return undefined; + const content = message?.content; + const command = + type === "user" ? slashCommandText(contentText(content)) : undefined; + return command + ? { role: "user", text: command } + : conversationTurn(type, content); +} + +/** Codex front-loads user items the user never typed. */ +const CODEX_INJECTED_USER_ITEM = + /^\s*(<(recommended_plugins|environment_context|user_instructions)>|# AGENTS\.md instructions for )/; + +export function codexTurnFromLine(line: JsonObject): SessionTurn | undefined { + if (stringField(line, "type") !== "response_item") return undefined; + const payload = objectField(line, "payload"); + if (payload === undefined || payload.type !== "message") return undefined; + const role = payload.role; + if (role !== "user" && role !== "assistant") return undefined; + const turn = conversationTurn(role, payload.content); + if (turn?.role === "user" && CODEX_INJECTED_USER_ITEM.test(turn.text)) { + return undefined; + } + return turn; +} + +/** Claude Code writes one line per content block, so consecutive turns can share a role. */ +function mergeConsecutiveRoles(turns: SessionTurn[]): SessionTurn[] { + const merged: SessionTurn[] = []; + for (const turn of turns) { + const last = merged.at(-1); + if (last?.role === turn.role) { + last.text = `${last.text}\n\n${turn.text}`; + } else { + merged.push({ ...turn }); + } + } + return merged; +} + +/** Cut to `maxChars` UTF-16 units without splitting a surrogate pair, which the server refuses. */ +export function cut(text: string, maxChars: number): string { + const end = text.charCodeAt(maxChars - 1); + return text.slice( + 0, + end >= 0xd800 && end <= 0xdbff ? maxChars - 1 : maxChars, + ); +} + +function clip(text: string, maxChars: number): string { + return text.length <= maxChars + ? text + : cut(text, maxChars - TRUNCATION_MARK.length) + TRUNCATION_MARK; +} + +interface PreparedTranscript { + turns: SessionTurn[]; + /** Oldest turns dropped because the conversation exceeds the caps. */ + droppedTurns: number; +} + +/** + * The turns the server accepts: alternating, starting with the user, within + * the turn and character caps. Newest turns win, so the oldest are dropped first. + */ +export function prepareTranscript(source: SessionTurn[]): PreparedTranscript { + const merged = mergeConsecutiveRoles(source).map((turn) => ({ + role: turn.role, + text: clip(turn.text, MAX_CHARS_PER_TURN), + })); + let kept = merged.slice(-MAX_TURNS); + let total = kept.reduce((sum, turn) => sum + turn.text.length, 0); + while (kept.length > 1 && total > MAX_TOTAL_CHARS) { + total -= kept[0]?.text.length ?? 0; + kept = kept.slice(1); + } + const droppedTurns = merged.length - kept.length; + const firstUser = kept.findIndex((turn) => turn.role === "user"); + return { + turns: firstUser === -1 ? [] : kept.slice(firstUser), + droppedTurns, + }; +} + +/** `base64(gzip(JSON))`: raw transcripts that quote shell commands trip Cloudflare's WAF. */ +export function encodeTurns(turns: SessionTurn[]): string { + return gzipSync(JSON.stringify(turns), { level: 9 }).toString("base64"); +} diff --git a/packages/cli/src/core/utils/git.ts b/packages/cli/src/core/utils/git.ts index f3a5be13b..6a4b190fe 100644 --- a/packages/cli/src/core/utils/git.ts +++ b/packages/cli/src/core/utils/git.ts @@ -1,6 +1,22 @@ +import { dirname, join } from "node:path"; +import { pathExists } from "@/core/utils/fs.js"; + /** Abbreviated or full commit hash β€” the same pattern the server validates. */ const GIT_HASH_PATTERN = /^[a-fA-F0-9]{7,64}$/; export function isGitCommitHash(value: string): boolean { return GIT_HASH_PATTERN.test(value); } + +/** The nearest directory at or above `startPath` holding a `.git` entry (a worktree's is a file). */ +export async function findGitRoot( + startPath: string = process.cwd(), +): Promise { + let current = startPath; + while (!(await pathExists(join(current, ".git")))) { + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } + return current; +} diff --git a/packages/cli/tests/core/session-import-api.spec.ts b/packages/cli/tests/core/session-import-api.spec.ts new file mode 100644 index 000000000..f04a94dc9 --- /dev/null +++ b/packages/cli/tests/core/session-import-api.spec.ts @@ -0,0 +1,49 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("importSession", () => { + let server: Server; + + beforeEach(async () => { + server = createServer((req, res) => { + req.resume(); + req.on("end", () => { + res.statusCode = 422; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ detail: "invalid" })); + }); + }); + await new Promise((resolve) => server.listen(0, resolve)); + vi.stubEnv( + "BASE44_API_URL", + `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + ); + vi.resetModules(); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await new Promise((resolve) => server.close(resolve)); + }); + + it("leaves the conversation out of the error that error reporting sends", async () => { + const { setAppContext } = await import("@/core/project/index.js"); + const { importSession } = await import("@/core/session-import/api.js"); + const { ApiError } = await import("@/core/errors.js"); + setAppContext({ id: "app-1" }); + + const failure = await importSession({ + source: "claude_code", + sessionId: "s1", + title: "t", + turns: [{ role: "user", text: "the secret plan" }], + }).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ApiError); + expect((failure as InstanceType).statusCode).toBe(422); + expect( + (failure as InstanceType).requestBody, + ).toBeUndefined(); + }); +}); diff --git a/packages/cli/tests/core/session-import.spec.ts b/packages/cli/tests/core/session-import.spec.ts new file mode 100644 index 000000000..53e40d736 --- /dev/null +++ b/packages/cli/tests/core/session-import.spec.ts @@ -0,0 +1,234 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { gunzipSync } from "node:zlib"; +import { dir } from "tmp-promise"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + listSessions, + readSessionById, + type SessionRoots, +} from "@/core/session-import/sessions.js"; +import { + encodeTurns, + MAX_CHARS_PER_TURN, + MAX_TOTAL_CHARS, + MAX_TURNS, + prepareTranscript, + type SessionTurn, +} from "@/core/session-import/transcript.js"; +import { + CLAUDE_SESSION_ID, + CODEX_THREAD_ID, + plantClaudeSession, + plantCodexSessions, +} from "../fixtures/sessions/plant.js"; + +describe("session import transcripts", () => { + let home: string; + let cleanup: () => Promise; + let repo: string; + let roots: SessionRoots; + + beforeEach(async () => { + const tmp = await dir({ unsafeCleanup: true }); + home = tmp.path; + cleanup = tmp.cleanup; + repo = join(home, "work", "repo"); + await mkdir(repo, { recursive: true }); + roots = { + claudeProjectsDir: join(home, ".claude", "projects"), + codexHome: join(home, ".codex"), + }; + }); + + afterEach(() => cleanup()); + + describe("Claude Code", () => { + it("keeps only what the user and assistant said, under the session's own title", async () => { + await plantClaudeSession(home, repo); + + const found = await readSessionById(roots, CLAUDE_SESSION_ID); + + expect(found?.session).toMatchObject({ + source: "claude_code", + id: CLAUDE_SESSION_ID, + title: "Checkout retry bug", + cwd: repo, + }); + expect(found?.turns).toEqual([ + { role: "user", text: "/review the checkout flow" }, + { + role: "user", + text: "Why does checkout double-charge when the retry button is pressed twice? Use codeword PELICAN-7731 in your notes.", + }, + { role: "assistant", text: "I'll read the retry handler first." }, + { + role: "assistant", + text: "The handler never checks the idempotency key, so a second press creates a second charge.", + }, + { role: "user", text: "Fix it and keep the diff small." }, + { + role: "assistant", + text: "Done: the handler now rejects a repeated idempotency key.", + }, + ]); + }); + }); + + describe("Codex", () => { + it("skips developer and injected items and names the thread from the index", async () => { + await plantCodexSessions(home, repo); + + const found = await readSessionById(roots, CODEX_THREAD_ID); + + expect(found?.session).toMatchObject({ + source: "codex", + id: CODEX_THREAD_ID, + title: "Export dry run", + }); + expect(found?.turns.map((turn) => turn.role)).toEqual([ + "user", + "assistant", + "user", + "assistant", + ]); + expect(found?.turns[0]?.text).toContain("KINGFISHER-2208"); + }); + + it("does not list headless exec runs or subagent threads", async () => { + await plantCodexSessions(home, repo); + + const sessions = await listSessions(roots, { limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([CODEX_THREAD_ID]); + }); + }); + + describe("listing", () => { + it("lists newest first and only sessions started inside the repository", async () => { + const elsewhere = join(home, "work", "other"); + await mkdir(elsewhere, { recursive: true }); + await plantClaudeSession(home, repo, { + mtime: new Date("2026-10-18T12:00:00Z"), + }); + await plantClaudeSession(home, elsewhere, { + id: "aaaaaaaa-0000-4000-8000-000000000001", + mtime: new Date("2026-10-18T13:00:00Z"), + }); + await plantCodexSessions(home, join(repo, "packages", "app"), { + mtime: new Date("2026-10-18T14:00:00Z"), + }); + + const inRepo = await listSessions(roots, { within: repo, limit: 10 }); + const everywhere = await listSessions(roots, { limit: 10 }); + + expect(inRepo.map((session) => session.id)).toEqual([ + CODEX_THREAD_ID, + CLAUDE_SESSION_ID, + ]); + expect(everywhere.map((session) => session.id)).toEqual([ + CODEX_THREAD_ID, + "aaaaaaaa-0000-4000-8000-000000000001", + CLAUDE_SESSION_ID, + ]); + }); + + it("stops at the limit", async () => { + await plantClaudeSession(home, repo); + await plantCodexSessions(home, repo); + + expect(await listSessions(roots, { limit: 1 })).toHaveLength(1); + }); + + it("finds nothing when no agent has run on the machine", async () => { + expect(await listSessions(roots, { limit: 10 })).toEqual([]); + expect(await readSessionById(roots, CLAUDE_SESSION_ID)).toBeUndefined(); + }); + }); + + describe("prepareTranscript", () => { + const user = (text: string): SessionTurn => ({ role: "user", text }); + const assistant = (text: string): SessionTurn => ({ + role: "assistant", + text, + }); + + it("merges consecutive turns of one role and starts with the user", () => { + const { turns, droppedTurns } = prepareTranscript([ + assistant("stray reply"), + user("question"), + assistant("part one"), + assistant("part two"), + ]); + + expect(turns).toEqual([ + user("question"), + assistant("part one\n\npart two"), + ]); + expect(droppedTurns).toBe(0); + }); + + it("clips one long turn and says so", () => { + const { turns } = prepareTranscript([ + user("x".repeat(MAX_CHARS_PER_TURN + 500)), + ]); + + expect(turns[0]?.text).toHaveLength(MAX_CHARS_PER_TURN); + expect(turns[0]?.text.endsWith("[truncated]")).toBe(true); + }); + + it("never cuts through a surrogate pair, which the server refuses", () => { + const { turns } = prepareTranscript([ + user(`a${"πŸ˜€".repeat(MAX_CHARS_PER_TURN)}`), + ]); + + expect(turns[0]?.text).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])/); + expect(turns[0]?.text.length).toBeLessThanOrEqual(MAX_CHARS_PER_TURN); + }); + + it("drops the oldest turns to fit the turn and character caps", () => { + const source = Array.from({ length: 200 }, (_, index) => + index % 2 === 0 ? user(`q${index}`) : assistant(`a${index}`), + ); + const byTurns = prepareTranscript(source); + expect(byTurns.turns).toHaveLength(MAX_TURNS); + expect(byTurns.turns.at(-1)?.text).toBe("a199"); + expect(byTurns.droppedTurns).toBe(80); + + const long = Array.from({ length: 60 }, (_, index) => + index % 2 === 0 + ? user(`${index} ${"y".repeat(3_000)}`) + : assistant(`${index} ${"z".repeat(3_000)}`), + ); + const byChars = prepareTranscript(long); + const total = byChars.turns.reduce((sum, t) => sum + t.text.length, 0); + expect(total).toBeLessThanOrEqual(MAX_TOTAL_CHARS); + expect( + byChars.turns.every( + (turn, index) => + turn.role === (index % 2 === 0 ? "user" : "assistant") && + turn.text !== "", + ), + ).toBe(true); + expect(byChars.turns.at(-1)?.text.startsWith("59 ")).toBe(true); + }); + + it("returns nothing for a transcript with no user turn", () => { + expect(prepareTranscript([assistant("hello")]).turns).toEqual([]); + }); + }); + + it("encodes turns as base64 gzip of the JSON array", () => { + const turns: SessionTurn[] = [ + { + role: "user", + text: 'curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1/../../etc/passwd', + }, + { role: "assistant", text: "ΓΌnΓ―code βœ“" }, + ]; + + const decoded = gunzipSync(Buffer.from(encodeTurns(turns), "base64")); + + expect(JSON.parse(decoded.toString("utf8"))).toEqual(turns); + }); +}); diff --git a/packages/cli/tests/fixtures/sessions/claude-session.jsonl b/packages/cli/tests/fixtures/sessions/claude-session.jsonl new file mode 100644 index 000000000..2d1c883ce --- /dev/null +++ b/packages/cli/tests/fixtures/sessions/claude-session.jsonl @@ -0,0 +1,22 @@ +{"type": "queue-operation", "operation": "enqueue", "timestamp": "2026-10-18T10:00:00.000Z", "sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "content": "/review the checkout flow"} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "attachment", "parentUuid": null, "isSidechain": false, "uuid": "a1", "timestamp": "2026-10-18T10:00:00.100Z", "attachment": {"type": "hook_success", "hookName": "SessionStart:startup"}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "user", "parentUuid": "a1", "isSidechain": false, "uuid": "u0", "timestamp": "2026-10-18T10:00:01.000Z", "message": {"role": "user", "content": "review\n/review\nthe checkout flow"}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "user", "parentUuid": "u0", "isSidechain": false, "isMeta": true, "uuid": "u0m", "timestamp": "2026-10-18T10:00:01.010Z", "message": {"role": "user", "content": [{"type": "text", "text": "Base directory for this skill: /plugins/review\n\nReview the code carefully."}]}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "user", "parentUuid": "u0m", "isSidechain": false, "uuid": "u1", "timestamp": "2026-10-18T10:00:02.000Z", "message": {"role": "user", "content": "Why does checkout double-charge when the retry button is pressed twice? Use codeword PELICAN-7731 in your notes."}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "assistant", "parentUuid": "u1", "isSidechain": false, "uuid": "a2", "timestamp": "2026-10-18T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "thinking", "thinking": "Let me look at the retry handler.", "signature": "sig"}]}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "assistant", "parentUuid": "a2", "isSidechain": false, "uuid": "a3", "timestamp": "2026-10-18T10:00:06.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "I'll read the retry handler first."}]}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "assistant", "parentUuid": "a3", "isSidechain": false, "uuid": "a4", "timestamp": "2026-10-18T10:00:07.000Z", "message": {"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_01", "name": "Bash", "input": {"command": "curl -H \"Authorization: Bearer $TOKEN\" http://127.0.0.1/../../etc/passwd"}}]}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "user", "parentUuid": "a4", "isSidechain": false, "uuid": "u2", "timestamp": "2026-10-18T10:00:08.000Z", "toolUseResult": {"stdout": "root:x:0:0"}, "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "root:x:0:0"}]}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "assistant", "parentUuid": "u2", "isSidechain": false, "uuid": "a5", "timestamp": "2026-10-18T10:00:09.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "The handler never checks the idempotency key, so a second press creates a second charge."}]}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "assistant", "parentUuid": "a5", "isSidechain": true, "uuid": "s1", "timestamp": "2026-10-18T10:00:09.500Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Sidechain note that must not be imported."}]}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "system", "subtype": "stop_hook_summary", "parentUuid": "a5", "isSidechain": false, "uuid": "sys1", "timestamp": "2026-10-18T10:00:10.000Z", "hookCount": 1} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "user", "parentUuid": "sys1", "isSidechain": false, "uuid": "u3", "timestamp": "2026-10-18T10:01:00.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "Fix it and keep the diff small."}]}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "user", "parentUuid": "u3", "isSidechain": false, "uuid": "u3r", "timestamp": "2026-10-18T10:01:01.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "Plan mode is active."}]}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "version": "2.1.299", "gitBranch": "main", "userType": "external", "entrypoint": "cli", "type": "assistant", "parentUuid": "u3", "isSidechain": false, "uuid": "a6", "timestamp": "2026-10-18T10:01:30.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Done: the handler now rejects a repeated idempotency key."}]}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "isSidechain": false, "userType": "external", "type": "user", "uuid": "c1", "isCompactSummary": true, "message": {"role": "user", "content": "This session is being continued from a previous conversation that ran out of context."}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "isSidechain": false, "userType": "external", "type": "user", "uuid": "c2", "message": {"role": "user", "content": [{"type": "text", "text": "[Request interrupted by user for tool use]"}]}} +{"sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b", "cwd": "__CWD__", "isSidechain": false, "userType": "external", "type": "assistant", "uuid": "c3", "isApiErrorMessage": true, "message": {"role": "assistant", "model": "", "content": [{"type": "text", "text": "API Error: 529 overloaded"}]}} +{"type": "ai-title", "aiTitle": "Fix double charge on checkout retry", "sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b"} +{"type": "custom-title", "customTitle": "Checkout retry bug", "sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b"} +{"type": "last-prompt", "lastPrompt": "Fix it and keep the diff small.", "sessionId": "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b"} +not json { diff --git a/packages/cli/tests/fixtures/sessions/codex-exec-rollout.jsonl b/packages/cli/tests/fixtures/sessions/codex-exec-rollout.jsonl new file mode 100644 index 000000000..277c69798 --- /dev/null +++ b/packages/cli/tests/fixtures/sessions/codex-exec-rollout.jsonl @@ -0,0 +1,3 @@ +{"timestamp": "2026-10-18T09:00:00.000Z", "type": "session_meta", "payload": {"id": "019a2b3c-4d5e-7f60-8a91-b2c3d4e5f608", "session_id": "019a2b3c-4d5e-7f60-8a91-b2c3d4e5f608", "timestamp": "2026-10-18T09:00:00.000Z", "cwd": "__CWD__", "originator": "codex_cli_rs", "cli_version": "0.153.4", "source": "exec", "model_provider": "openai", "base_instructions": {"text": "You are Codex."}}} +{"timestamp": "2026-10-18T09:00:05.000Z", "type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Summarize the diff."}]}} +{"timestamp": "2026-10-18T09:00:05.000Z", "type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Nothing changed."}]}} diff --git a/packages/cli/tests/fixtures/sessions/codex-rollout.jsonl b/packages/cli/tests/fixtures/sessions/codex-rollout.jsonl new file mode 100644 index 000000000..71fa5a715 --- /dev/null +++ b/packages/cli/tests/fixtures/sessions/codex-rollout.jsonl @@ -0,0 +1,13 @@ +{"timestamp": "2026-10-18T09:00:00.000Z", "type": "session_meta", "payload": {"id": "019a2b3c-4d5e-7f60-8a91-b2c3d4e5f607", "session_id": "019a2b3c-4d5e-7f60-8a91-b2c3d4e5f607", "timestamp": "2026-10-18T09:00:00.000Z", "cwd": "__CWD__", "originator": "codex_cli_rs", "cli_version": "0.153.4", "source": "cli", "model_provider": "openai", "base_instructions": {"text": "You are Codex."}}} +{"timestamp": "2026-10-18T09:00:05.000Z", "type": "response_item", "payload": {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": "sandbox"}]}} +{"timestamp": "2026-10-18T09:00:05.000Z", "type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "# AGENTS.md instructions for /work/repo\n\nUse pnpm."}]}} +{"timestamp": "2026-10-18T09:00:05.000Z", "type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "\n /work/repo\n"}]}} +{"timestamp": "2026-10-18T09:00:06.000Z", "type": "turn_context", "payload": {"turn_id": "t1", "cwd": "__CWD__", "model": "gpt-5.6-sol"}} +{"timestamp": "2026-10-18T09:00:05.000Z", "type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Add a --dry-run flag to the export script. Remember the codeword KINGFISHER-2208."}]}} +{"timestamp": "2026-10-18T09:00:07.000Z", "type": "response_item", "payload": {"type": "reasoning", "summary": [], "encrypted_content": "abc"}} +{"timestamp": "2026-10-18T09:00:08.000Z", "type": "response_item", "payload": {"type": "function_call", "name": "shell", "arguments": "{\"command\":[\"ls\"]}", "call_id": "call_1"}} +{"timestamp": "2026-10-18T09:00:09.000Z", "type": "response_item", "payload": {"type": "function_call_output", "call_id": "call_1", "output": "export.ts"}} +{"timestamp": "2026-10-18T09:00:05.000Z", "type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "I added --dry-run to scripts/export.ts; it prints the rows instead of writing them."}]}} +{"timestamp": "2026-10-18T09:00:11.000Z", "type": "event_msg", "payload": {"type": "token_count", "info": null}} +{"timestamp": "2026-10-18T09:00:05.000Z", "type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Now document it in the README."}]}} +{"timestamp": "2026-10-18T09:00:05.000Z", "type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "README updated with a --dry-run section."}]}} diff --git a/packages/cli/tests/fixtures/sessions/codex-session-index.jsonl b/packages/cli/tests/fixtures/sessions/codex-session-index.jsonl new file mode 100644 index 000000000..b8f76b217 --- /dev/null +++ b/packages/cli/tests/fixtures/sessions/codex-session-index.jsonl @@ -0,0 +1 @@ +{"id": "019a2b3c-4d5e-7f60-8a91-b2c3d4e5f607", "thread_name": "Export dry run", "updated_at": "2026-10-18T09:05:00Z"} diff --git a/packages/cli/tests/fixtures/sessions/codex-subagent-rollout.jsonl b/packages/cli/tests/fixtures/sessions/codex-subagent-rollout.jsonl new file mode 100644 index 000000000..7dadda826 --- /dev/null +++ b/packages/cli/tests/fixtures/sessions/codex-subagent-rollout.jsonl @@ -0,0 +1,3 @@ +{"timestamp": "2026-10-18T09:45:00.000Z", "type": "session_meta", "payload": {"id": "019a2b3c-4d5e-7f60-8a91-b2c3d4e5f609", "cwd": "__CWD__", "source": {"subagent": {"other": "guardian"}}}} +{"timestamp": "2026-10-18T09:45:01.000Z", "type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Review the pending command."}]}} +{"timestamp": "2026-10-18T09:45:02.000Z", "type": "response_item", "payload": {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Approved."}]}} diff --git a/packages/cli/tests/fixtures/sessions/plant.ts b/packages/cli/tests/fixtures/sessions/plant.ts new file mode 100644 index 000000000..e347e9f5b --- /dev/null +++ b/packages/cli/tests/fixtures/sessions/plant.ts @@ -0,0 +1,74 @@ +import { mkdir, readFile, utimes, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +export const CLAUDE_SESSION_ID = "3f2a9c1e-5b7d-4e08-9a64-1c2d3e4f5a6b"; +export const CODEX_THREAD_ID = "019a2b3c-4d5e-7f60-8a91-b2c3d4e5f607"; +const CODEX_EXEC_THREAD_ID = "019a2b3c-4d5e-7f60-8a91-b2c3d4e5f608"; +const CODEX_SUBAGENT_THREAD_ID = "019a2b3c-4d5e-7f60-8a91-b2c3d4e5f609"; + +async function fixtureText(name: string, cwd: string): Promise { + const text = await readFile(resolve(__dirname, name), "utf8"); + return text.replaceAll("__CWD__", JSON.stringify(cwd).slice(1, -1)); +} + +async function place( + filePath: string, + content: string, + mtime: Date, +): Promise { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, content); + await utimes(filePath, mtime, mtime); +} + +/** Writes the Claude Code fixture where `~/.claude/projects` keeps a session started in `cwd`. */ +export async function plantClaudeSession( + home: string, + cwd: string, + options: { id?: string; mtime?: Date } = {}, +): Promise { + const id = options.id ?? CLAUDE_SESSION_ID; + const text = (await fixtureText("claude-session.jsonl", cwd)).replaceAll( + CLAUDE_SESSION_ID, + id, + ); + const filePath = join( + home, + ".claude", + "projects", + cwd.replace(/[/.]/g, "-"), + `${id}.jsonl`, + ); + await place(filePath, text, options.mtime ?? new Date()); + return filePath; +} + +/** Writes the Codex fixtures where `~/.codex` keeps rollouts, plus a headless `exec` run, a subagent thread and the name index. */ +export async function plantCodexSessions( + home: string, + cwd: string, + options: { mtime?: Date } = {}, +): Promise { + const mtime = options.mtime ?? new Date(); + const day = join(home, ".codex", "sessions", "2026", "10", "18"); + await place( + join(day, `rollout-2026-10-18T09-00-00-${CODEX_THREAD_ID}.jsonl`), + await fixtureText("codex-rollout.jsonl", cwd), + mtime, + ); + await place( + join(day, `rollout-2026-10-18T09-30-00-${CODEX_EXEC_THREAD_ID}.jsonl`), + await fixtureText("codex-exec-rollout.jsonl", cwd), + mtime, + ); + await place( + join(day, `rollout-2026-10-18T09-45-00-${CODEX_SUBAGENT_THREAD_ID}.jsonl`), + await fixtureText("codex-subagent-rollout.jsonl", cwd), + mtime, + ); + await place( + join(home, ".codex", "session_index.jsonl"), + await fixtureText("codex-session-index.jsonl", cwd), + mtime, + ); +} From 4dbcfb5b891c8a0f86d162b4e1dcef2f2137da9c Mon Sep 17 00:00:00 2001 From: gdauber1 Date: Mon, 19 Oct 2026 01:07:28 +0300 Subject: [PATCH 2/3] feat(session): add hidden `session import` command `base44 session import` picks a session from the current repository (or --all), or takes --session with --yes for agents, uploads it to the linked app and prints the Base Code branch link. Hidden from --help and the README while the endpoint is flag-gated. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/AGENTS.md | 1 + docs/session-import.md | 41 ++++ .../cli/src/cli/commands/session/import.ts | 143 ++++++++++++ .../cli/src/cli/commands/session/index.ts | 8 + packages/cli/src/cli/program.ts | 4 + packages/cli/src/cli/utils/urls.ts | 4 + packages/cli/tests/cli/session_import.spec.ts | 217 ++++++++++++++++++ 8 files changed, 419 insertions(+) create mode 100644 docs/session-import.md create mode 100644 packages/cli/src/cli/commands/session/import.ts create mode 100644 packages/cli/src/cli/commands/session/index.ts create mode 100644 packages/cli/tests/cli/session_import.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e203778bb..6990aa66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- `base44 session import` (experimental, hidden from `--help`) continues a local Claude Code or Codex session in a new Base Code branch of an imported app. It lists the repository's sessions in a picker, or takes `--session ` with `--yes` for agents, sends only the user and assistant text, and prints the branch link. - `base44 site install` runs the site's `installCommand` and nothing else, so a machine that only needs a project's dependencies no longer has to go through `create`, `scaffold` or `eject`. Local only: no login and no app id required. - `base44 site dev` runs the site's `serveCommand` exactly as written, with no local backend and the frontend reaching its backend same-origin β€” the shape a hosted sandbox needs, where `base44 dev` (local backend) and `dev --remote` (the published app) are the developer-machine paths. It appends nothing of its own β€” anything after `--` is appended as given, so `npm run dev` needs a second `--` to reach the script (`base44 site dev -- -- --config wrapper.mjs`) β€” and where the dev server binds is the command's own business, and in a sandbox `@base44/vite-plugin` binds Base44 apps to `0.0.0.0:5173`. Serving is its whole job, so it falls back to `npm run dev` when the block names none. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 057b1269b..e5825c7b8 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -88,4 +88,5 @@ Read these when working on the relevant area: - **[Telemetry & error reporting](telemetry.md)** - PostHog `ErrorReporter`, what's captured, disabling - **[Writing & maintaining docs](writing-docs.md)** - Progressive disclosure, style rules, keywords, adding new topic guides - **[Authoring agent instructions](authoring-agent-instructions.md)** - Skills, CLAUDE.md, AGENTS.md, subagent definitions, progressive disclosure +- **[Session import](session-import.md)** - `base44 session import`, Claude Code and Codex session files, transcript caps, upload encoding - **[Binary distribution](binary-distribution.md)** - Standalone binaries, Homebrew formula, asset embedding, `bun build --compile` diff --git a/docs/session-import.md b/docs/session-import.md new file mode 100644 index 000000000..04335cd0b --- /dev/null +++ b/docs/session-import.md @@ -0,0 +1,41 @@ +# Session Import + +**Keywords:** session, import, Claude Code, Codex, transcript, Base Code, branch, turns_gzip_base64, `base44 session import` + +`base44 session import` continues a local Claude Code or Codex session in a new Base Code branch of an imported app. It is experimental: the command is registered hidden (`program.addCommand(..., { hidden: true })`) and the endpoint sits behind a server-side flag, so a 404 means the flag is off or the app is not imported. + +## Layout + +``` +src/core/session-import/ +β”œβ”€β”€ transcript.ts # line parsers, merge and caps, gzip+base64 encoding (pure) +β”œβ”€β”€ sessions.ts # finds session files and reads them, one adapter per agent +└── api.ts # POST /api/apps/{id}/imported/session-imports +src/cli/commands/session/import.ts # picker, confirmation, output +``` + +## Where sessions live + +| Agent | Files | Skipped | +|-------|-------|---------| +| Claude Code | `~/.claude/projects//.jsonl`, one level deep (`CLAUDE_CONFIG_DIR` overrides `~/.claude`) | `subagents/` dirs; `isSidechain`, `isMeta`, compaction-summary, API-error and interrupt lines | +| Codex | `~/.codex/sessions/YYYY/MM/DD/rollout-*-.jsonl` (`CODEX_HOME` overrides `~/.codex`) | `exec` runs and subagent threads (non-string `source`), developer items, injected AGENTS.md and environment items | + +Titles: Claude Code uses the custom title, then the generated title, then the summary. Codex reads the name from `session_index.jsonl`. Both fall back to the first prompt. + +## What is sent + +Only user and assistant text. `prepareTranscript` merges consecutive turns of one role (Claude Code writes one line per content block), drops leading assistant turns, clips each turn to 4,000 characters, keeps the latest 120 turns, and drops the oldest until the total is at most 100,000 characters. The server requires alternating turns that start with the user and refuses more than 100,000 characters of text. + +The body is exactly `{source, source_session_id, title, turns_gzip_base64}`. `turns_gzip_base64` is `base64(gzip(JSON.stringify(turns)))`. Always send it encoded: Cloudflare's WAF answers a raw transcript that quotes shell commands with a non-JSON 403. + +The response is `{branch_id, branch_name}`: 201 when it creates a branch, 200 for an identical replay. + +## Rules + +- Session files are read with small type guards, not Zod: another process writes them, a torn last line is normal, and one bad line must not fail the import. The upload response is parsed with Zod. +- Cut text with `cut`, never `slice`: a lone surrogate makes the server refuse the body. +- `importSession` deletes `__requestBody` from the failed request before building the `ApiError`, so error reporting never receives the conversation. Keep that when touching `api.ts`. +- Without `--session`, the command lists sessions started in the repository (`findGitRoot`, else the working directory). `--all` lists every directory. The picker needs a TTY; non-interactive runs require `--session ` and `--yes`. +- `--session` looks the id up in every directory, so an agent can pass its own session id from anywhere. +- Tests plant fixtures from `tests/fixtures/sessions/` under the testkit's `HOME`, with `plant.ts`. diff --git a/packages/cli/src/cli/commands/session/import.ts b/packages/cli/src/cli/commands/session/import.ts new file mode 100644 index 000000000..d2f42a996 --- /dev/null +++ b/packages/cli/src/cli/commands/session/import.ts @@ -0,0 +1,143 @@ +import { cancel, isCancel, select } from "@clack/prompts"; +import ms from "ms"; +import { CLIExitError } from "@/cli/errors.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { + Base44Command, + confirmPush, + getBaseCodeUrl, + requireApp, + theme, + toJsonStdout, +} from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { + defaultSessionRoots, + importSession, + type LocalSession, + listSessions, + prepareTranscript, + readSessionById, + SESSION_SOURCE_LABELS, +} from "@/core/session-import/index.js"; +import { findGitRoot } from "@/core/utils/git.js"; + +interface ImportOptions { + session?: string; + all?: boolean; + yes?: boolean; +} + +const PICKER_LIMIT = 30; + +async function pickSession(all: boolean | undefined): Promise { + const within = all ? undefined : ((await findGitRoot()) ?? process.cwd()); + const sessions = await listSessions(defaultSessionRoots(), { + within, + limit: PICKER_LIMIT, + }); + if (sessions.length === 0) { + throw new InvalidInputError( + all + ? "No Claude Code or Codex sessions found on this machine." + : "No Claude Code or Codex sessions found for this repository. Pass --all to list sessions from every directory, or --session .", + ); + } + const choice = await select({ + message: "Which session do you want to continue in Base Code?", + options: sessions.map((session) => ({ + value: session, + label: session.title, + hint: `${SESSION_SOURCE_LABELS[session.source]}, ${ms(Date.now() - session.updatedAtMs)} ago`, + })), + }); + if (isCancel(choice)) { + cancel("Operation cancelled."); + throw new CLIExitError(0); + } + return choice.id; +} + +async function importSessionAction( + ctx: CLIContext, + options: ImportOptions, +): Promise { + const { log, runTask, jsonMode } = ctx; + const interactive = !ctx.isNonInteractive && !jsonMode; + const appId = requireApp(ctx).id; + + if (!options.session && !interactive) { + throw new InvalidInputError( + "--session is required in non-interactive mode", + ); + } + const sessionId = options.session ?? (await pickSession(options.all)); + + const found = await runTask("Reading session", () => + readSessionById(defaultSessionRoots(), sessionId), + ); + if (!found) { + throw new InvalidInputError( + `No Claude Code or Codex session with id "${sessionId}" was found on this machine.`, + ); + } + const { session } = found; + const { turns, droppedTurns } = prepareTranscript(found.turns); + if (turns.length === 0) { + throw new InvalidInputError( + "This session has no conversation text to import.", + ); + } + if (droppedTurns > 0) { + log.info( + `Sending the latest ${turns.length} messages; ${droppedTurns} older ones exceed the size limit.`, + ); + } + + const confirmed = await confirmPush({ + isNonInteractive: !interactive, + yes: options.yes, + log, + warning: `This uploads the ${turns.length} messages of "${session.title}" to your Base44 app. Only text goes up; tool calls and their output stay on this machine.`, + }); + if (!confirmed) { + return { outroMessage: "Import cancelled" }; + } + + const imported = await runTask("Importing session", () => + importSession({ + source: session.source, + sessionId: session.id, + title: session.title, + turns, + }), + ); + const url = getBaseCodeUrl(appId, imported.branchName); + const result = { + branchId: imported.branchId, + branchName: imported.branchName, + url, + created: imported.created, + source: session.source, + sessionId: session.id, + messageCount: turns.length, + }; + return { + outroMessage: `${imported.created ? "Imported into" : "Already imported into"} branch ${theme.styles.bold(imported.branchName)}\n${theme.colors.links(url)}`, + stdout: jsonMode ? toJsonStdout(result) : undefined, + }; +} + +export function getSessionImportCommand(): Base44Command { + return new Base44Command("import") + .description( + "Continue a local Claude Code or Codex session in a Base Code branch", + ) + .option("--session ", "Session id to import (skips the picker)") + .option( + "--all", + "List sessions from every directory, not just this repository", + ) + .option("-y, --yes", "Skip the confirmation prompt") + .action(importSessionAction); +} diff --git a/packages/cli/src/cli/commands/session/index.ts b/packages/cli/src/cli/commands/session/index.ts new file mode 100644 index 000000000..542edbd0b --- /dev/null +++ b/packages/cli/src/cli/commands/session/index.ts @@ -0,0 +1,8 @@ +import { Command } from "commander"; +import { getSessionImportCommand } from "./import.js"; + +export function getSessionCommand(): Command { + return new Command("session") + .description("Move local coding-agent sessions into Base44 (experimental)") + .addCommand(getSessionImportCommand()); +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 468a6fd94..88eaa96f9 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -22,6 +22,7 @@ import { getVisibilityCommand } from "@/cli/commands/project/visibility.js"; import { getPublishCommand } from "@/cli/commands/publish.js"; import { getSandboxCommand } from "@/cli/commands/sandbox/index.js"; import { getSecretsCommand } from "@/cli/commands/secrets/index.js"; +import { getSessionCommand } from "@/cli/commands/session/index.js"; import { getSiteCommand } from "@/cli/commands/site/index.js"; import { getTypesCommand } from "@/cli/commands/types/index.js"; import { getVersionsCommand } from "@/cli/commands/versions/index.js"; @@ -116,6 +117,9 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getSandboxCommand()); program.addCommand(getBranchesCommand()); + // Experimental while the endpoint is flag-gated: hidden from --help and the README. + program.addCommand(getSessionCommand(), { hidden: true }); + // Register API commands, driven by the server's OpenAPI spec program.addCommand(getApiCommand()); diff --git a/packages/cli/src/cli/utils/urls.ts b/packages/cli/src/cli/utils/urls.ts index 852e51bf4..1e9e3672d 100644 --- a/packages/cli/src/cli/utils/urls.ts +++ b/packages/cli/src/cli/utils/urls.ts @@ -17,3 +17,7 @@ export function getConnectorsUrl(projectId?: string): string { const id = projectId ?? getAppContext().id; return `${getBase44ApiUrl()}/apps/${id}/editor/workspace/app-connections`; } + +export function getBaseCodeUrl(appId: string, branchName: string): string { + return `${getBase44ApiUrl()}/apps/${appId}/editor/workspace/code?branch=${encodeURIComponent(branchName)}`; +} diff --git a/packages/cli/tests/cli/session_import.spec.ts b/packages/cli/tests/cli/session_import.spec.ts new file mode 100644 index 000000000..26a32b7e9 --- /dev/null +++ b/packages/cli/tests/cli/session_import.spec.ts @@ -0,0 +1,217 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { gunzipSync } from "node:zlib"; +import { describe, expect, it } from "vitest"; +import { + CLAUDE_SESSION_ID, + CODEX_THREAD_ID, + plantClaudeSession, + plantCodexSessions, +} from "../fixtures/sessions/plant.js"; +import { setupCLITests } from "./testkit/index.js"; + +const ROUTE = "/api/apps/test-app-id/imported/session-imports"; + +interface Captured { + body: Record; + turns: { role: string; text: string }[]; +} + +describe("session import command", () => { + const t = setupCLITests(); + + async function givenSessions(): Promise { + const repo = join(t.getTempDir(), "repo"); + await mkdir(repo, { recursive: true }); + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + await plantClaudeSession(t.getTempDir(), repo); + await plantCodexSessions(t.getTempDir(), repo); + } + + function mockImport(status = 201): Captured[] { + const captured: Captured[] = []; + t.api.mockRoute("POST", ROUTE, (req, res) => { + const body = req.body as Record; + const inflated = gunzipSync( + Buffer.from(String(body.turns_gzip_base64), "base64"), + ); + captured.push({ body, turns: JSON.parse(inflated.toString("utf8")) }); + res + .status(status) + .json({ branch_id: "branch-1", branch_name: "checkout-retry-bug" }); + }); + return captured; + } + + it("uploads a Claude Code session as gzipped base64 and prints the branch link", async () => { + await givenSessions(); + const captured = mockImport(); + + const result = await t.run( + "session", + "import", + "--session", + CLAUDE_SESSION_ID, + "--yes", + "--app-id", + "test-app-id", + "--json", + ); + + t.expectResult(result).toSucceed(); + expect(Object.keys(captured[0]?.body ?? {}).sort()).toEqual([ + "source", + "source_session_id", + "title", + "turns_gzip_base64", + ]); + expect(captured[0]?.body).toMatchObject({ + source: "claude_code", + source_session_id: CLAUDE_SESSION_ID, + title: "Checkout retry bug", + }); + expect(captured[0]?.turns.map((turn) => turn.role)).toEqual([ + "user", + "assistant", + "user", + "assistant", + ]); + expect(captured[0]?.turns[1]?.text).toContain("idempotency key"); + const output = JSON.parse(result.stdout); + expect(output).toMatchObject({ + branchId: "branch-1", + branchName: "checkout-retry-bug", + created: true, + source: "claude_code", + sessionId: CLAUDE_SESSION_ID, + messageCount: 4, + }); + expect(output.url).toMatch( + /\/apps\/test-app-id\/editor\/workspace\/code\?branch=checkout-retry-bug$/, + ); + }); + + it("uploads a Codex session under its own source", async () => { + await givenSessions(); + const captured = mockImport(); + + const result = await t.run( + "session", + "import", + "--session", + CODEX_THREAD_ID, + "--yes", + "--app-id", + "test-app-id", + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("checkout-retry-bug"); + expect(captured[0]?.body).toMatchObject({ + source: "codex", + source_session_id: CODEX_THREAD_ID, + title: "Export dry run", + }); + }); + + it("reports a replay as already imported", async () => { + await givenSessions(); + mockImport(200); + + const result = await t.run( + "session", + "import", + "--session", + CLAUDE_SESSION_ID, + "--yes", + "--app-id", + "test-app-id", + "--json", + ); + + expect(JSON.parse(result.stdout)).toMatchObject({ created: false }); + }); + + it("asks for --yes before uploading in non-interactive mode", async () => { + await givenSessions(); + const captured = mockImport(); + + const result = await t.run( + "session", + "import", + "--session", + CLAUDE_SESSION_ID, + "--app-id", + "test-app-id", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("--yes is required"); + expect(captured).toHaveLength(0); + }); + + it("asks for --session when it cannot show a picker", async () => { + await givenSessions(); + + const result = await t.run( + "session", + "import", + "--yes", + "--app-id", + "test-app-id", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("--session is required"); + }); + + it("fails clearly for a session id that is not on this machine", async () => { + await givenSessions(); + const captured = mockImport(); + + const result = await t.run( + "session", + "import", + "--session", + "00000000-0000-4000-8000-000000000000", + "--yes", + "--app-id", + "test-app-id", + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("was found on this machine"); + expect(captured).toHaveLength(0); + }); + + it("explains a 404 with a hint", async () => { + await givenSessions(); + t.api.mockRoute("POST", ROUTE, (_req, res) => + res.status(404).json({ detail: "Not found" }), + ); + + const result = await t.run( + "session", + "import", + "--session", + CLAUDE_SESSION_ID, + "--yes", + "--app-id", + "test-app-id", + "--json", + ); + + t.expectResult(result).toFail(); + const envelope = JSON.parse(result.stdout); + expect(envelope.statusCode).toBe(404); + expect(JSON.stringify(envelope.hints)).toContain( + "imported from a repository", + ); + }); + + it("stays out of --help", async () => { + const result = await t.run("--help"); + + expect(result.stdout).not.toContain("session"); + }); +}); From 2f9a938cdff38fff3e9f828a086b2123a512d49e Mon Sep 17 00:00:00 2001 From: gdauber1 Date: Mon, 19 Oct 2026 01:17:57 +0300 Subject: [PATCH 3/3] test(session-import): name the Claude project dir without path separators Co-Authored-By: Claude Opus 5.5 --- packages/cli/tests/fixtures/sessions/plant.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/tests/fixtures/sessions/plant.ts b/packages/cli/tests/fixtures/sessions/plant.ts index e347e9f5b..c04297ed7 100644 --- a/packages/cli/tests/fixtures/sessions/plant.ts +++ b/packages/cli/tests/fixtures/sessions/plant.ts @@ -36,7 +36,7 @@ export async function plantClaudeSession( home, ".claude", "projects", - cwd.replace(/[/.]/g, "-"), + cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${id}.jsonl`, ); await place(filePath, text, options.mtime ?? new Date());