Skip to content
5 changes: 5 additions & 0 deletions .changeset/claude-code-session-import-endpoint.md
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.
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),
)
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)
}),
)
2 changes: 2 additions & 0 deletions packages/opencode/src/kilocode/server/httpapi/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { networkHandlers } from "./handlers/network"
import { remoteHandlers } from "./handlers/remote"
import { sandboxHandlers } from "./handlers/sandbox"
import { sessionImportHandlers } from "./handlers/session-import"
import { sessionResumeHandlers } from "./handlers/session-resume"
import { suggestionHandlers } from "./handlers/suggestion"
import { telemetryHandlers } from "./handlers/telemetry"

Expand All @@ -48,6 +49,7 @@ export const provide = Layer.provide([
remoteHandlers,
sandboxHandlers,
sessionImportHandlers,
sessionResumeHandlers,
suggestionHandlers,
telemetryHandlers,
])
Expand Down
211 changes: 211 additions & 0 deletions packages/opencode/src/kilocode/session-resume/import.ts
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)
Comment thread
kirillk marked this conversation as resolved.
Outdated
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()
Comment thread
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
})
}
2 changes: 2 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { NetworkApi } from "@/kilocode/server/httpapi/groups/network"
import { RemoteApi } from "@/kilocode/server/httpapi/groups/remote"
import { SandboxApi } from "@/kilocode/server/httpapi/groups/sandbox"
import { SessionImportApi } from "@/kilocode/server/httpapi/groups/session-import"
import { SessionResumeApi } from "@/kilocode/server/httpapi/groups/session-resume"
import { SuggestionApi } from "@/kilocode/server/httpapi/groups/suggestion"
import { TelemetryApi } from "@/kilocode/server/httpapi/groups/telemetry"
import { MemoryApi } from "@/kilocode/server/httpapi/groups/memory" // kilocode_change
Expand Down Expand Up @@ -112,6 +113,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(RemoteApi)
.addHttpApi(SandboxApi)
.addHttpApi(SessionImportApi)
.addHttpApi(SessionResumeApi)
.addHttpApi(SuggestionApi)
.addHttpApi(TelemetryApi)
.addHttpApi(MemoryApi)
Expand Down
Loading
Loading