Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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.

Expand Down
1 change: 1 addition & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
41 changes: 41 additions & 0 deletions docs/session-import.md
Original file line number Diff line number Diff line change
@@ -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/<escaped-cwd>/<id>.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-*-<id>.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 <id>` 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`.
143 changes: 143 additions & 0 deletions packages/cli/src/cli/commands/session/import.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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 <id>.",
);
}
const choice = await select<LocalSession>({
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<RunCommandResult> {
const { log, runTask, jsonMode } = ctx;
const interactive = !ctx.isNonInteractive && !jsonMode;
const appId = requireApp(ctx).id;

if (!options.session && !interactive) {
throw new InvalidInputError(
"--session <id> 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 <id>", "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);
}
8 changes: 8 additions & 0 deletions packages/cli/src/cli/commands/session/index.ts
Original file line number Diff line number Diff line change
@@ -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());
}
4 changes: 4 additions & 0 deletions packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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());

Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/cli/utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;
}
92 changes: 92 additions & 0 deletions packages/cli/src/core/session-import/api.ts
Original file line number Diff line number Diff line change
@@ -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<ImportedSession> {
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 };
}
13 changes: 13 additions & 0 deletions packages/cli/src/core/session-import/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading