Przeglądaj źródła

feat(cli): add session import and export (#40914)

James Long 1 tydzień temu
rodzic
commit
0cc507b8a9

+ 26 - 0
packages/cli/src/commands/commands.ts

@@ -140,6 +140,32 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
       description: "List all available models",
       params: ServerParams,
     }),
+    Spec.make("export", {
+      description: "Export session data as JSON",
+      params: {
+        ...ServerParams,
+        session: Flag.string("session").pipe(
+          Flag.withAlias("s"),
+          Flag.withDescription("Session ID to export to stdout"),
+          Flag.optional,
+        ),
+        sanitize: Flag.boolean("sanitize").pipe(
+          Flag.withDescription("Redact sensitive transcript and file data"),
+          Flag.withDefault(false),
+        ),
+      },
+    }),
+    Spec.make("import", {
+      description: "Import session data from a JSON file or URL",
+      params: {
+        ...ServerParams,
+        file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
+        directory: Flag.string("directory").pipe(
+          Flag.withDescription("Directory in which to import the session"),
+          Flag.optional,
+        ),
+      },
+    }),
     Spec.make("mini", {
       description: "Start the minimal interactive interface",
       params: {

+ 140 - 0
packages/cli/src/commands/handlers/export.ts

@@ -0,0 +1,140 @@
+import { OpenCode, type SessionInfo } from "@opencode-ai/client"
+import { Service } from "@opencode-ai/client/effect/service"
+import { Effect, Option } from "effect"
+import { EOL, tmpdir } from "node:os"
+import path from "node:path"
+import { emitKeypressEvents, type Key } from "node:readline"
+import { Commands } from "../commands"
+import { Runtime } from "../../framework/runtime"
+import { ServerConnection } from "../../services/server-connection"
+
+export default Runtime.handler(
+  Commands.commands.export,
+  Effect.fn("cli.export")(function* (input) {
+    const server = yield* ServerConnection.resolve({
+      server: Option.getOrUndefined(input.server),
+      standalone: input.standalone,
+    })
+    const client = OpenCode.make({
+      baseUrl: server.endpoint.url,
+      headers: Service.headers(server.endpoint),
+    })
+    const requested = Option.getOrUndefined(input.session)
+    const selected = requested
+      ? undefined
+      : yield* Effect.promise(async () => {
+          const location = await client.location.get({ location: { directory: process.cwd() } })
+          const page = await client.session.list({
+            directory: location.directory,
+            workspace: location.workspaceID,
+            parentID: null,
+            order: "desc",
+            limit: 50,
+          })
+          if (page.data.length === 0) {
+            process.stderr.write(`No sessions found${EOL}`)
+            return undefined
+          }
+          return selectSession(page.data, input.sanitize)
+        })
+    const sessionID = requested ?? selected?.session.id
+    if (!sessionID) return
+    const data = yield* Effect.promise(() =>
+      client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }),
+    )
+    process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined)))
+  }),
+)
+
+type Selection = { session: SessionInfo; sanitize: boolean }
+
+function selectSession(sessions: SessionInfo[], initialSanitize: boolean) {
+  if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive"))
+  const input = process.stdin
+  const output = process.stderr
+  const wasRaw = input.isRaw
+  const wasPaused = input.isPaused()
+  const date = new Intl.DateTimeFormat(undefined, {
+    month: "short",
+    day: "numeric",
+    hour: "numeric",
+    minute: "2-digit",
+  })
+  const columns = output.columns ?? 100
+  const titleWidth = Math.max(8, Math.min(48, columns - 34))
+  let selected = 0
+  let offset = 0
+  let sanitize = initialSanitize
+  let height = 0
+
+  const render = () => {
+    const visible = sessions.slice(offset, offset + 10)
+    const lines = ["  \x1b[36mExport session\x1b[0m", ""]
+    lines.push(
+      ...visible.map((session) => {
+        const index = sessions.indexOf(session)
+        const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth)
+        const updated = date.format(session.time.updated).slice(0, 18).padEnd(18)
+        const row = `${index === selected ? ">" : " "} ${title}  ${updated}  ${session.id.slice(-8)}`
+        return index === selected ? `\x1b[1m${row}\x1b[0m` : row
+      }),
+      "",
+      `  [${sanitize ? "x" : " "}] sanitize sensitive data`,
+      "",
+      "  navigate \x1b[2mup/down\x1b[0m   sanitize \x1b[2mspace\x1b[0m   export \x1b[2menter\x1b[0m   cancel \x1b[2mesc\x1b[0m",
+    )
+    if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
+    output.write(lines.join(EOL) + EOL)
+    height = lines.length
+  }
+  const clear = () => {
+    if (height > 0) output.write(`\x1b[${height}F\x1b[J`)
+    output.write("\x1b[?25h")
+    input.removeListener("keypress", onKeypress)
+    input.setRawMode(wasRaw ?? false)
+    if (wasPaused) input.pause()
+  }
+  const onKeypress = (value: string | undefined, key: Key) => {
+    if (key.name === "up") {
+      selected = (selected - 1 + sessions.length) % sessions.length
+      if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10)
+      if (selected < offset) offset = selected
+    }
+    if (key.name === "down") {
+      selected = (selected + 1) % sessions.length
+      if (selected === 0) offset = 0
+      if (selected >= offset + 10) offset = selected - 9
+    }
+    if (key.name === "space" || value === " ") sanitize = !sanitize
+    if (key.name === "return") return finish(sessions[selected])
+    if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel()
+    render()
+  }
+  const finish = (session: SessionInfo) => {
+    clear()
+    resolveSelection?.({ session, sanitize })
+  }
+  const cancel = () => {
+    clear()
+    resolveSelection?.()
+  }
+  let resolveSelection: ((selection?: Selection) => void) | undefined
+
+  emitKeypressEvents(input)
+  input.setRawMode(true)
+  input.resume()
+  input.on("keypress", onKeypress)
+  output.write("\x1b[?25l")
+  render()
+  return new Promise<Selection | undefined>((resolve) => {
+    resolveSelection = resolve
+  })
+}
+
+export async function writeExport(data: unknown, sessionID: string, stdout: boolean) {
+  const json = JSON.stringify(data, null, 2) + EOL
+  if (stdout) return json
+  const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`)
+  await Bun.write(file, json)
+  return file + EOL
+}

+ 60 - 0
packages/cli/src/commands/handlers/import.ts

@@ -0,0 +1,60 @@
+import { OpenCode } from "@opencode-ai/client"
+import { Service } from "@opencode-ai/client/effect/service"
+import { Session } from "@opencode-ai/schema/session"
+import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
+import { Effect, Option, Schema } from "effect"
+import { EOL } from "node:os"
+import path from "node:path"
+import { Commands } from "../commands"
+import { Runtime } from "../../framework/runtime"
+import { ServerConnection } from "../../services/server-connection"
+
+export default Runtime.handler(
+  Commands.commands.import,
+  Effect.fn("cli.import")(function* (input) {
+    const text = yield* Effect.tryPromise({
+      try: () =>
+        input.file.startsWith("http://") || input.file.startsWith("https://")
+          ? fetch(input.file).then((response) => {
+              if (!response.ok) throw new Error(`Failed to fetch session data: ${response.statusText}`)
+              return response.text()
+            })
+          : Bun.file(input.file).text(),
+      catch: (cause) => new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
+    })
+    const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
+    const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
+    const server = yield* ServerConnection.resolve({
+      server: Option.getOrUndefined(input.server),
+      standalone: input.standalone,
+    })
+    const client = OpenCode.make({
+      baseUrl: server.endpoint.url,
+      headers: Service.headers(server.endpoint),
+    })
+    const location = yield* Effect.promise(() =>
+      client.location.get({
+        location: { directory: path.resolve(Option.getOrElse(input.directory, () => process.cwd())) },
+      }),
+    )
+    const response = yield* Effect.promise(() =>
+      fetch(new URL("/api/session/import", server.endpoint.url), {
+        method: "POST",
+        headers: { ...Service.headers(server.endpoint), "content-type": "application/json" },
+        body: JSON.stringify({
+          ...encoded,
+          location: { directory: location.directory, workspaceID: location.workspaceID },
+        }),
+      }),
+    )
+    if (response.status === 409) {
+      process.stderr.write(`Session already exists${EOL}`)
+      return
+    }
+    if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`))
+    const imported = yield* Schema.decodeUnknownEffect(
+      Schema.fromJsonString(Schema.Struct({ data: Session.Info })),
+    )(yield* Effect.promise(() => response.text()))
+    process.stdout.write(`Imported session: ${imported.data.id}${EOL}`)
+  }),
+)

+ 2 - 0
packages/cli/src/index.ts

@@ -37,6 +37,8 @@ const Handlers = Runtime.handlers(Commands, {
     list: () => import("./commands/handlers/plugin/list"),
   },
   models: () => import("./commands/handlers/models"),
+  export: () => import("./commands/handlers/export"),
+  import: () => import("./commands/handlers/import"),
   mini: () => import("./commands/handlers/mini"),
   run: () => import("./commands/handlers/run"),
   pair: () => import("./commands/handlers/pair"),

+ 210 - 0
packages/cli/test/import-export.test.ts

@@ -0,0 +1,210 @@
+import { expect, test } from "bun:test"
+import fs from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+import { OPENCODE_VERSION } from "../src/version"
+import { writeExport } from "../src/commands/handlers/export"
+
+const info = {
+  id: "ses_export_test",
+  projectID: "global",
+  cost: 0,
+  tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+  time: { created: 1, updated: 2 },
+  title: "Exported session",
+  location: { directory: "/project" },
+}
+const transfer = {
+  info,
+  messages: [
+    { id: "msg_first", type: "user", text: "First", time: { created: 1 } },
+    { id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
+  ],
+}
+const sanitizedTransfer = {
+  info: {
+    ...info,
+    title: "[redacted:session-title:ses_export_test]",
+    location: { directory: "/[redacted:session-directory:ses_export_test]" },
+  },
+  messages: [
+    {
+      id: "msg_first",
+      type: "user",
+      text: "[redacted:text:msg_first]",
+      time: { created: 1 },
+    },
+    {
+      id: "msg_second",
+      type: "user",
+      text: "[redacted:text:msg_second]",
+      time: { created: 2 },
+    },
+  ],
+}
+
+const health = () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
+
+function run(args: string[], stdin?: string) {
+  const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
+    cwd: path.join(import.meta.dir, ".."),
+    stdin: stdin === undefined ? undefined : new Blob([stdin]),
+    stdout: "pipe",
+    stderr: "pipe",
+  })
+  return Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
+}
+
+test("export is raw by default and supports explicit sanitization", async () => {
+  const sanitization: string[] = []
+  const server = Bun.serve({
+    port: 0,
+    fetch(request) {
+      const url = new URL(request.url)
+      if (url.pathname === "/api/health") return health()
+      if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
+      if (url.pathname === `/api/session/${info.id}/export`) {
+        sanitization.push(url.searchParams.get("sanitize") ?? "")
+        return Response.json({ data: url.searchParams.get("sanitize") === "true" ? sanitizedTransfer : transfer })
+      }
+      return new Response("Not found", { status: 404 })
+    },
+  })
+
+  try {
+    const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()])
+    const exported = JSON.parse(stdout)
+
+    expect(exitCode).toBe(0)
+    expect(exported).toEqual(transfer)
+
+    const [sanitized, , sanitizedExitCode] = await run([
+      "export",
+      "-s",
+      info.id,
+      "--sanitize",
+      "--server",
+      server.url.toString(),
+    ])
+    expect(sanitizedExitCode).toBe(0)
+    expect(JSON.parse(sanitized)).toEqual(sanitizedTransfer)
+    expect(sanitization).toEqual(["false", "true"])
+  } finally {
+    await server.stop(true)
+  }
+}, 15_000)
+
+test("export reports an empty session list without a stack trace", async () => {
+  const server = Bun.serve({
+    port: 0,
+    fetch(request) {
+      const url = new URL(request.url)
+      if (url.pathname === "/api/health") return health()
+      if (url.pathname === "/api/location") {
+        return Response.json({
+          directory: "/project",
+          project: { id: "global", directory: "/project", canonical: "/project" },
+        })
+      }
+      if (url.pathname === "/api/session") return Response.json({ data: [], cursor: {} })
+      return new Response("Not found", { status: 404 })
+    },
+  })
+
+  try {
+    const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()])
+
+    expect(exitCode).toBe(0)
+    expect(stdout).toBe("")
+    expect(stderr).toBe(`No sessions found${os.EOL}`)
+  } finally {
+    await server.stop(true)
+  }
+})
+
+test("interactive export writes a temporary JSON file", async () => {
+  const output = await writeExport(transfer, info.id, false)
+  const file = output.trim()
+
+  try {
+    expect(path.dirname(file)).toBe(os.tmpdir())
+    expect(await Bun.file(file).json()).toEqual(transfer)
+  } finally {
+    await fs.rm(file, { force: true })
+  }
+})
+
+test("import validates a file and sends it to the resolved location", async () => {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-"))
+  const file = path.join(root, "session.json")
+  await fs.writeFile(file, JSON.stringify(transfer))
+  let imported: unknown
+  const server = Bun.serve({
+    port: 0,
+    async fetch(request) {
+      const url = new URL(request.url)
+      if (url.pathname === "/api/health") return health()
+      if (url.pathname === "/api/location") {
+        return Response.json({
+          directory: root,
+          project: { id: "global", directory: root, canonical: root },
+        })
+      }
+      if (url.pathname === "/api/session/import") {
+        imported = await request.json()
+        return Response.json({ data: { ...info, location: { directory: root } } })
+      }
+      return new Response("Not found", { status: 404 })
+    },
+  })
+
+  try {
+    const [stdout, , exitCode] = await run([
+      "import",
+      file,
+      "--directory",
+      root,
+      "--server",
+      server.url.toString(),
+    ])
+
+    expect(exitCode).toBe(0)
+    expect(stdout).toBe(`Imported session: ${info.id}${os.EOL}`)
+    expect(imported).toEqual({ ...transfer, location: { directory: root } })
+  } finally {
+    await server.stop(true)
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})
+
+test("import reports an existing session without a stack trace", async () => {
+  const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-import-conflict-"))
+  const file = path.join(root, "session.json")
+  await fs.writeFile(file, JSON.stringify(transfer))
+  const server = Bun.serve({
+    port: 0,
+    fetch(request) {
+      const url = new URL(request.url)
+      if (url.pathname === "/api/health") return health()
+      if (url.pathname === "/api/location") {
+        return Response.json({
+          directory: root,
+          project: { id: "global", directory: root, canonical: root },
+        })
+      }
+      if (url.pathname === "/api/session/import") return new Response("Conflict", { status: 409 })
+      return new Response("Not found", { status: 404 })
+    },
+  })
+
+  try {
+    const [stdout, stderr, exitCode] = await run(["import", file, "--server", server.url.toString()])
+
+    expect(exitCode).toBe(0)
+    expect(stdout).toBe("")
+    expect(stderr).toBe(`Session already exists${os.EOL}`)
+  } finally {
+    await server.stop(true)
+    await fs.rm(root, { recursive: true, force: true })
+  }
+})

+ 96 - 82
packages/client/src/effect/api/api.ts

@@ -127,42 +127,54 @@ export type Endpoint5_1Input = {
 export type Endpoint5_1Output = Session.Info
 export type SessionCreateOperation<E = never> = (input?: Endpoint5_1Input) => Effect.Effect<Endpoint5_1Output, E>
 
-export type Endpoint5_2Output = { readonly [x: Session.ID]: { readonly type: "running" } }
-export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_2Output, E>
+export type Endpoint5_2Input = {
+  readonly info: Session.Info
+  readonly messages: ReadonlyArray<SessionMessage.Info>
+  readonly location?: Location.Ref | undefined
+}
+export type Endpoint5_2Output = Session.Info
+export type SessionImportOperation<E = never> = (input: Endpoint5_2Input) => Effect.Effect<Endpoint5_2Output, E>
 
-export type Endpoint5_3Input = { readonly sessionID: Session.ID }
-export type Endpoint5_3Output = Session.Info
-export type SessionGetOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
+export type Endpoint5_3Input = { readonly sessionID: Session.ID; readonly sanitize?: boolean | undefined }
+export type Endpoint5_3Output = { readonly info: Session.Info; readonly messages: ReadonlyArray<SessionMessage.Info> }
+export type SessionExportOperation<E = never> = (input: Endpoint5_3Input) => Effect.Effect<Endpoint5_3Output, E>
 
-export type Endpoint5_4Input = { readonly sessionID: Session.ID }
-export type Endpoint5_4Output = void
-export type SessionRemoveOperation<E = never> = (input: Endpoint5_4Input) => Effect.Effect<Endpoint5_4Output, E>
+export type Endpoint5_4Output = { readonly [x: Session.ID]: { readonly type: "running" } }
+export type SessionActiveOperation<E = never> = () => Effect.Effect<Endpoint5_4Output, E>
 
-export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
+export type Endpoint5_5Input = { readonly sessionID: Session.ID }
 export type Endpoint5_5Output = Session.Info
-export type SessionForkOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
+export type SessionGetOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
 
-export type Endpoint5_6Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
+export type Endpoint5_6Input = { readonly sessionID: Session.ID }
 export type Endpoint5_6Output = void
-export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
+export type SessionRemoveOperation<E = never> = (input: Endpoint5_6Input) => Effect.Effect<Endpoint5_6Output, E>
 
-export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
-export type Endpoint5_7Output = void
-export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
+export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
+export type Endpoint5_7Output = Session.Info
+export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
 
-export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly title: string }
+export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
 export type Endpoint5_8Output = void
-export type SessionRenameOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
+export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
+
+export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
+export type Endpoint5_9Output = void
+export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
 
-export type Endpoint5_9Input = {
+export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
+export type Endpoint5_10Output = void
+export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
+
+export type Endpoint5_11Input = {
   readonly sessionID: Session.ID
   readonly directory: AbsolutePath
   readonly workspaceID?: Workspace.ID | undefined
 }
-export type Endpoint5_9Output = void
-export type SessionMoveOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
+export type Endpoint5_11Output = void
+export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
 
-export type Endpoint5_10Input = {
+export type Endpoint5_12Input = {
   readonly sessionID: Session.ID
   readonly id?: SessionMessage.ID | undefined
   readonly text: string
@@ -172,10 +184,10 @@ export type Endpoint5_10Input = {
   readonly delivery?: "steer" | "queue" | undefined
   readonly resume?: boolean | undefined
 }
-export type Endpoint5_10Output = SessionPending.User
-export type SessionPromptOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
+export type Endpoint5_12Output = SessionPending.User
+export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
 
-export type Endpoint5_11Input = {
+export type Endpoint5_13Input = {
   readonly sessionID: Session.ID
   readonly id?: SessionMessage.ID | undefined
   readonly command: string
@@ -187,19 +199,19 @@ export type Endpoint5_11Input = {
   readonly delivery?: "steer" | "queue" | undefined
   readonly resume?: boolean | undefined
 }
-export type Endpoint5_11Output = SessionPending.User
-export type SessionCommandOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
+export type Endpoint5_13Output = SessionPending.User
+export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
 
-export type Endpoint5_12Input = {
+export type Endpoint5_14Input = {
   readonly sessionID: Session.ID
   readonly id?: SessionMessage.ID | undefined
   readonly skill: Skill.ID
   readonly resume?: boolean | undefined
 }
-export type Endpoint5_12Output = void
-export type SessionSkillOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
+export type Endpoint5_14Output = void
+export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
 
-export type Endpoint5_13Input = {
+export type Endpoint5_15Input = {
   readonly sessionID: Session.ID
   readonly id?: SessionMessage.ID | undefined
   readonly text: string
@@ -208,81 +220,81 @@ export type Endpoint5_13Input = {
   readonly delivery?: "steer" | "queue" | undefined
   readonly resume?: boolean | undefined
 }
-export type Endpoint5_13Output = SessionPending.Synthetic
-export type SessionSyntheticOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
+export type Endpoint5_15Output = SessionPending.Synthetic
+export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
 
-export type Endpoint5_14Input = {
+export type Endpoint5_16Input = {
   readonly sessionID: Session.ID
   readonly id?: Event.ID | undefined
   readonly command: string
 }
-export type Endpoint5_14Output = void
-export type SessionShellOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
+export type Endpoint5_16Output = void
+export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
 
-export type Endpoint5_15Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
-export type Endpoint5_15Output = SessionPending.Compaction
-export type SessionCompactOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
+export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
+export type Endpoint5_17Output = SessionPending.Compaction
+export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
 
-export type Endpoint5_16Input = { readonly sessionID: Session.ID }
-export type Endpoint5_16Output = void
-export type SessionWaitOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
+export type Endpoint5_18Input = { readonly sessionID: Session.ID }
+export type Endpoint5_18Output = void
+export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
 
-export type Endpoint5_17Input = {
+export type Endpoint5_19Input = {
   readonly sessionID: Session.ID
   readonly messageID: SessionMessage.ID
   readonly files?: boolean | undefined
 }
-export type Endpoint5_17Output = Session.Revert
-export type SessionRevertStageOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
-
-export type Endpoint5_18Input = { readonly sessionID: Session.ID }
-export type Endpoint5_18Output = void
-export type SessionRevertClearOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
-
-export type Endpoint5_19Input = { readonly sessionID: Session.ID }
-export type Endpoint5_19Output = void
-export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
+export type Endpoint5_19Output = Session.Revert
+export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
 
 export type Endpoint5_20Input = { readonly sessionID: Session.ID }
-export type Endpoint5_20Output = ReadonlyArray<SessionMessage.Info>
-export type SessionContextOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
+export type Endpoint5_20Output = void
+export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
 
 export type Endpoint5_21Input = { readonly sessionID: Session.ID }
-export type Endpoint5_21Output = ReadonlyArray<SessionPending.Info>
-export type SessionPendingListOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
+export type Endpoint5_21Output = void
+export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
 
 export type Endpoint5_22Input = { readonly sessionID: Session.ID }
-export type Endpoint5_22Output = ReadonlyArray<InstructionEntry.Info>
+export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
+export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
+
+export type Endpoint5_23Input = { readonly sessionID: Session.ID }
+export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
+export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
+
+export type Endpoint5_24Input = { readonly sessionID: Session.ID }
+export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
 export type SessionInstructionsEntryListOperation<E = never> = (
-  input: Endpoint5_22Input,
-) => Effect.Effect<Endpoint5_22Output, E>
+  input: Endpoint5_24Input,
+) => Effect.Effect<Endpoint5_24Output, E>
 
-export type Endpoint5_23Input = {
+export type Endpoint5_25Input = {
   readonly sessionID: Session.ID
   readonly key: InstructionEntry.Key
   readonly value: Schema.Json
 }
-export type Endpoint5_23Output = void
+export type Endpoint5_25Output = void
 export type SessionInstructionsEntryPutOperation<E = never> = (
-  input: Endpoint5_23Input,
-) => Effect.Effect<Endpoint5_23Output, E>
+  input: Endpoint5_25Input,
+) => Effect.Effect<Endpoint5_25Output, E>
 
-export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
-export type Endpoint5_24Output = void
+export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
+export type Endpoint5_26Output = void
 export type SessionInstructionsEntryRemoveOperation<E = never> = (
-  input: Endpoint5_24Input,
-) => Effect.Effect<Endpoint5_24Output, E>
+  input: Endpoint5_26Input,
+) => Effect.Effect<Endpoint5_26Output, E>
 
-export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly prompt: string }
-export type Endpoint5_25Output = { readonly text: string }
-export type SessionGenerateOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
+export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
+export type Endpoint5_27Output = { readonly text: string }
+export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
 
-export type Endpoint5_26Input = {
+export type Endpoint5_28Input = {
   readonly sessionID: Session.ID
   readonly after?: Event.Seq | undefined
   readonly follow?: boolean | undefined
 }
-export type Endpoint5_26Output =
+export type Endpoint5_28Output =
   | (
       | {
           readonly id: Event.ID
@@ -850,23 +862,25 @@ export type Endpoint5_26Output =
         }
     )
   | EventLog.Synced
-export type SessionLogOperation<E = never> = (input: Endpoint5_26Input) => Stream.Stream<Endpoint5_26Output, E>
+export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
 
-export type Endpoint5_27Input = { readonly sessionID: Session.ID }
-export type Endpoint5_27Output = void
-export type SessionInterruptOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
+export type Endpoint5_29Input = { readonly sessionID: Session.ID }
+export type Endpoint5_29Output = void
+export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
 
-export type Endpoint5_28Input = { readonly sessionID: Session.ID }
-export type Endpoint5_28Output = void
-export type SessionBackgroundOperation<E = never> = (input: Endpoint5_28Input) => Effect.Effect<Endpoint5_28Output, E>
+export type Endpoint5_30Input = { readonly sessionID: Session.ID }
+export type Endpoint5_30Output = void
+export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
 
-export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
-export type Endpoint5_29Output = SessionMessage.Info
-export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
+export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
+export type Endpoint5_31Output = SessionMessage.Info
+export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
 
 export interface SessionApi<E = never> {
   readonly list: SessionListOperation<E>
   readonly create: SessionCreateOperation<E>
+  readonly import: SessionImportOperation<E>
+  readonly export: SessionExportOperation<E>
   readonly active: SessionActiveOperation<E>
   readonly get: SessionGetOperation<E>
   readonly remove: SessionRemoveOperation<E>

+ 100 - 76
packages/client/src/effect/generated/client.ts

@@ -21,10 +21,10 @@ import type {
   Endpoint5_0Output,
   Endpoint5_1Input,
   Endpoint5_1Output,
+  Endpoint5_2Input,
   Endpoint5_2Output,
   Endpoint5_3Input,
   Endpoint5_3Output,
-  Endpoint5_4Input,
   Endpoint5_4Output,
   Endpoint5_5Input,
   Endpoint5_5Output,
@@ -76,6 +76,10 @@ import type {
   Endpoint5_28Output,
   Endpoint5_29Input,
   Endpoint5_29Output,
+  Endpoint5_30Input,
+  Endpoint5_30Output,
+  Endpoint5_31Input,
+  Endpoint5_31Output,
   Endpoint6_0Input,
   Endpoint6_0Output,
   Endpoint7_0Input,
@@ -317,9 +321,11 @@ const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1In
     ),
   )
 
-const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
+const Endpoint5_2 = (raw: RawClient["server.session"]) => (input: Endpoint5_2Input) =>
   preserveEffect<Endpoint5_2Output>()(
-    raw["session.active"]({}).pipe(
+    raw["session.import"]({
+      payload: { info: input["info"], messages: input["messages"], location: input["location"] },
+    }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
     ),
@@ -327,20 +333,23 @@ const Endpoint5_2 = (raw: RawClient["server.session"]) => () =>
 
 const Endpoint5_3 = (raw: RawClient["server.session"]) => (input: Endpoint5_3Input) =>
   preserveEffect<Endpoint5_3Output>()(
-    raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
+    raw["session.export"]({ params: { sessionID: input["sessionID"] }, query: { sanitize: input["sanitize"] } }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
     ),
   )
 
-const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Input) =>
+const Endpoint5_4 = (raw: RawClient["server.session"]) => () =>
   preserveEffect<Endpoint5_4Output>()(
-    raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
+    raw["session.active"]({}).pipe(
+      Effect.mapError(mapClientError),
+      Effect.map((value) => value.data),
+    ),
   )
 
 const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
   preserveEffect<Endpoint5_5Output>()(
-    raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
+    raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
     ),
@@ -348,35 +357,48 @@ const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Inp
 
 const Endpoint5_6 = (raw: RawClient["server.session"]) => (input: Endpoint5_6Input) =>
   preserveEffect<Endpoint5_6Output>()(
-    raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
-      Effect.mapError(mapClientError),
-    ),
+    raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
   )
 
 const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Input) =>
   preserveEffect<Endpoint5_7Output>()(
-    raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
+    raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
       Effect.mapError(mapClientError),
+      Effect.map((value) => value.data),
     ),
   )
 
 const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
   preserveEffect<Endpoint5_8Output>()(
-    raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
+    raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
       Effect.mapError(mapClientError),
     ),
   )
 
 const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
   preserveEffect<Endpoint5_9Output>()(
+    raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
+      Effect.mapError(mapClientError),
+    ),
+  )
+
+const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
+  preserveEffect<Endpoint5_10Output>()(
+    raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
+      Effect.mapError(mapClientError),
+    ),
+  )
+
+const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
+  preserveEffect<Endpoint5_11Output>()(
     raw["session.move"]({
       params: { sessionID: input["sessionID"] },
       payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
     }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
-  preserveEffect<Endpoint5_10Output>()(
+const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
+  preserveEffect<Endpoint5_12Output>()(
     raw["session.prompt"]({
       params: { sessionID: input["sessionID"] },
       payload: {
@@ -394,8 +416,8 @@ const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10I
     ),
   )
 
-const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
-  preserveEffect<Endpoint5_11Output>()(
+const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
+  preserveEffect<Endpoint5_13Output>()(
     raw["session.command"]({
       params: { sessionID: input["sessionID"] },
       payload: {
@@ -415,16 +437,16 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I
     ),
   )
 
-const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
-  preserveEffect<Endpoint5_12Output>()(
+const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
+  preserveEffect<Endpoint5_14Output>()(
     raw["session.skill"]({
       params: { sessionID: input["sessionID"] },
       payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
     }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
-  preserveEffect<Endpoint5_13Output>()(
+const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
+  preserveEffect<Endpoint5_15Output>()(
     raw["session.synthetic"]({
       params: { sessionID: input["sessionID"] },
       payload: {
@@ -441,29 +463,29 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
     ),
   )
 
-const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
-  preserveEffect<Endpoint5_14Output>()(
+const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
+  preserveEffect<Endpoint5_16Output>()(
     raw["session.shell"]({
       params: { sessionID: input["sessionID"] },
       payload: { id: input["id"], command: input["command"] },
     }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
-  preserveEffect<Endpoint5_15Output>()(
+const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
+  preserveEffect<Endpoint5_17Output>()(
     raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
     ),
   )
 
-const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
-  preserveEffect<Endpoint5_16Output>()(
+const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
+  preserveEffect<Endpoint5_18Output>()(
     raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
-  preserveEffect<Endpoint5_17Output>()(
+const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
+  preserveEffect<Endpoint5_19Output>()(
     raw["session.revert.stage"]({
       params: { sessionID: input["sessionID"] },
       payload: { messageID: input["messageID"], files: input["files"] },
@@ -473,65 +495,65 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
     ),
   )
 
-const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
-  preserveEffect<Endpoint5_18Output>()(
+const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
+  preserveEffect<Endpoint5_20Output>()(
     raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
-  preserveEffect<Endpoint5_19Output>()(
+const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
+  preserveEffect<Endpoint5_21Output>()(
     raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
-  preserveEffect<Endpoint5_20Output>()(
+const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
+  preserveEffect<Endpoint5_22Output>()(
     raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
     ),
   )
 
-const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
-  preserveEffect<Endpoint5_21Output>()(
+const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
+  preserveEffect<Endpoint5_23Output>()(
     raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
     ),
   )
 
-const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
-  preserveEffect<Endpoint5_22Output>()(
+const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
+  preserveEffect<Endpoint5_24Output>()(
     raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
     ),
   )
 
-const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
-  preserveEffect<Endpoint5_23Output>()(
+const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
+  preserveEffect<Endpoint5_25Output>()(
     raw["session.instructions.entry.put"]({
       params: { sessionID: input["sessionID"], key: input["key"] },
       payload: { value: input["value"] },
     }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
-  preserveEffect<Endpoint5_24Output>()(
+const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
+  preserveEffect<Endpoint5_26Output>()(
     raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
       Effect.mapError(mapClientError),
     ),
   )
 
-const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
-  preserveEffect<Endpoint5_25Output>()(
+const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
+  preserveEffect<Endpoint5_27Output>()(
     raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
     ),
   )
 
-const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
-  preserveStream<Endpoint5_26Output>()(
+const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
+  preserveStream<Endpoint5_28Output>()(
     Stream.unwrap(
       raw["session.log"]({
         params: { sessionID: input["sessionID"] },
@@ -543,18 +565,18 @@ const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26I
     ),
   )
 
-const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
-  preserveEffect<Endpoint5_27Output>()(
+const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
+  preserveEffect<Endpoint5_29Output>()(
     raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
-  preserveEffect<Endpoint5_28Output>()(
+const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
+  preserveEffect<Endpoint5_30Output>()(
     raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
   )
 
-const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
-  preserveEffect<Endpoint5_29Output>()(
+const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
+  preserveEffect<Endpoint5_31Output>()(
     raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
       Effect.mapError(mapClientError),
       Effect.map((value) => value.data),
@@ -564,30 +586,32 @@ const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29I
 const adaptGroup5 = (raw: RawClient["server.session"]) => ({
   list: Endpoint5_0(raw),
   create: Endpoint5_1(raw),
-  active: Endpoint5_2(raw),
-  get: Endpoint5_3(raw),
-  remove: Endpoint5_4(raw),
-  fork: Endpoint5_5(raw),
-  switchAgent: Endpoint5_6(raw),
-  switchModel: Endpoint5_7(raw),
-  rename: Endpoint5_8(raw),
-  move: Endpoint5_9(raw),
-  prompt: Endpoint5_10(raw),
-  command: Endpoint5_11(raw),
-  skill: Endpoint5_12(raw),
-  synthetic: Endpoint5_13(raw),
-  shell: Endpoint5_14(raw),
-  compact: Endpoint5_15(raw),
-  wait: Endpoint5_16(raw),
-  revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
-  context: Endpoint5_20(raw),
-  pending: { list: Endpoint5_21(raw) },
-  instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
-  generate: Endpoint5_25(raw),
-  log: Endpoint5_26(raw),
-  interrupt: Endpoint5_27(raw),
-  background: Endpoint5_28(raw),
-  message: Endpoint5_29(raw),
+  import: Endpoint5_2(raw),
+  export: Endpoint5_3(raw),
+  active: Endpoint5_4(raw),
+  get: Endpoint5_5(raw),
+  remove: Endpoint5_6(raw),
+  fork: Endpoint5_7(raw),
+  switchAgent: Endpoint5_8(raw),
+  switchModel: Endpoint5_9(raw),
+  rename: Endpoint5_10(raw),
+  move: Endpoint5_11(raw),
+  prompt: Endpoint5_12(raw),
+  command: Endpoint5_13(raw),
+  skill: Endpoint5_14(raw),
+  synthetic: Endpoint5_15(raw),
+  shell: Endpoint5_16(raw),
+  compact: Endpoint5_17(raw),
+  wait: Endpoint5_18(raw),
+  revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
+  context: Endpoint5_22(raw),
+  pending: { list: Endpoint5_23(raw) },
+  instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
+  generate: Endpoint5_27(raw),
+  log: Endpoint5_28(raw),
+  interrupt: Endpoint5_29(raw),
+  background: Endpoint5_30(raw),
+  message: Endpoint5_31(raw),
 })
 
 const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>

+ 28 - 0
packages/client/src/promise/generated/client.ts

@@ -15,6 +15,10 @@ import type {
   SessionListOutput,
   SessionCreateInput,
   SessionCreateOutput,
+  SessionImportInput,
+  SessionImportOutput,
+  SessionExportInput,
+  SessionExportOutput,
   SessionActiveOutput,
   SessionGetInput,
   SessionGetOutput,
@@ -478,6 +482,30 @@ export function make(options: ClientOptions) {
           },
           requestOptions,
         ).then((value) => value.data),
+      import: (input: SessionImportInput, requestOptions?: RequestOptions) =>
+        request<{ readonly data: SessionImportOutput }>(
+          {
+            method: "POST",
+            path: `/api/session/import`,
+            body: { info: input["info"], messages: input["messages"], location: input["location"] },
+            successStatus: 200,
+            declaredStatuses: [409, 401, 400],
+            empty: false,
+          },
+          requestOptions,
+        ).then((value) => value.data),
+      export: (input: SessionExportInput, requestOptions?: RequestOptions) =>
+        request<{ readonly data: SessionExportOutput }>(
+          {
+            method: "GET",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/export`,
+            query: { sanitize: input["sanitize"] },
+            successStatus: 200,
+            declaredStatuses: [404, 500, 401, 400],
+            empty: false,
+          },
+          requestOptions,
+        ).then((value) => value.data),
       active: (requestOptions?: RequestOptions) =>
         request<{ readonly data: SessionActiveOutput }>(
           {

+ 793 - 44
packages/client/src/promise/generated/types.ts

@@ -35,18 +35,6 @@ export type FileDiffInfo = {
   status: "added" | "deleted" | "modified"
 }
 
-export type SessionActive = { type: "running" }
-
-export type PromptBase64 = string
-
-export type PromptFileSource = { type: "inline" } | { type: "uri"; uri: string }
-
-export type PromptMention = { start: number; end: number; text: string }
-
-export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
-
-export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" }
-
 export type SessionMessageAgentSelected = {
   id: string
   metadata?: { [x: string]: JsonValue }
@@ -55,6 +43,12 @@ export type SessionMessageAgentSelected = {
   agent: string
 }
 
+export type PromptBase64 = string
+
+export type PromptFileSource = { type: "inline" } | { type: "uri"; uri: string }
+
+export type PromptMention = { start: number; end: number; text: string }
+
 export type SessionMessageSynthetic = {
   id: string
   metadata?: { [x: string]: JsonValue }
@@ -132,6 +126,12 @@ export type SessionMessageCompactionCompleted = {
   recent: string
 }
 
+export type SessionActive = { type: "running" }
+
+export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
+
+export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" }
+
 export type InstructionEntryKey = string
 
 export type SessionGenerateResponse = { data: { text: string } }
@@ -1048,15 +1048,6 @@ export type PromptFileAttachment = {
 
 export type PromptAgentAttachment = { name: string; mention?: PromptMention }
 
-export type SessionPendingSynthetic = {
-  id: string
-  sessionID: string
-  timeCreated: number
-  type: "synthetic"
-  data: SessionPendingSyntheticData
-  delivery: "steer" | "queue"
-}
-
 export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
 
 export type SessionMessageAssistantReasoning = {
@@ -1128,6 +1119,15 @@ export type SessionCompactionFailed = {
   data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string }
 }
 
+export type SessionPendingSynthetic = {
+  id: string
+  sessionID: string
+  timeCreated: number
+  type: "synthetic"
+  data: SessionPendingSyntheticData
+  delivery: "steer" | "queue"
+}
+
 export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue }
 
 export type SessionPendingSyntheticMessage = {
@@ -1522,13 +1522,6 @@ export type SessionRevertStaged = {
   data: { sessionID: string; revert: SessionRevert }
 }
 
-export type SessionPendingUserData = {
-  text: string
-  files?: Array<PromptFileAttachment>
-  agents?: Array<PromptAgentAttachment>
-  metadata?: { [x: string]: JsonValue }
-}
-
 export type SessionMessageUser = {
   id: string
   metadata?: { [x: string]: JsonValue }
@@ -1539,6 +1532,13 @@ export type SessionMessageUser = {
   type: "user"
 }
 
+export type SessionPendingUserData = {
+  text: string
+  files?: Array<PromptFileAttachment>
+  agents?: Array<PromptAgentAttachment>
+  metadata?: { [x: string]: JsonValue }
+}
+
 export type SessionPendingUserData1 = {
   text: string
   files?: Array<PromptFileAttachment>
@@ -1995,6 +1995,8 @@ export type SessionEventDurable =
   | SessionRevertCommitted
   | SessionUsageRecorded
 
+export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
+
 export type SessionMessagesResponse = {
   data: Array<SessionMessageInfo>
   cursor: { previous?: string | null; next?: string | null }
@@ -2114,6 +2116,14 @@ export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly
 export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
   typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
 
+export type ConflictError = {
+  readonly _tag: "ConflictError"
+  readonly message: string
+  readonly resource?: string | undefined
+}
+export const isConflictError = (value: unknown): value is ConflictError =>
+  typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError"
+
 export type SessionNotFoundError = {
   readonly _tag: "SessionNotFoundError"
   readonly sessionID: string
@@ -2122,6 +2132,14 @@ export type SessionNotFoundError = {
 export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError =>
   typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionNotFoundError"
 
+export type UnknownError = {
+  readonly _tag: "UnknownError"
+  readonly message: string
+  readonly ref?: string | undefined
+}
+export const isUnknownError = (value: unknown): value is UnknownError =>
+  typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnknownError"
+
 export type MessageNotFoundError = {
   readonly _tag: "MessageNotFoundError"
   readonly sessionID: string
@@ -2131,14 +2149,6 @@ export type MessageNotFoundError = {
 export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
   typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError"
 
-export type ConflictError = {
-  readonly _tag: "ConflictError"
-  readonly message: string
-  readonly resource?: string | undefined
-}
-export const isConflictError = (value: unknown): value is ConflictError =>
-  typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError"
-
 export type CommandNotFoundError = {
   readonly _tag: "CommandNotFoundError"
   readonly command: string
@@ -2179,14 +2189,6 @@ export type SessionBusyError = {
 export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
   typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
 
-export type UnknownError = {
-  readonly _tag: "UnknownError"
-  readonly message: string
-  readonly ref?: string | undefined
-}
-export const isUnknownError = (value: unknown): value is UnknownError =>
-  typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnknownError"
-
 export type InstructionEntryValueTooLargeError = {
   readonly _tag: "InstructionEntryValueTooLargeError"
   readonly actualBytes: number
@@ -2464,6 +2466,753 @@ export type SessionCreateInput = {
 
 export type SessionCreateOutput = { data: SessionInfo }["data"]
 
+export type SessionImportInput = {
+  readonly info: {
+    readonly info: {
+      readonly id: string
+      readonly parentID?: string
+      readonly fork?: {
+        readonly sessionID: string
+        readonly boundary:
+          | { readonly type: "before"; readonly messageID: string }
+          | { readonly type: "through"; readonly messageID: string }
+      }
+      readonly projectID: string
+      readonly agent?: string
+      readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
+      readonly cost: number
+      readonly tokens: {
+        readonly input: number
+        readonly output: number
+        readonly reasoning: number
+        readonly cache: { readonly read: number; readonly write: number }
+      }
+      readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
+      readonly title?: string
+      readonly location: { readonly directory: string; readonly workspaceID?: string }
+      readonly subpath?: string
+      readonly revert?: {
+        readonly messageID: string
+        readonly partID?: string
+        readonly snapshot?: string
+        readonly files?: ReadonlyArray<{
+          readonly file: string
+          readonly patch: string
+          readonly additions: number
+          readonly deletions: number
+          readonly status: "added" | "deleted" | "modified"
+        }>
+      }
+    }
+    readonly messages: ReadonlyArray<
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly type: "agent-switched"
+          readonly agent: string
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly type: "model-switched"
+          readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+          readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly text: string
+          readonly files?: ReadonlyArray<{
+            readonly data: string
+            readonly mime: string
+            readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
+            readonly name?: string
+            readonly description?: string
+            readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+          }>
+          readonly agents?: ReadonlyArray<{
+            readonly name: string
+            readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+          }>
+          readonly type: "user"
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly text: string
+          readonly description?: string
+          readonly type: "synthetic"
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly type: "system"
+          readonly text: string
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly type: "skill"
+          readonly skill: string
+          readonly name: string
+          readonly text: string
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number; readonly completed?: number }
+          readonly type: "shell"
+          readonly shellID: string
+          readonly command: string
+          readonly status: "running" | "exited" | "timeout" | "killed"
+          readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
+          readonly output?: {
+            readonly output: string
+            readonly cursor: number
+            readonly size: number
+            readonly truncated: boolean
+          }
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number; readonly completed?: number }
+          readonly type: "assistant"
+          readonly agent: string
+          readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+          readonly content: ReadonlyArray<
+            | { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
+            | {
+                readonly type: "reasoning"
+                readonly text: string
+                readonly state?: { readonly [x: string]: JsonValue }
+                readonly time?: { readonly created: number; readonly completed?: number }
+              }
+            | {
+                readonly type: "tool"
+                readonly id: string
+                readonly name: string
+                readonly executed?: boolean
+                readonly providerState?: { readonly [x: string]: JsonValue }
+                readonly providerResultState?: { readonly [x: string]: JsonValue }
+                readonly state:
+                  | { readonly status: "streaming"; readonly input: string }
+                  | {
+                      readonly status: "running"
+                      readonly input: { readonly [x: string]: JsonValue }
+                      readonly metadata: { readonly [x: string]: JsonValue }
+                    }
+                  | {
+                      readonly status: "completed"
+                      readonly input: { readonly [x: string]: JsonValue }
+                      readonly content: readonly [
+                        (
+                          | { readonly type: "text"; readonly text: string }
+                          | {
+                              readonly type: "file"
+                              readonly uri: string
+                              readonly mime: string
+                              readonly name?: string | null
+                            }
+                        ),
+                        ...Array<
+                          | { readonly type: "text"; readonly text: string }
+                          | {
+                              readonly type: "file"
+                              readonly uri: string
+                              readonly mime: string
+                              readonly name?: string | null
+                            }
+                        >,
+                      ]
+                      readonly metadata?: { readonly [x: string]: JsonValue }
+                    }
+                  | {
+                      readonly status: "error"
+                      readonly input: { readonly [x: string]: JsonValue }
+                      readonly error: { readonly type: string; readonly message: string; readonly status?: number }
+                      readonly content?: readonly [
+                        (
+                          | { readonly type: "text"; readonly text: string }
+                          | {
+                              readonly type: "file"
+                              readonly uri: string
+                              readonly mime: string
+                              readonly name?: string | null
+                            }
+                        ),
+                        ...Array<
+                          | { readonly type: "text"; readonly text: string }
+                          | {
+                              readonly type: "file"
+                              readonly uri: string
+                              readonly mime: string
+                              readonly name?: string | null
+                            }
+                        >,
+                      ]
+                      readonly metadata?: { readonly [x: string]: JsonValue }
+                    }
+                readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number }
+              }
+          >
+          readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
+          readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
+          readonly cost?: number
+          readonly tokens?: {
+            readonly input: number
+            readonly output: number
+            readonly reasoning: number
+            readonly cache: { readonly read: number; readonly write: number }
+          }
+          readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
+          readonly retry?: {
+            readonly attempt: number
+            readonly at: number
+            readonly error: { readonly type: string; readonly message: string; readonly status?: number }
+          }
+        }
+      | (
+          | {
+              readonly type: "compaction"
+              readonly id: string
+              readonly metadata?: { readonly [x: string]: JsonValue }
+              readonly time: { readonly created: number }
+              readonly status: "running"
+              readonly reason: "auto" | "manual"
+              readonly summary: string
+              readonly recent: string
+            }
+          | {
+              readonly type: "compaction"
+              readonly id: string
+              readonly metadata?: { readonly [x: string]: JsonValue }
+              readonly time: { readonly created: number }
+              readonly status: "completed"
+              readonly reason: "auto" | "manual"
+              readonly summary: string
+              readonly recent: string
+            }
+          | {
+              readonly type: "compaction"
+              readonly id: string
+              readonly metadata?: { readonly [x: string]: JsonValue }
+              readonly time: { readonly created: number }
+              readonly status: "failed"
+              readonly reason: "auto" | "manual"
+              readonly error: { readonly type: string; readonly message: string; readonly status?: number }
+            }
+        )
+    >
+    readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
+  }["info"]
+  readonly messages: {
+    readonly info: {
+      readonly id: string
+      readonly parentID?: string
+      readonly fork?: {
+        readonly sessionID: string
+        readonly boundary:
+          | { readonly type: "before"; readonly messageID: string }
+          | { readonly type: "through"; readonly messageID: string }
+      }
+      readonly projectID: string
+      readonly agent?: string
+      readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
+      readonly cost: number
+      readonly tokens: {
+        readonly input: number
+        readonly output: number
+        readonly reasoning: number
+        readonly cache: { readonly read: number; readonly write: number }
+      }
+      readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
+      readonly title?: string
+      readonly location: { readonly directory: string; readonly workspaceID?: string }
+      readonly subpath?: string
+      readonly revert?: {
+        readonly messageID: string
+        readonly partID?: string
+        readonly snapshot?: string
+        readonly files?: ReadonlyArray<{
+          readonly file: string
+          readonly patch: string
+          readonly additions: number
+          readonly deletions: number
+          readonly status: "added" | "deleted" | "modified"
+        }>
+      }
+    }
+    readonly messages: ReadonlyArray<
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly type: "agent-switched"
+          readonly agent: string
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly type: "model-switched"
+          readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+          readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly text: string
+          readonly files?: ReadonlyArray<{
+            readonly data: string
+            readonly mime: string
+            readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
+            readonly name?: string
+            readonly description?: string
+            readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+          }>
+          readonly agents?: ReadonlyArray<{
+            readonly name: string
+            readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+          }>
+          readonly type: "user"
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly text: string
+          readonly description?: string
+          readonly type: "synthetic"
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly type: "system"
+          readonly text: string
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly type: "skill"
+          readonly skill: string
+          readonly name: string
+          readonly text: string
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number; readonly completed?: number }
+          readonly type: "shell"
+          readonly shellID: string
+          readonly command: string
+          readonly status: "running" | "exited" | "timeout" | "killed"
+          readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
+          readonly output?: {
+            readonly output: string
+            readonly cursor: number
+            readonly size: number
+            readonly truncated: boolean
+          }
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number; readonly completed?: number }
+          readonly type: "assistant"
+          readonly agent: string
+          readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+          readonly content: ReadonlyArray<
+            | { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
+            | {
+                readonly type: "reasoning"
+                readonly text: string
+                readonly state?: { readonly [x: string]: JsonValue }
+                readonly time?: { readonly created: number; readonly completed?: number }
+              }
+            | {
+                readonly type: "tool"
+                readonly id: string
+                readonly name: string
+                readonly executed?: boolean
+                readonly providerState?: { readonly [x: string]: JsonValue }
+                readonly providerResultState?: { readonly [x: string]: JsonValue }
+                readonly state:
+                  | { readonly status: "streaming"; readonly input: string }
+                  | {
+                      readonly status: "running"
+                      readonly input: { readonly [x: string]: JsonValue }
+                      readonly metadata: { readonly [x: string]: JsonValue }
+                    }
+                  | {
+                      readonly status: "completed"
+                      readonly input: { readonly [x: string]: JsonValue }
+                      readonly content: readonly [
+                        (
+                          | { readonly type: "text"; readonly text: string }
+                          | {
+                              readonly type: "file"
+                              readonly uri: string
+                              readonly mime: string
+                              readonly name?: string | null
+                            }
+                        ),
+                        ...Array<
+                          | { readonly type: "text"; readonly text: string }
+                          | {
+                              readonly type: "file"
+                              readonly uri: string
+                              readonly mime: string
+                              readonly name?: string | null
+                            }
+                        >,
+                      ]
+                      readonly metadata?: { readonly [x: string]: JsonValue }
+                    }
+                  | {
+                      readonly status: "error"
+                      readonly input: { readonly [x: string]: JsonValue }
+                      readonly error: { readonly type: string; readonly message: string; readonly status?: number }
+                      readonly content?: readonly [
+                        (
+                          | { readonly type: "text"; readonly text: string }
+                          | {
+                              readonly type: "file"
+                              readonly uri: string
+                              readonly mime: string
+                              readonly name?: string | null
+                            }
+                        ),
+                        ...Array<
+                          | { readonly type: "text"; readonly text: string }
+                          | {
+                              readonly type: "file"
+                              readonly uri: string
+                              readonly mime: string
+                              readonly name?: string | null
+                            }
+                        >,
+                      ]
+                      readonly metadata?: { readonly [x: string]: JsonValue }
+                    }
+                readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number }
+              }
+          >
+          readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
+          readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
+          readonly cost?: number
+          readonly tokens?: {
+            readonly input: number
+            readonly output: number
+            readonly reasoning: number
+            readonly cache: { readonly read: number; readonly write: number }
+          }
+          readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
+          readonly retry?: {
+            readonly attempt: number
+            readonly at: number
+            readonly error: { readonly type: string; readonly message: string; readonly status?: number }
+          }
+        }
+      | (
+          | {
+              readonly type: "compaction"
+              readonly id: string
+              readonly metadata?: { readonly [x: string]: JsonValue }
+              readonly time: { readonly created: number }
+              readonly status: "running"
+              readonly reason: "auto" | "manual"
+              readonly summary: string
+              readonly recent: string
+            }
+          | {
+              readonly type: "compaction"
+              readonly id: string
+              readonly metadata?: { readonly [x: string]: JsonValue }
+              readonly time: { readonly created: number }
+              readonly status: "completed"
+              readonly reason: "auto" | "manual"
+              readonly summary: string
+              readonly recent: string
+            }
+          | {
+              readonly type: "compaction"
+              readonly id: string
+              readonly metadata?: { readonly [x: string]: JsonValue }
+              readonly time: { readonly created: number }
+              readonly status: "failed"
+              readonly reason: "auto" | "manual"
+              readonly error: { readonly type: string; readonly message: string; readonly status?: number }
+            }
+        )
+    >
+    readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
+  }["messages"]
+  readonly location?: {
+    readonly info: {
+      readonly id: string
+      readonly parentID?: string
+      readonly fork?: {
+        readonly sessionID: string
+        readonly boundary:
+          | { readonly type: "before"; readonly messageID: string }
+          | { readonly type: "through"; readonly messageID: string }
+      }
+      readonly projectID: string
+      readonly agent?: string
+      readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
+      readonly cost: number
+      readonly tokens: {
+        readonly input: number
+        readonly output: number
+        readonly reasoning: number
+        readonly cache: { readonly read: number; readonly write: number }
+      }
+      readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
+      readonly title?: string
+      readonly location: { readonly directory: string; readonly workspaceID?: string }
+      readonly subpath?: string
+      readonly revert?: {
+        readonly messageID: string
+        readonly partID?: string
+        readonly snapshot?: string
+        readonly files?: ReadonlyArray<{
+          readonly file: string
+          readonly patch: string
+          readonly additions: number
+          readonly deletions: number
+          readonly status: "added" | "deleted" | "modified"
+        }>
+      }
+    }
+    readonly messages: ReadonlyArray<
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly type: "agent-switched"
+          readonly agent: string
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly type: "model-switched"
+          readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+          readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly text: string
+          readonly files?: ReadonlyArray<{
+            readonly data: string
+            readonly mime: string
+            readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string }
+            readonly name?: string
+            readonly description?: string
+            readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+          }>
+          readonly agents?: ReadonlyArray<{
+            readonly name: string
+            readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
+          }>
+          readonly type: "user"
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly text: string
+          readonly description?: string
+          readonly type: "synthetic"
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly type: "system"
+          readonly text: string
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number }
+          readonly type: "skill"
+          readonly skill: string
+          readonly name: string
+          readonly text: string
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number; readonly completed?: number }
+          readonly type: "shell"
+          readonly shellID: string
+          readonly command: string
+          readonly status: "running" | "exited" | "timeout" | "killed"
+          readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
+          readonly output?: {
+            readonly output: string
+            readonly cursor: number
+            readonly size: number
+            readonly truncated: boolean
+          }
+        }
+      | {
+          readonly id: string
+          readonly metadata?: { readonly [x: string]: JsonValue }
+          readonly time: { readonly created: number; readonly completed?: number }
+          readonly type: "assistant"
+          readonly agent: string
+          readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+          readonly content: ReadonlyArray<
+            | { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
+            | {
+                readonly type: "reasoning"
+                readonly text: string
+                readonly state?: { readonly [x: string]: JsonValue }
+                readonly time?: { readonly created: number; readonly completed?: number }
+              }
+            | {
+                readonly type: "tool"
+                readonly id: string
+                readonly name: string
+                readonly executed?: boolean
+                readonly providerState?: { readonly [x: string]: JsonValue }
+                readonly providerResultState?: { readonly [x: string]: JsonValue }
+                readonly state:
+                  | { readonly status: "streaming"; readonly input: string }
+                  | {
+                      readonly status: "running"
+                      readonly input: { readonly [x: string]: JsonValue }
+                      readonly metadata: { readonly [x: string]: JsonValue }
+                    }
+                  | {
+                      readonly status: "completed"
+                      readonly input: { readonly [x: string]: JsonValue }
+                      readonly content: readonly [
+                        (
+                          | { readonly type: "text"; readonly text: string }
+                          | {
+                              readonly type: "file"
+                              readonly uri: string
+                              readonly mime: string
+                              readonly name?: string | null
+                            }
+                        ),
+                        ...Array<
+                          | { readonly type: "text"; readonly text: string }
+                          | {
+                              readonly type: "file"
+                              readonly uri: string
+                              readonly mime: string
+                              readonly name?: string | null
+                            }
+                        >,
+                      ]
+                      readonly metadata?: { readonly [x: string]: JsonValue }
+                    }
+                  | {
+                      readonly status: "error"
+                      readonly input: { readonly [x: string]: JsonValue }
+                      readonly error: { readonly type: string; readonly message: string; readonly status?: number }
+                      readonly content?: readonly [
+                        (
+                          | { readonly type: "text"; readonly text: string }
+                          | {
+                              readonly type: "file"
+                              readonly uri: string
+                              readonly mime: string
+                              readonly name?: string | null
+                            }
+                        ),
+                        ...Array<
+                          | { readonly type: "text"; readonly text: string }
+                          | {
+                              readonly type: "file"
+                              readonly uri: string
+                              readonly mime: string
+                              readonly name?: string | null
+                            }
+                        >,
+                      ]
+                      readonly metadata?: { readonly [x: string]: JsonValue }
+                    }
+                readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number }
+              }
+          >
+          readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
+          readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
+          readonly cost?: number
+          readonly tokens?: {
+            readonly input: number
+            readonly output: number
+            readonly reasoning: number
+            readonly cache: { readonly read: number; readonly write: number }
+          }
+          readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
+          readonly retry?: {
+            readonly attempt: number
+            readonly at: number
+            readonly error: { readonly type: string; readonly message: string; readonly status?: number }
+          }
+        }
+      | (
+          | {
+              readonly type: "compaction"
+              readonly id: string
+              readonly metadata?: { readonly [x: string]: JsonValue }
+              readonly time: { readonly created: number }
+              readonly status: "running"
+              readonly reason: "auto" | "manual"
+              readonly summary: string
+              readonly recent: string
+            }
+          | {
+              readonly type: "compaction"
+              readonly id: string
+              readonly metadata?: { readonly [x: string]: JsonValue }
+              readonly time: { readonly created: number }
+              readonly status: "completed"
+              readonly reason: "auto" | "manual"
+              readonly summary: string
+              readonly recent: string
+            }
+          | {
+              readonly type: "compaction"
+              readonly id: string
+              readonly metadata?: { readonly [x: string]: JsonValue }
+              readonly time: { readonly created: number }
+              readonly status: "failed"
+              readonly reason: "auto" | "manual"
+              readonly error: { readonly type: string; readonly message: string; readonly status?: number }
+            }
+        )
+    >
+    readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
+  }["location"]
+}
+
+export type SessionImportOutput = { data: SessionInfo }["data"]
+
+export type SessionExportInput = {
+  readonly sessionID: { readonly sessionID: string }["sessionID"]
+  readonly sanitize?: { readonly sanitize?: boolean | undefined }["sanitize"]
+}
+
+export type SessionExportOutput = { data: SessionTransferData }["data"]
+
 export type SessionActiveOutput = { data: { [x: string]: SessionActive } }["data"]
 
 export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }

+ 323 - 0
packages/core/src/session/transfer.ts

@@ -0,0 +1,323 @@
+export * as SessionTransfer from "./transfer"
+
+import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
+import { Tool } from "@opencode-ai/schema/tool"
+import { eq, isNotNull, isNull, ne, or } from "drizzle-orm"
+import { Context, DateTime, Effect, Layer, Schema } from "effect"
+import path from "path"
+import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
+import { App } from "../app"
+import { Bus } from "../bus"
+import { Database } from "../database/database"
+import { Location } from "../location"
+import { Project } from "../project"
+import { ProjectTable } from "../project/sql"
+import { AbsolutePath, RelativePath } from "../schema"
+import { Session } from "../session"
+import { Slug } from "../util/slug"
+import { SessionEvent } from "./event"
+import { SessionMessage } from "./message"
+import { SessionProjector } from "./projector"
+import { SessionMessageTable, SessionTable } from "./sql"
+
+export const Data = SessionTransfer.Data
+export type Data = SessionTransfer.Data
+
+export class ImportConflictError extends Schema.TaggedErrorClass<ImportConflictError>()(
+  "SessionTransfer.ImportConflictError",
+  { sessionID: Session.ID },
+) {}
+
+export interface Interface {
+  readonly export: (input: {
+    sessionID: Session.ID
+    sanitize?: boolean
+  }) => Effect.Effect<Data, Session.NotFoundError | Session.MessageDecodeError>
+  readonly import: (input: {
+    data: Data
+    location: Location.Ref
+  }) => Effect.Effect<Session.Info, ImportConflictError>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTransfer") {}
+
+const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const app = yield* App.Metadata
+    const bus = yield* Bus.Service
+    const { db } = yield* Database.Service
+    const projects = yield* Project.Service
+    const sessions = yield* Session.Service
+    const encodeMessage = Schema.encodeSync(SessionMessage.Info)
+
+    const persistProject = (project: Project.Resolved) => {
+      const vcs = project.vcs?.type
+      return db
+        .insert(ProjectTable)
+        .values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
+        .onConflictDoUpdate({
+          target: ProjectTable.id,
+          set: { worktree: project.canonical, vcs: vcs ?? null },
+          setWhere: or(
+            ne(ProjectTable.worktree, project.canonical),
+            vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
+          ),
+        })
+        .run()
+        .pipe(Effect.orDie)
+    }
+
+    return Service.of({
+      export: Effect.fn("SessionTransfer.export")(function* (input) {
+        const data = {
+          info: yield* sessions.get(input.sessionID),
+          messages: yield* sessions.messages({ sessionID: input.sessionID, order: "asc" }),
+        }
+        return input.sanitize ? sanitize(data) : data
+      }),
+      import: Effect.fn("SessionTransfer.import")(function* (input) {
+        const sessionID = input.data.info.id
+        const recorded = yield* db
+          .select({ id: SessionTable.id })
+          .from(SessionTable)
+          .where(eq(SessionTable.id, sessionID))
+          .get()
+          .pipe(Effect.orDie)
+        if (recorded) return yield* new ImportConflictError({ sessionID })
+        const project = yield* projects.resolve(input.location.directory)
+        yield* persistProject(project)
+        const messages = input.data.messages.map((message, index) => {
+          const encoded = encodeMessage(message)
+          const { id: _, type, ...data } = encoded
+          return {
+            id: message.id,
+            session_id: sessionID,
+            type,
+            seq: index + 1,
+            time_created: DateTime.toEpochMillis(message.time.created),
+            data,
+          }
+        })
+        yield* bus
+          .publish(
+            SessionEvent.Created,
+            {
+              sessionID,
+              slug: Slug.create(),
+              version: app.version,
+              projectID: project.id,
+              location: input.location,
+              subpath: RelativePath.make(path.relative(project.directory, input.location.directory).replaceAll("\\", "/")),
+              title: input.data.info.title,
+              agent: input.data.info.agent,
+              model: input.data.info.model,
+            },
+            {
+              location: input.location,
+              commit: (seq) =>
+                Effect.gen(function* () {
+                  if (messages.length > 0) {
+                    yield* db.insert(SessionMessageTable).values(messages).run().pipe(Effect.orDie)
+                    yield* Bus.reserveSequence(db, sessionID, seq + messages.length)
+                  }
+                  yield* db
+                    .update(SessionTable)
+                    .set({
+                      cost: input.data.info.cost,
+                      tokens_input: input.data.info.tokens.input,
+                      tokens_output: input.data.info.tokens.output,
+                      tokens_reasoning: input.data.info.tokens.reasoning,
+                      tokens_cache_read: input.data.info.tokens.cache.read,
+                      tokens_cache_write: input.data.info.tokens.cache.write,
+                      time_created: DateTime.toEpochMillis(input.data.info.time.created),
+                      time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
+                      time_archived: input.data.info.time.archived
+                        ? DateTime.toEpochMillis(input.data.info.time.archived)
+                        : null,
+                    })
+                    .where(eq(SessionTable.id, sessionID))
+                    .run()
+                    .pipe(Effect.orDie)
+                }),
+            },
+          )
+          .pipe(
+            Effect.catchDefect((defect) =>
+              defect instanceof SessionProjector.SessionAlreadyProjected
+                ? Effect.fail(new ImportConflictError({ sessionID }))
+                : Effect.die(defect),
+            ),
+          )
+        return yield* sessions.get(sessionID).pipe(Effect.orDie)
+      }),
+    })
+  }),
+)
+
+export const node = makeGlobalNode({
+  service: Service,
+  layer,
+  deps: [App.node, Bus.node, Database.node, Project.node, Session.node],
+})
+
+function redact(kind: string, id: string, value: string) {
+  return value.trim() ? `[redacted:${kind}:${id}]` : value
+}
+
+function metadata(kind: string, id: string, value: Readonly<Record<string, unknown>> | undefined) {
+  if (!value) return value
+  return Object.keys(value).length > 0 ? { redacted: `${kind}:${id}` } : value
+}
+
+function sanitize(data: Data): Data {
+  return {
+    info: {
+      ...data.info,
+      title: data.info.title === undefined ? undefined : redact("session-title", data.info.id, data.info.title),
+      location: {
+        ...data.info.location,
+        directory: AbsolutePath.make(`/${redact("session-directory", data.info.id, data.info.location.directory)}`),
+      },
+      revert: data.info.revert
+        ? {
+            ...data.info.revert,
+            files: data.info.revert.files?.map((file, index) => ({
+              ...file,
+              file: redact("revert-file", String(index), file.file),
+              patch: redact("revert-patch", String(index), file.patch),
+            })),
+          }
+        : undefined,
+    },
+    messages: data.messages.map(sanitizeMessage),
+  }
+}
+
+function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
+  const meta = metadata("message-metadata", message.id, message.metadata)
+  if (message.type === "user")
+    return {
+      ...message,
+      metadata: meta,
+      text: redact("text", message.id, message.text),
+      files: message.files?.map((file, index) => ({
+        ...file,
+        data: "",
+        source: { type: "inline" },
+        name: file.name === undefined ? undefined : redact("file-name", String(index), file.name),
+        description:
+          file.description === undefined ? undefined : redact("file-description", String(index), file.description),
+        mention: file.mention
+          ? { ...file.mention, text: redact("file-mention", String(index), file.mention.text) }
+          : undefined,
+      })),
+      agents: message.agents?.map((agent, index) => ({
+        ...agent,
+        name: redact("agent-name", String(index), agent.name),
+        mention: agent.mention
+          ? { ...agent.mention, text: redact("agent-mention", String(index), agent.mention.text) }
+          : undefined,
+      })),
+    }
+  if (message.type === "synthetic")
+    return {
+      ...message,
+      metadata: meta,
+      text: redact("synthetic", message.id, message.text),
+      description:
+        message.description === undefined
+          ? undefined
+          : redact("synthetic-description", message.id, message.description),
+    }
+  if (message.type === "system")
+    return { ...message, metadata: meta, text: redact("system", message.id, message.text) }
+  if (message.type === "skill") return { ...message, metadata: meta, text: redact("skill", message.id, message.text) }
+  if (message.type === "shell")
+    return {
+      ...message,
+      metadata: meta,
+      command: redact("shell-command", message.id, message.command),
+      output: message.output
+        ? { ...message.output, output: redact("shell-output", message.id, message.output.output) }
+        : undefined,
+    }
+  if (message.type === "assistant")
+    return {
+      ...message,
+      metadata: meta,
+      content: message.content.map((content) => {
+        if (content.type === "text")
+          return {
+            ...content,
+            text: redact("text", message.id, content.text),
+            state: content.state ? { redacted: `text-state:${message.id}` } : undefined,
+          }
+        if (content.type === "reasoning")
+          return {
+            ...content,
+            text: redact("reasoning", message.id, content.text),
+            state: content.state ? { redacted: `reasoning-state:${message.id}` } : undefined,
+          }
+        return {
+          ...content,
+          providerState: content.providerState ? { redacted: `tool-provider-state:${message.id}` } : undefined,
+          providerResultState: content.providerResultState
+            ? { redacted: `tool-provider-result-state:${message.id}` }
+            : undefined,
+          state: sanitizeToolState(message.id, content.state),
+        }
+      }),
+    }
+  if (message.type === "compaction") {
+    if (message.status === "failed")
+      return {
+        ...message,
+        metadata: meta,
+      }
+    return {
+      ...message,
+      metadata: meta,
+      summary: redact("compaction-summary", message.id, message.summary),
+      recent: redact("compaction-recent", message.id, message.recent),
+    }
+  }
+  return { ...message, metadata: meta }
+}
+
+function sanitizeToolState(id: string, state: SessionMessage.ToolState): SessionMessage.ToolState {
+  if (state.status === "streaming") return { ...state, input: redact("tool-input", id, state.input) }
+  if (state.status === "running")
+    return { ...state, input: { redacted: `tool-input:${id}` }, metadata: { redacted: `tool-metadata:${id}` } }
+  const meta = state.metadata === undefined ? undefined : { redacted: `tool-metadata:${id}` }
+  if (state.status === "completed")
+    return {
+      ...state,
+      input: { redacted: `tool-input:${id}` },
+      content: [
+        sanitizeToolContent(id, state.content[0]),
+        ...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
+      ],
+      metadata: meta,
+    }
+  return {
+    ...state,
+    input: { redacted: `tool-input:${id}` },
+    content: state.content
+      ? [
+          sanitizeToolContent(id, state.content[0]),
+          ...state.content.slice(1).map((item) => sanitizeToolContent(id, item)),
+        ]
+      : undefined,
+    metadata: meta,
+  }
+}
+
+function sanitizeToolContent(id: string, content: Tool.Content): Tool.Content {
+  if (content.type === "text") return { ...content, text: redact("tool-output", id, content.text) }
+  return {
+    ...content,
+    uri: redact("tool-file-uri", id, content.uri),
+    name: content.name === undefined ? undefined : redact("tool-file-name", id, content.name),
+  }
+}

+ 83 - 1
packages/core/test/session-create.test.ts

@@ -23,6 +23,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
 import { SessionEvent } from "@opencode-ai/core/session/event"
 import { SessionTable } from "@opencode-ai/core/session/sql"
 import { SessionStore } from "@opencode-ai/core/session/store"
+import { SessionTransfer } from "@opencode-ai/core/session/transfer"
 import { Workspace } from "@opencode-ai/core/workspace"
 import { testEffect } from "./lib/effect"
 import { tmpdir } from "./fixture/tmpdir"
@@ -37,7 +38,14 @@ const projects = Layer.succeed(
 )
 const it = testEffect(
   AppNodeBuilder.build(
-    LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
+    LayerNode.group([
+      Database.node,
+      Bus.node,
+      SessionProjector.node,
+      SessionStore.node,
+      Session.node,
+      SessionTransfer.node,
+    ]),
     [
       [Bus.node, Bus.configured({ persist: true })],
       [Project.node, projects],
@@ -740,3 +748,77 @@ describe("Session.create", () => {
     }),
   )
 })
+
+describe("SessionTransfer", () => {
+  it.effect("imports projected messages and reserves their aggregate sequence", () =>
+    Effect.gen(function* () {
+      const session = yield* Session.Service
+      const transfer = yield* SessionTransfer.Service
+      const bus = yield* Bus.Service
+      const { db } = yield* Database.Service
+      const template = yield* session.create({ location, title: "Exported" })
+      const sessionID = Session.ID.create()
+      const sourceMessageID = SessionMessage.ID.create()
+      const errorMessageID = SessionMessage.ID.create()
+
+      const imported = yield* transfer.import({
+        data: {
+          info: { ...template, id: sessionID },
+          messages: [
+            {
+              id: sourceMessageID,
+              type: "user",
+              text: "Imported message",
+              time: { created: DateTime.makeUnsafe(100) },
+            },
+            {
+              id: errorMessageID,
+              type: "compaction",
+              status: "failed",
+              reason: "manual",
+              error: { type: "test_error", message: "Original error" },
+              time: { created: DateTime.makeUnsafe(101) },
+            },
+          ],
+        },
+        location,
+      })
+      const messages = yield* session.messages({ sessionID, order: "asc" })
+
+      expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
+      expect(messages).toMatchObject([
+        { id: sourceMessageID, type: "user", text: "Imported message" },
+        { id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
+      ])
+      expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
+      expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
+      expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
+        { id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
+        { id: errorMessageID, error: { type: "test_error", message: "Original error" } },
+      ])
+
+      yield* session.prompt({ sessionID, text: "Continue", resume: false })
+      yield* SessionPending.promote(db, bus, sessionID, "steer")
+
+      expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.type)).toEqual([
+        "user",
+        "compaction",
+        "user",
+      ])
+      expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
+    }),
+  )
+
+  it.effect("rejects an existing session ID without changing its transcript", () =>
+    Effect.gen(function* () {
+      const session = yield* Session.Service
+      const transfer = yield* SessionTransfer.Service
+      const existing = yield* session.create({ location, title: "Existing" })
+      const exit = yield* Effect.exit(transfer.import({ data: { info: existing, messages: [] }, location }))
+
+      expect(exit._tag).toBe("Failure")
+      expect((yield* session.get(existing.id)).title).toBe("Existing")
+      expect(yield* session.messages({ sessionID: existing.id })).toEqual([])
+    }),
+  )
+})

+ 31 - 0
packages/protocol/src/groups/session.ts

@@ -1,4 +1,5 @@
 import { SessionMessage } from "@opencode-ai/schema/session-message"
+import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
 import { SessionPending } from "@opencode-ai/schema/session-pending"
 import { PromptInput } from "@opencode-ai/schema/prompt-input"
 import { Session } from "@opencode-ai/schema/session"
@@ -163,6 +164,36 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
         }),
       ),
     )
+    .add(
+      HttpApiEndpoint.post("session.import", "/api/session/import", {
+        payload: Schema.Struct({
+          ...SessionTransfer.Data.fields,
+          location: Location.Ref.pipe(Schema.optional),
+        }),
+        success: Schema.Struct({ data: Session.Info }),
+        error: ConflictError,
+      }).annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.session.import",
+          summary: "Import session",
+          description: "Import a projected session transcript at the requested location.",
+        }),
+      ),
+    )
+    .add(
+      HttpApiEndpoint.get("session.export", "/api/session/:sessionID/export", {
+        params: { sessionID: Session.ID },
+        query: Schema.Struct({ sanitize: BooleanFromString.pipe(Schema.optional) }),
+        success: Schema.Struct({ data: SessionTransfer.Data }),
+        error: [SessionNotFoundError, UnknownError],
+      }).annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.session.export",
+          summary: "Export session",
+          description: "Export a complete projected session transcript.",
+        }),
+      ),
+    )
     .add(
       HttpApiEndpoint.get("session.active", "/api/session/active", {
         success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }),

+ 1 - 0
packages/schema/src/index.ts

@@ -25,6 +25,7 @@ export { Vcs } from "./vcs.js"
 export { SessionPending } from "./session-pending.js"
 export { SessionError } from "./session-error.js"
 export { SessionMessage } from "./session-message.js"
+export { SessionTransfer } from "./session-transfer.js"
 export { Snapshot } from "./snapshot.js"
 export { Shell } from "./shell.js"
 export { Skill } from "./skill.js"

+ 11 - 0
packages/schema/src/session-transfer.ts

@@ -0,0 +1,11 @@
+export * as SessionTransfer from "./session-transfer.js"
+
+import { Schema } from "effect"
+import { Session } from "./session.js"
+import { SessionMessage } from "./session-message.js"
+
+export interface Data extends Schema.Schema.Type<typeof Data> {}
+export const Data = Schema.Struct({
+  info: Session.Info,
+  messages: Schema.Array(SessionMessage.Info),
+}).annotate({ identifier: "SessionTransfer.Data" })

+ 52 - 0
packages/server/src/handlers/session.ts

@@ -1,4 +1,5 @@
 import { Session } from "@opencode-ai/core/session"
+import { SessionTransfer } from "@opencode-ai/core/session/transfer"
 import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
 import { DateTime, Effect, Stream } from "effect"
 import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
@@ -24,6 +25,7 @@ const DefaultSessionsLimit = 50
 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
   Effect.gen(function* () {
     const session = yield* Session.Service
+    const transfer = yield* SessionTransfer.Service
 
     return handlers
       .handle(
@@ -86,6 +88,56 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
           }
         }),
       )
+      .handle(
+        "session.import",
+        Effect.fn(function* (ctx) {
+          return {
+            data: yield* transfer
+              .import({
+                data: { info: ctx.payload.info, messages: ctx.payload.messages },
+                location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
+              })
+              .pipe(
+                Effect.catchTag(
+                  "SessionTransfer.ImportConflictError",
+                  (error) =>
+                    new ConflictError({
+                      message: `Session already exists: ${error.sessionID}`,
+                      resource: error.sessionID,
+                    }),
+                ),
+              ),
+          }
+        }),
+      )
+      .handle(
+        "session.export",
+        Effect.fn(function* (ctx) {
+          return {
+            data: yield* transfer.export({ sessionID: ctx.params.sessionID, sanitize: ctx.query.sanitize }).pipe(
+              Effect.catchTag(
+                "Session.NotFoundError",
+                (error) =>
+                  new SessionNotFoundError({
+                    sessionID: error.sessionID,
+                    message: `Session not found: ${error.sessionID}`,
+                  }),
+              ),
+              Effect.catchTag("Session.MessageDecodeError", (error) => {
+                const ref = `err_${crypto.randomUUID().slice(0, 8)}`
+                return Effect.logError("failed to decode session message").pipe(
+                  Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
+                  Effect.andThen(
+                    Effect.fail(
+                      new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
+                    ),
+                  ),
+                )
+              }),
+            ),
+          }
+        }),
+      )
       .handle(
         "session.active",
         Effect.fn(function* () {

+ 2 - 0
packages/server/src/routes.ts

@@ -16,6 +16,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
 import { Pty } from "@opencode-ai/core/pty"
 import { Project } from "@opencode-ai/core/project"
 import { Session } from "@opencode-ai/core/session"
+import { SessionTransfer } from "@opencode-ai/core/session/transfer"
 import { Shell } from "@opencode-ai/core/shell"
 import { Job } from "@opencode-ai/core/job"
 import { MCP } from "@opencode-ai/core/mcp/index"
@@ -51,6 +52,7 @@ const applicationServices = LayerNode.group([
   Job.node,
   Project.node,
   Session.node,
+  SessionTransfer.node,
   PluginRuntime.providerNode,
   SdkPlugins.node,
   PermissionSaved.node,

+ 7 - 16
packages/tui/src/routes/session/index.tsx

@@ -824,22 +824,13 @@ export function Session() {
           if (options === null) return
 
           const content =
-            options.format === "markdown"
-              ? formatSessionTranscript(sessionData, messages(), options.thinking)
-              : await (async () => {
-                  const messages: unknown[] = []
-                  let cursor: string | undefined
-                  do {
-                    const page = await client.api.message.list(
-                      cursor
-                        ? { sessionID: sessionData.id, limit: 200, cursor }
-                        : { sessionID: sessionData.id, limit: 200, order: "asc" },
-                    )
-                    messages.push(...page.data)
-                    cursor = page.data.length ? (page.cursor.next ?? undefined) : undefined
-                  } while (cursor)
-                  return JSON.stringify({ info: sessionData, messages }, null, 2) + EOL
-                })()
+              options.format === "markdown"
+                ? formatSessionTranscript(sessionData, messages(), options.thinking)
+                : JSON.stringify(
+                    await client.api.session.export({ sessionID: sessionData.id, sanitize: options.sanitize }),
+                    null,
+                    2,
+                  ) + EOL
 
           if (options.action === "copy") {
             await clipboard.write?.(content)

+ 47 - 3
packages/tui/src/ui/dialog-export-options.tsx

@@ -9,11 +9,11 @@ export type ExportFormat = "markdown" | "json"
 
 export type DialogExportOptionsProps = {
   defaultThinking: boolean
-  onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; thinking: boolean }) => void
+  onConfirm?: (options: { action: "copy" | "export"; format: ExportFormat; thinking: boolean; sanitize: boolean }) => void
   onCancel?: () => void
 }
 
-type Active = ExportFormat | "thinking" | "copy" | "export"
+type Active = ExportFormat | "thinking" | "sanitize" | "copy" | "export"
 
 export function DialogExportOptions(props: DialogExportOptionsProps) {
   const dialog = useDialog()
@@ -22,6 +22,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
   const [store, setStore] = createStore({
     format: "markdown" as ExportFormat,
     thinking: props.defaultThinking,
+    sanitize: false,
     active: "markdown" as Active,
   })
 
@@ -30,6 +31,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
       action,
       format: store.format,
       thinking: store.thinking,
+      sanitize: store.sanitize,
     })
 
   const activate = () => {
@@ -38,6 +40,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
       return
     }
     if (store.active === "thinking") setStore("thinking", !store.thinking)
+    if (store.active === "sanitize") setStore("sanitize", !store.sanitize)
     if (store.active === "copy" || store.active === "export") confirm(store.active)
   }
 
@@ -52,7 +55,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
           const order: Active[] =
             store.format === "markdown"
               ? ["markdown", "json", "thinking", "copy", "export"]
-              : ["markdown", "json", "copy", "export"]
+              : ["markdown", "json", "sanitize", "copy", "export"]
           setStore("active", order[(order.indexOf(store.active) + 1) % order.length])
         },
       },
@@ -153,6 +156,46 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
           </text>
         </box>
       </Show>
+      <Show when={store.format === "json"}>
+        <box
+          flexDirection="row"
+          gap={1}
+          backgroundColor={
+            store.active === "sanitize"
+              ? theme.background.formfield.focused
+              : store.sanitize
+                ? theme.background.formfield.selected
+                : theme.background.formfield.default
+          }
+          onMouseUp={() => {
+            setStore("active", "sanitize")
+            setStore("sanitize", !store.sanitize)
+          }}
+        >
+          <text
+            fg={
+              store.active === "sanitize"
+                ? theme.text.formfield.focused
+                : store.sanitize
+                  ? theme.text.formfield.selected
+                  : theme.text.formfield.default
+            }
+          >
+            {store.sanitize ? "[x]" : "[ ]"}
+          </text>
+          <text
+            fg={
+              store.active === "sanitize"
+                ? theme.text.formfield.focused
+                : store.sanitize
+                  ? theme.text.formfield.selected
+                  : theme.text.formfield.default
+            }
+          >
+            Sanitize sensitive data
+          </text>
+        </box>
+      </Show>
       <box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
         <box
           paddingLeft={4}
@@ -186,6 +229,7 @@ DialogExportOptions.show = (dialog: DialogContext, defaultThinking: boolean) =>
     action: "copy" | "export"
     format: ExportFormat
     thinking: boolean
+    sanitize: boolean
   } | null>((resolve) => {
     dialog.replace(
       () => (