Repository navigation
feat(cli): add a client-only migrate API to bring Claude Code and Codex sessions into Kilo #13294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
b59ebd7
feat(cli): import Claude Code and Codex transcripts via a session-res…
kirillk 19ceae0
feat(cli): discover importable Claude Code / Codex sessions via sessi…
kirillk 0bfc479
fix(cli): reject unknown session and agent in session-resume import
kirillk 61b705a
Merge remote-tracking branch 'origin/main' into feat/claude-code-sess…
kirillk 8d05e8a
refactor(cli): move session transcript import to a migrate API group
kirillk 6cc21af
feat(cli): make session migration server-side and idempotent
kirillk 2e1a704
fix(cli): look up migration markers without the recent-session page l…
kirillk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@kilocode/cli": minor | ||
| --- | ||
|
|
||
| Expose Claude Code and OpenAI Codex session transcript import through a CLI server endpoint (`POST /kilocode/session-resume`), so any client can import an external JSONL transcript into an empty Kilo session by passing the raw transcript content. The existing `/resume-claude` and `/resume-codex` slash commands now share this same import path. |
68 changes: 68 additions & 0 deletions
68
packages/opencode/src/kilocode/server/httpapi/groups/session-resume.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import { Schema } from "effect" | ||
| import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" | ||
| import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" | ||
| import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" | ||
| import { | ||
| WorkspaceRoutingMiddleware, | ||
| WorkspaceRoutingQuery, | ||
| } from "@/server/routes/instance/httpapi/middleware/workspace-routing" | ||
| import { described } from "@/server/routes/instance/httpapi/groups/metadata" | ||
|
|
||
| const root = "/kilocode/session-resume" | ||
|
|
||
| export const SessionResumePayload = Schema.Struct({ | ||
| sessionID: Schema.String.annotate({ | ||
| description: "Target Kilo session. Must be empty (no existing messages).", | ||
| }), | ||
| content: Schema.String.annotate({ | ||
| description: "Raw JSONL transcript content (Claude Code or OpenAI Codex, Anthropic message format).", | ||
| }), | ||
| agent: Schema.optional(Schema.String).annotate({ | ||
| description: "Agent name to attribute the imported messages to. Defaults to the default agent.", | ||
| }), | ||
| model: Schema.optional(Schema.String).annotate({ | ||
| description: "Model reference (providerID/modelID). Defaults to the agent or provider default.", | ||
| }), | ||
| }) | ||
|
|
||
| const SessionResumeResult = Schema.Struct({ | ||
| messageID: Schema.String.annotate({ description: "Final assistant message written by the import." }), | ||
| format: Schema.Literals(["claude", "codex"]).annotate({ description: "Detected transcript format." }), | ||
| messages: Schema.Finite.annotate({ description: "Number of messages written to the session." }), | ||
| dropped: Schema.Array(Schema.String).annotate({ | ||
| description: "Human-readable reasons for content that could not be imported.", | ||
| }), | ||
| }).annotate({ identifier: "KilocodeSessionResumeResult" }) | ||
|
|
||
| export class SessionResumeFailedError extends Schema.ErrorClass<SessionResumeFailedError>("SessionResumeFailedError")( | ||
| { message: Schema.String }, | ||
| { httpApiStatus: 422 }, | ||
| ) {} | ||
|
|
||
| export const SessionResumeApi = HttpApi.make("session-resume").add( | ||
| HttpApiGroup.make("session-resume") | ||
| .add( | ||
| HttpApiEndpoint.post("import", root, { | ||
| query: WorkspaceRoutingQuery, | ||
| payload: SessionResumePayload, | ||
| success: described(SessionResumeResult, "Session import result"), | ||
| error: [HttpApiError.BadRequest, SessionResumeFailedError], | ||
| }).annotateMerge( | ||
| OpenApi.annotations({ | ||
| identifier: "kilocode.sessionResume.import", | ||
| summary: "Import an external session transcript", | ||
| description: | ||
| "Parse a Claude Code or OpenAI Codex JSONL transcript and import it into an empty Kilo session.", | ||
| }), | ||
| ), | ||
| ) | ||
| .annotateMerge( | ||
| OpenApi.annotations({ | ||
| title: "session-resume", | ||
| description: "Kilo external session transcript import routes.", | ||
| }), | ||
| ) | ||
| .middleware(InstanceContextMiddleware) | ||
| .middleware(WorkspaceRoutingMiddleware) | ||
| .middleware(Authorization), | ||
| ) |
35 changes: 35 additions & 0 deletions
35
packages/opencode/src/kilocode/server/httpapi/handlers/session-resume.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { Effect } from "effect" | ||
| import { HttpApiBuilder } from "effect/unstable/httpapi" | ||
| import { NamedError } from "@opencode-ai/core/util/error" | ||
| import { SessionResumeImport } from "@/kilocode/session-resume/import" | ||
| import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" | ||
| import { SessionResumeFailedError, SessionResumePayload } from "../groups/session-resume" | ||
|
|
||
| export const sessionResumeHandlers = HttpApiBuilder.group(InstanceHttpApi, "session-resume", (handlers) => | ||
| Effect.gen(function* () { | ||
| const doImport = Effect.fn("SessionResumeHttpApi.import")(function* (ctx: { | ||
| payload: typeof SessionResumePayload.Type | ||
| }) { | ||
| const result = yield* SessionResumeImport.fromContent({ | ||
| sessionID: ctx.payload.sessionID, | ||
| content: ctx.payload.content, | ||
| agent: ctx.payload.agent, | ||
| model: ctx.payload.model, | ||
| }).pipe( | ||
| Effect.catch((err) => | ||
| NamedError.Unknown.isInstance(err) | ||
| ? Effect.fail(new SessionResumeFailedError({ message: err.data.message })) | ||
| : Effect.fail(err), | ||
| ), | ||
| ) | ||
| return { | ||
| messageID: result.messageID, | ||
| format: result.format, | ||
| messages: result.messages, | ||
| dropped: result.dropped, | ||
| } | ||
| }) | ||
|
|
||
| return handlers.handle("import", doImport) | ||
| }), | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
211 changes: 211 additions & 0 deletions
211
packages/opencode/src/kilocode/session-resume/import.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| // kilocode_change - new file | ||
| // | ||
| // Shared "import a parsed transcript into a Kilo session" logic. | ||
| // | ||
| // SessionResume (./index.ts) is a pure parser/mapper with no Effect or Session | ||
| // dependencies. This module adds the Effect-based orchestration that both the | ||
| // `/resume-claude` / `/resume-codex` slash commands (src/session/prompt.ts) and | ||
| // the HTTP endpoint (src/kilocode/server/httpapi/.../session-resume) use to map a | ||
| // parsed transcript and write the resulting messages/parts through | ||
| // Session.Service. Keeping the write path here means every client (VS Code, CLI, | ||
| // TUI) can trigger a Claude Code / Codex import through the CLI server without | ||
| // reimplementing it. | ||
|
|
||
| import { Effect } from "effect" | ||
| import * as InstanceState from "@/effect/instance-state" | ||
| import { Agent } from "@/agent/agent" | ||
| import { Provider } from "@/provider/provider" | ||
| import { Session } from "@/session/session" | ||
| import { SessionID, MessageID, PartID } from "@/session/schema" | ||
| import { SessionV1 } from "@opencode-ai/core/v1/session" | ||
| import { NamedError } from "@opencode-ai/core/util/error" | ||
| import { SessionResume } from "./index" | ||
|
|
||
| export namespace SessionResumeImport { | ||
| export type WriteInput = { | ||
| sessionID: string | ||
| transcript: SessionResume.Transcript | ||
| agent: string | ||
| providerID: string | ||
| modelID: string | ||
| directory: string | ||
| worktree: string | ||
| } | ||
|
|
||
| export type WriteResult = { | ||
| /** Final assistant message written by the import. */ | ||
| last: SessionV1.WithParts | ||
| /** Number of messages written to the session. */ | ||
| messages: number | ||
| /** Human-readable reasons for any content that could not be imported. */ | ||
| dropped: string[] | ||
| } | ||
|
|
||
| const fail = (message: string) => Effect.fail(new NamedError.Unknown({ message })) | ||
|
|
||
| /** | ||
| * Map a parsed transcript and write it into an existing empty Kilo session. | ||
| * | ||
| * Shared by the slash-command handler and the HTTP endpoint. The caller is | ||
| * responsible for resolving the agent + model and for rejecting nonempty | ||
| * sessions before calling this. Fails with NamedError.Unknown for structural | ||
| * problems (empty transcript, assistant-first) before writing anything. | ||
| */ | ||
| export const write = Effect.fn("SessionResumeImport.write")(function* (input: WriteInput) { | ||
| const sessions = yield* Session.Service | ||
| const sessionID = SessionID.make(input.sessionID) | ||
|
|
||
| const { messages: mapped, dropped } = SessionResume.mapTranscript(input.transcript, { | ||
| sessionID: input.sessionID, | ||
| agent: input.agent, | ||
| providerID: input.providerID, | ||
| modelID: input.modelID, | ||
| directory: input.directory, | ||
| worktree: input.worktree, | ||
| sourceModel: input.transcript.sourceModel, | ||
| }) | ||
|
|
||
| // The first message must be a user message (assistant messages need a user parent). | ||
| if (mapped.length > 0 && mapped[0].info.role !== "user") { | ||
| return yield* fail("Transcript starts with an assistant message. The first message must be from a user.") | ||
| } | ||
|
|
||
| // Write messages and parts in transcript order with ascending IDs, remapping | ||
| // the placeholder IDs from mapTranscript to real ascending IDs. | ||
| const idMap = new Map<string, string>() | ||
| for (const item of mapped) { | ||
| const newID = MessageID.ascending() | ||
| idMap.set(item.info.id as string, newID) | ||
|
|
||
| const parentID = | ||
| item.info.role === "assistant" && typeof item.info.parentID === "string" | ||
| ? idMap.get(item.info.parentID) | ||
| : undefined | ||
|
|
||
| const info = { | ||
| ...item.info, | ||
| id: newID, | ||
| sessionID: input.sessionID, | ||
| ...(parentID && { parentID }), | ||
| } as SessionV1.Info | ||
|
|
||
| yield* sessions.updateMessage(info) | ||
|
|
||
| for (const part of item.parts) { | ||
| const p = { | ||
| ...part, | ||
| id: PartID.ascending(), | ||
| messageID: newID, | ||
| sessionID: input.sessionID, | ||
| } as SessionV1.Part | ||
| yield* sessions.updatePart(p) | ||
| } | ||
| } | ||
|
|
||
| yield* sessions.touch(sessionID) | ||
|
|
||
| const resultMsgs = yield* sessions.messages({ sessionID }).pipe(Effect.orDie) | ||
| const last = resultMsgs.findLast((m) => m.info.role === "assistant") | ||
| if (!last) { | ||
| return yield* fail("No assistant message found after import.") | ||
| } | ||
|
|
||
| return { last, messages: mapped.length, dropped } satisfies WriteResult | ||
| }) | ||
|
|
||
| export type Input = { | ||
| /** Target Kilo session. Must be empty (no existing messages). */ | ||
| sessionID: string | ||
| /** Raw JSONL transcript content (Claude Code or Codex). */ | ||
| content: string | ||
| /** Agent name to attribute the imported messages to. Defaults to the default agent. */ | ||
| agent?: string | ||
| /** Model reference (`providerID/modelID`). Defaults to the agent/provider default. */ | ||
| model?: string | ||
| } | ||
|
|
||
| export type Result = { | ||
| /** ID of the final assistant message written by the import. */ | ||
| messageID: string | ||
| /** Detected transcript format. */ | ||
| format: SessionResume.Format | ||
| /** Number of messages written to the session. */ | ||
| messages: number | ||
| /** Human-readable reasons for any content that could not be imported. */ | ||
| dropped: string[] | ||
| } | ||
|
|
||
| /** | ||
| * Import a raw JSONL transcript into an existing empty Kilo session. | ||
| * | ||
| * This is the entry point used by clients through the HTTP endpoint. It | ||
| * resolves the agent + model, validates the transcript, and delegates the | ||
| * write to `write`. Fails with NamedError.Unknown for any user-actionable | ||
| * problem, and never writes to a session unless every validation has passed. | ||
| */ | ||
| export const fromContent = Effect.fn("SessionResumeImport.fromContent")(function* (input: Input) { | ||
| const ctx = yield* InstanceState.context | ||
| const sessions = yield* Session.Service | ||
| const agents = yield* Agent.Service | ||
| const provider = yield* Provider.Service | ||
|
|
||
| const sessionID = SessionID.make(input.sessionID) | ||
|
|
||
| // Reject nonempty sessions — importing into a session with history would | ||
| // interleave the transcript with unrelated messages. | ||
| const existing = yield* sessions.messages({ sessionID }).pipe(Effect.orDie) | ||
| if (existing.length > 0) { | ||
| return yield* fail("Start a new Kilo session, then run the import again.") | ||
| } | ||
|
|
||
| // Resolve agent. | ||
| const agent = input.agent ? yield* agents.get(input.agent) : yield* agents.defaultInfo() | ||
|
kirillk marked this conversation as resolved.
|
||
|
|
||
| // Resolve model: explicit input → agent default → provider default. | ||
| const model = input.model | ||
| ? Provider.parseModel(input.model) | ||
| : (agent.model ?? (yield* provider.defaultModel().pipe(Effect.orDie))) | ||
| // Ensure the model is loadable so imported assistant messages reference a real model. | ||
| yield* provider | ||
| .getModel(model.providerID, model.modelID) | ||
| .pipe( | ||
| Effect.mapError( | ||
| (err) => new NamedError.Unknown({ message: `Model not found: ${err.providerID}/${err.modelID}` }), | ||
| ), | ||
| ) | ||
|
|
||
| // Parse the transcript. SessionResume.parseLines throws ParseError on bad input. | ||
| const transcript = yield* Effect.try({ | ||
| try: () => SessionResume.parseLines(input.content), | ||
| catch: (err) => { | ||
| const msg = err instanceof Error ? err.message : String(err) | ||
| return new NamedError.Unknown({ message: `Failed to parse session transcript: ${msg}` }) | ||
| }, | ||
| }) | ||
|
|
||
| // Reject transcripts without a real user message. | ||
| const hasRealUser = transcript.steps.some( | ||
| (s) => s.role === "user" && s.parts.some((p) => p.type === "text" && p.text.trim().length > 0), | ||
| ) | ||
| if (!hasRealUser) { | ||
| return yield* fail("The transcript contains no user messages. Nothing was imported.") | ||
| } | ||
|
|
||
| const result = yield* write({ | ||
| sessionID: input.sessionID, | ||
| transcript, | ||
| agent: agent.name, | ||
| providerID: model.providerID, | ||
| modelID: model.modelID, | ||
| directory: ctx.directory, | ||
| worktree: ctx.worktree, | ||
| }) | ||
|
|
||
| return { | ||
| messageID: result.last.info.id, | ||
| format: transcript.format, | ||
| messages: result.messages, | ||
| dropped: result.dropped, | ||
| } satisfies Result | ||
| }) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.