Переглянути джерело

feat(core): moving sessions (#30640)

James Long 3 місяців тому
батько
коміт
ba1b660d57
36 змінених файлів з 2180 додано та 476 видалено
  1. 128 0
      packages/core/src/control-plane/move-session.ts
  2. 166 1
      packages/core/src/git.ts
  3. 9 3
      packages/core/src/project/copy-strategies.ts
  4. 15 3
      packages/core/src/project/copy.ts
  5. 0 9
      packages/core/src/session.ts
  6. 14 0
      packages/core/src/session/event.ts
  7. 1 0
      packages/core/src/session/message-updater.ts
  8. 14 0
      packages/core/src/session/projector.ts
  9. 247 0
      packages/core/test/move-session.test.ts
  10. 97 3
      packages/core/test/project-copy.test.ts
  11. 0 1
      packages/core/test/session-create.test.ts
  12. 129 0
      packages/opencode/src/cli/cmd/tui/component/dialog-move-session.tsx
  13. 1 1
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx
  14. 58 163
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  15. 158 0
      packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx
  16. 137 0
      packages/opencode/src/cli/cmd/tui/component/prompt/workspace.tsx
  17. 1 0
      packages/opencode/src/cli/cmd/tui/config/keybind.ts
  18. 16 0
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  19. 6 1
      packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx
  20. 7 5
      packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx
  21. 3 2
      packages/opencode/src/cli/cmd/tui/routes/home.tsx
  22. 26 0
      packages/opencode/src/cli/cmd/tui/routes/home/session-destination.tsx
  23. 18 2
      packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
  24. 2 0
      packages/opencode/src/server/routes/instance/httpapi/api.ts
  25. 35 0
      packages/opencode/src/server/routes/instance/httpapi/groups/control-plane.ts
  26. 25 6
      packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts
  27. 35 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/control-plane.ts
  28. 103 4
      packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts
  29. 6 1
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  30. 63 0
      packages/opencode/test/server/httpapi-control-plane.test.ts
  31. 8 0
      packages/opencode/test/server/httpapi-exercise/index.ts
  32. 4 1
      packages/opencode/test/server/httpapi-global.test.ts
  33. 5 4
      packages/opencode/test/server/project-copy.test.ts
  34. 271 226
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  35. 102 12
      packages/sdk/js/src/v2/gen/types.gen.ts
  36. 270 28
      packages/sdk/openapi.json

+ 128 - 0
packages/core/src/control-plane/move-session.ts

@@ -0,0 +1,128 @@
+export * as MoveSession from "./move-session"
+
+import { Context, DateTime, Effect, Layer, Schema } from "effect"
+import { EventV2 } from "../event"
+import { Git } from "../git"
+import { Location } from "../location"
+import { ProjectV2 } from "../project"
+import { SessionV2 } from "../session"
+import { SessionEvent } from "../session/event"
+import { SessionSchema } from "../session/schema"
+import { AbsolutePath, RelativePath } from "../schema"
+import path from "path"
+
+export const Destination = Schema.Struct({
+  directory: AbsolutePath,
+}).annotate({ identifier: "MoveSession.Destination" })
+export type Destination = typeof Destination.Type
+
+export const Input = Schema.Struct({
+  sessionID: SessionSchema.ID,
+  destination: Destination,
+  moveChanges: Schema.optional(Schema.Boolean),
+}).annotate({ identifier: "MoveSession.Input" })
+export type Input = typeof Input.Type
+
+export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
+  "MoveSession.DestinationProjectMismatchError",
+  {
+    expected: ProjectV2.ID,
+    actual: ProjectV2.ID,
+  },
+) {}
+
+export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
+  message: Schema.String,
+}) {}
+
+export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
+  "MoveSession.CaptureChangesError",
+  {
+    message: Schema.String,
+  },
+) {}
+
+export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
+  "MoveSession.ResetSourceChangesError",
+  {
+    directory: AbsolutePath,
+    message: Schema.String,
+    cause: Schema.optional(Schema.Defect),
+  },
+) {}
+
+export type Error =
+  | SessionV2.NotFoundError
+  | DestinationProjectMismatchError
+  | CaptureChangesError
+  | ApplyChangesError
+  | ResetSourceChangesError
+
+export interface Interface {
+  readonly moveSession: (input: Input) => Effect.Effect<void, Error>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const git = yield* Git.Service
+    const events = yield* EventV2.Service
+    const project = yield* ProjectV2.Service
+    const session = yield* SessionV2.Service
+
+    const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
+      const current = yield* session.get(input.sessionID)
+      const directory = AbsolutePath.make(input.destination.directory)
+      if (current.location.directory === directory) return
+
+      const source = yield* project.resolve(current.location.directory)
+      const destination = yield* project.resolve(directory)
+      if (current.projectID !== destination.id) {
+        return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
+      }
+
+      const patch =
+        input.moveChanges && source.directory !== destination.directory
+          ? yield* git
+              .patch(current.location.directory)
+              .pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
+          : ""
+      if (patch) {
+        yield* git
+          .applyPatch({ directory, patch })
+          .pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
+      }
+
+      yield* events.publish(SessionEvent.Moved, {
+        sessionID: input.sessionID,
+        location: Location.Ref.make({ directory }),
+        subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
+        timestamp: yield* DateTime.now,
+      })
+
+      if (patch) {
+        yield* git.softResetChanges(current.location.directory).pipe(
+          Effect.mapError(
+            (error) =>
+              new ResetSourceChangesError({
+                directory: current.location.directory,
+                message: error.message,
+                cause: error.cause,
+              }),
+          ),
+        )
+      }
+    })
+
+    return Service.of({ moveSession })
+  }),
+)
+
+export const defaultLayer = layer.pipe(
+  Layer.provide(Git.defaultLayer),
+  Layer.provide(EventV2.defaultLayer),
+  Layer.provide(ProjectV2.defaultLayer),
+  Layer.provide(SessionV2.defaultLayer),
+)

+ 166 - 1
packages/core/src/git.ts

@@ -1,7 +1,7 @@
 export * as Git from "./git"
 
 import path from "path"
-import { Context, Effect, Layer, Schema } from "effect"
+import { Context, Effect, Layer, Schema, Stream } from "effect"
 import { ChildProcess } from "effect/unstable/process"
 import { AbsolutePath } from "./schema"
 import { FSUtil } from "./fs-util"
@@ -33,6 +33,13 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
   cause: Schema.optional(Schema.Defect),
 }) {}
 
+export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
+  operation: Schema.Literals(["capture", "apply", "reset"]),
+  directory: AbsolutePath,
+  message: Schema.String,
+  cause: Schema.optional(Schema.Defect),
+}) {}
+
 export interface Interface {
   readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
   readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
@@ -52,6 +59,10 @@ export interface Interface {
   readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
   readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
   readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
+  readonly patch: (directory: AbsolutePath) => Effect.Effect<string, PatchError>
+  readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect<void, PatchError>
+  readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
+  readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
   readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
   readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
   readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
@@ -159,6 +170,156 @@ export const layer = Layer.effect(
       execute(directory, proc)(["reset", "--hard", target]),
     )
 
+    const patch = Effect.fn("Git.patch")(function* (directory: AbsolutePath) {
+      const root = yield* execute(
+        directory,
+        proc,
+      )(["rev-parse", "--show-toplevel"]).pipe(
+        Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
+      )
+      if (root.exitCode !== 0) {
+        return yield* new PatchError({
+          operation: "capture",
+          directory,
+          message: root.stderr.trim() || root.text.trim() || "Failed to locate repository root",
+        })
+      }
+      const repo = AbsolutePath.make(resolvePath(directory, root.text))
+      const scope = path.relative(repo, directory).replaceAll("\\", "/") || "."
+      const tracked = yield* execute(
+        repo,
+        proc,
+      )(["diff", "--binary", "HEAD", "--", scope]).pipe(
+        Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
+      )
+      if (tracked.exitCode !== 0) {
+        return yield* new PatchError({
+          operation: "capture",
+          directory,
+          message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
+        })
+      }
+
+      const untracked = yield* execute(
+        repo,
+        proc,
+      )(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
+        Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
+      )
+      if (untracked.exitCode !== 0) {
+        return yield* new PatchError({
+          operation: "capture",
+          directory,
+          message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
+        })
+      }
+
+      const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
+        execute(
+          repo,
+          proc,
+        )(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
+          Effect.mapError(
+            (cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause }),
+          ),
+          Effect.flatMap((result) =>
+            // git diff --no-index returns 1 when differences were found.
+            result.exitCode === 0 || result.exitCode === 1
+              ? Effect.succeed(result.text)
+              : Effect.fail(
+                  new PatchError({
+                    operation: "capture",
+                    directory,
+                    message:
+                      result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
+                  }),
+                ),
+          ),
+        ),
+      )
+      return [tracked.text, ...created].filter(Boolean).join("\n")
+    })
+
+    const applyPatch = Effect.fn("Git.applyPatch")(function* (input: { directory: AbsolutePath; patch: string }) {
+      const result = yield* proc
+        .run(
+          ChildProcess.make("git", ["apply", "-"], {
+            cwd: input.directory,
+            extendEnv: true,
+            stdin: Stream.make(new TextEncoder().encode(input.patch)),
+          }),
+        )
+        .pipe(
+          Effect.mapError(
+            (cause) =>
+              new PatchError({ operation: "apply", directory: input.directory, message: cause.message, cause }),
+          ),
+        )
+      if (result.exitCode === 0) return
+      return yield* new PatchError({
+        operation: "apply",
+        directory: input.directory,
+        message:
+          result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
+      })
+    })
+
+    const resetChanges = Effect.fn("Git.resetChanges")(function* (directory: AbsolutePath) {
+      const reset = yield* execute(
+        directory,
+        proc,
+      )(["reset", "--hard", "HEAD"]).pipe(
+        Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
+      )
+      if (reset.exitCode !== 0) {
+        return yield* new PatchError({
+          operation: "reset",
+          directory,
+          message: reset.stderr.trim() || reset.text.trim() || "Failed to reset tracked changes",
+        })
+      }
+      const clean = yield* execute(
+        directory,
+        proc,
+      )(["clean", "-fd"]).pipe(
+        Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
+      )
+      if (clean.exitCode === 0) return
+      return yield* new PatchError({
+        operation: "reset",
+        directory,
+        message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
+      })
+    })
+
+    const softResetChanges = Effect.fn("Git.softResetChanges")(function* (directory: AbsolutePath) {
+      const checkout = yield* execute(
+        directory,
+        proc,
+      )(["checkout", "--", "."]).pipe(
+        Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
+      )
+      if (checkout.exitCode !== 0) {
+        return yield* new PatchError({
+          operation: "reset",
+          directory,
+          message: checkout.stderr.trim() || checkout.text.trim() || "Failed to restore tracked changes",
+        })
+      }
+      const clean = yield* execute(
+        directory,
+        proc,
+      )(["clean", "-fd", "--", "."]).pipe(
+        Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
+      )
+      if (clean.exitCode === 0) return
+      return yield* new PatchError({
+        operation: "reset",
+        directory,
+        message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
+      })
+    })
+
     const worktree = Effect.fnUntraced(function* (
       operation: "create" | "remove" | "list",
       repo: Repo,
@@ -216,6 +377,10 @@ export const layer = Layer.effect(
       fetchBranch,
       checkout,
       reset,
+      patch,
+      applyPatch,
+      resetChanges,
+      softResetChanges,
       worktreeCreate,
       worktreeRemove,
       worktreeList,

+ 9 - 3
packages/core/src/project/copy-strategies.ts

@@ -25,11 +25,17 @@ export function makeStrategies(input: {
       yield* input.git.worktreeRemove({ repo: found, directory })
     }),
     list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
-      const entries = yield* input.git.worktreeList(repo(directory))
+      const found = yield* input.git.find(directory)
+      if (!found) return yield* new DirectoryUnavailableError({ directory })
+      const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store
+      const entries = yield* input.git.worktreeList(found)
       return yield* Effect.forEach(entries, (entry) =>
-        entry === directory
+        entry === core
           ? Effect.succeed(undefined)
-          : input.canonical(entry).pipe(Effect.map((directory) => ({ directory }))),
+          : input.canonical(entry).pipe(
+              Effect.map((directory) => ({ directory })),
+              Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
+            ),
       ).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined)))
     }),
     detect: Effect.fn("ProjectCopy.GitWorktree.detect")(function* (inputDirectory) {

+ 15 - 3
packages/core/src/project/copy.ts

@@ -2,6 +2,7 @@ export * as ProjectCopy from "./copy"
 
 import { and, eq, inArray } from "drizzle-orm"
 import { Context, Effect, Layer, Schema } from "effect"
+import path from "path"
 import { AbsolutePath } from "../schema"
 import { FSUtil } from "../fs-util"
 import { Git } from "../git"
@@ -10,6 +11,7 @@ import { EventV2 } from "../event"
 import { Project } from "../project"
 import { ProjectDirectoryTable } from "./sql"
 import { makeStrategies } from "./copy-strategies"
+import { Slug } from "../util/slug"
 
 export const StrategyID = Schema.Literal("git_worktree")
 export type StrategyID = typeof StrategyID.Type
@@ -24,6 +26,8 @@ export const CreateInput = Schema.Struct({
   strategy: StrategyID,
   sourceDirectory: AbsolutePath,
   directory: AbsolutePath,
+  name: Schema.optional(Schema.String),
+  context: Schema.optional(Schema.String),
 }).annotate({ identifier: "ProjectCopy.CreateInput" })
 export type CreateInput = typeof CreateInput.Type
 
@@ -183,10 +187,18 @@ export const layer = Layer.effect(
     })
 
     const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
-      if (yield* fs.existsSafe(input.directory))
-        return yield* new DestinationExistsError({ directory: input.directory })
+      yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
+      const name = input.name ?? Slug.create()
+      let suffix = 1
+      let copyDirectory = AbsolutePath.make(path.join(input.directory, name))
+      while (yield* fs.existsSafe(copyDirectory)) {
+        suffix++
+        if (suffix > 10) return yield* new DestinationExistsError({ directory: copyDirectory })
+        copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
+      }
+
       const result = yield* strategy(input.strategy).create({
-        directory: input.directory,
+        directory: copyDirectory,
         sourceDirectory: yield* source(input.sourceDirectory, input.projectID),
       })
       yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy))

+ 0 - 9
packages/core/src/session.ts

@@ -77,11 +77,6 @@ type CreateInput = {
   location: Location.Ref
 }
 
-type MoveInput = {
-  sessionID: SessionSchema.ID
-  location: Location.Ref
-}
-
 type CompactInput = {
   sessionID: SessionSchema.ID
   prompt?: Prompt
@@ -110,7 +105,6 @@ export type Error = NotFoundError | MessageDecodeError | OperationUnavailableErr
 export interface Interface {
   readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
   readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info>
-  readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
   readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
   readonly messages: (input: {
     sessionID: SessionSchema.ID
@@ -417,9 +411,6 @@ export const layer = Layer.effect(
         yield* result.get(sessionID)
         yield* execution.resume(sessionID)
       }),
-      move: Effect.fn("V2Session.move")(function* () {
-        return yield* new OperationUnavailableError({ operation: "move" })
-      }),
     })
 
     return result

+ 14 - 0
packages/core/src/session/event.ts

@@ -7,6 +7,8 @@ import { ToolOutput } from "../tool-output"
 import { V2Schema } from "../v2-schema"
 import { FileAttachment, Prompt } from "./prompt"
 import { SessionSchema } from "./schema"
+import { Location } from "../location"
+import { RelativePath } from "../schema"
 
 export { FileAttachment }
 
@@ -65,6 +67,17 @@ export const ModelSwitched = EventV2.define({
 })
 export type ModelSwitched = typeof ModelSwitched.Type
 
+export const Moved = EventV2.define({
+  type: "session.next.moved",
+  ...options,
+  schema: {
+    ...Base,
+    location: Location.Ref,
+    subdirectory: RelativePath.pipe(Schema.optional),
+  },
+})
+export type Moved = typeof Moved.Type
+
 export const Prompted = EventV2.define({
   type: "session.next.prompted",
   ...options,
@@ -387,6 +400,7 @@ export namespace Compaction {
 const DurableDefinitions = [
   AgentSwitched,
   ModelSwitched,
+  Moved,
   Prompted,
   Synthetic,
   Shell.Started,

+ 1 - 0
packages/core/src/session/message-updater.ts

@@ -142,6 +142,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
           }),
         )
       },
+      "session.next.moved": () => Effect.void,
       "session.next.prompted": (event) => {
         return adapter.appendMessage(
           new SessionMessage.User({

+ 14 - 0
packages/core/src/session/projector.ts

@@ -10,6 +10,7 @@ import { WorkspaceTable } from "../control-plane/workspace.sql"
 import { SessionMessage } from "./message"
 import { SessionMessageUpdater } from "./message-updater"
 import { SessionInput } from "./input"
+import { WorkspaceV2 } from "../workspace"
 import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
 import type { DeepMutable } from "../schema"
 
@@ -245,6 +246,19 @@ export const layer = Layer.effectDiscard(
         .run()
         .pipe(Effect.orDie),
     )
+    yield* events.project(SessionEvent.Moved, (event) =>
+      db
+        .update(SessionTable)
+        .set({
+          directory: event.data.location.directory,
+          path: event.data.subdirectory,
+          workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
+          time_updated: DateTime.toEpochMillis(event.data.timestamp),
+        })
+        .where(eq(SessionTable.id, event.data.sessionID))
+        .run()
+        .pipe(Effect.orDie),
+    )
     yield* events.project(SessionV1.Event.Deleted, (event) =>
       db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
     )

+ 247 - 0
packages/core/test/move-session.test.ts

@@ -0,0 +1,247 @@
+import { describe, expect } from "bun:test"
+import { $ } from "bun"
+import fs from "fs/promises"
+import path from "path"
+import { eq } from "drizzle-orm"
+import { Effect, Layer } from "effect"
+import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
+import { Database } from "@opencode-ai/core/database/database"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import { Git } from "@opencode-ai/core/git"
+import { EventV2 } from "@opencode-ai/core/event"
+import { Project } from "@opencode-ai/core/project"
+import { ProjectTable } from "@opencode-ai/core/project/sql"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { SessionV2 } from "@opencode-ai/core/session"
+import { SessionExecution } from "@opencode-ai/core/session/execution"
+import { SessionProjector } from "@opencode-ai/core/session/projector"
+import { SessionTable } from "@opencode-ai/core/session/sql"
+import { SessionStore } from "@opencode-ai/core/session/store"
+import { tmpdir } from "./fixture/tmpdir"
+import { testEffect } from "./lib/effect"
+
+const database = Database.layerFromPath(":memory:")
+const events = EventV2.layer.pipe(Layer.provide(database))
+const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events))
+const project = Project.layer.pipe(
+  Layer.provide(database),
+  Layer.provide(FSUtil.defaultLayer),
+  Layer.provide(Git.defaultLayer),
+)
+const store = SessionStore.layer.pipe(Layer.provide(database))
+const sessions = SessionV2.layer.pipe(
+  Layer.provide(database),
+  Layer.provide(events),
+  Layer.provide(project),
+  Layer.provide(store),
+  Layer.provide(SessionExecution.noopLayer),
+)
+const layer = MoveSession.layer.pipe(
+  Layer.provide(database),
+  Layer.provide(FSUtil.defaultLayer),
+  Layer.provide(Git.defaultLayer),
+  Layer.provide(events),
+  Layer.provide(project),
+  Layer.provide(sessions),
+)
+const it = testEffect(Layer.mergeAll(layer, database, events, project, projector, store, SessionExecution.noopLayer, sessions))
+
+function abs(input: string) {
+  return AbsolutePath.make(input)
+}
+
+async function initRepo(directory: string) {
+  await $`git init`.cwd(directory).quiet()
+  await $`git config core.autocrlf false`.cwd(directory).quiet()
+  await $`git config core.fsmonitor false`.cwd(directory).quiet()
+  await $`git config commit.gpgsign false`.cwd(directory).quiet()
+  await $`git config user.email test@opencode.test`.cwd(directory).quiet()
+  await $`git config user.name Test`.cwd(directory).quiet()
+  await fs.writeFile(path.join(directory, "tracked.txt"), "initial\n")
+  await $`git add tracked.txt`.cwd(directory).quiet()
+  await $`git commit -m root`.cwd(directory).quiet()
+}
+
+describe("MoveSession", () => {
+  it.live("moves session changes to another project directory", () =>
+    Effect.gen(function* () {
+      const root = yield* Effect.acquireRelease(
+        Effect.promise(() => tmpdir()),
+        (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
+      )
+      yield* Effect.promise(() => initRepo(root.path))
+      const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
+      const destination = abs(`${root.path}-move-destination`)
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
+      )
+      yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(root.path).quiet())
+      const moved = abs(yield* Effect.promise(() => fs.realpath(destination)))
+      yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
+      yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
+
+      const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
+      const sessionID = SessionV2.ID.make("ses_move")
+      const { db } = yield* Database.Service
+      yield* db
+        .insert(ProjectTable)
+        .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
+        .run()
+        .pipe(Effect.orDie)
+      yield* db
+        .insert(SessionTable)
+        .values({
+          id: sessionID,
+          project_id: projectID,
+          slug: "move",
+          directory: source,
+          title: "move",
+          version: "test",
+          time_created: 1,
+          time_updated: 1,
+        })
+        .run()
+        .pipe(Effect.orDie)
+
+      yield* MoveSession.Service.use((service) =>
+        service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
+      )
+
+      expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
+      expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
+      expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n")
+      expect(yield* Effect.promise(() => Bun.file(path.join(source, "untracked.txt")).exists())).toBe(false)
+      expect(
+        yield* db
+          .select({ directory: SessionTable.directory, path: SessionTable.path })
+          .from(SessionTable)
+          .where(eq(SessionTable.id, sessionID))
+          .get(),
+      ).toEqual({ directory: moved, path: "" })
+    }),
+  )
+
+  it.live("moves within a checkout without transferring existing changes", () =>
+    Effect.gen(function* () {
+      const root = yield* Effect.acquireRelease(
+        Effect.promise(() => tmpdir()),
+        (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
+      )
+      yield* Effect.promise(() => initRepo(root.path))
+      const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
+      const destination = abs(path.join(source, "packages"))
+      yield* Effect.promise(() => fs.mkdir(destination))
+      yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
+      yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
+
+      const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
+      const sessionID = SessionV2.ID.make("ses_move_nested")
+      const { db } = yield* Database.Service
+      yield* db
+        .insert(ProjectTable)
+        .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
+        .run()
+        .pipe(Effect.orDie)
+      yield* db
+        .insert(SessionTable)
+        .values({
+          id: sessionID,
+          project_id: projectID,
+          slug: "move-nested",
+          directory: source,
+          title: "move nested",
+          version: "test",
+          time_created: 1,
+          time_updated: 1,
+        })
+        .run()
+        .pipe(Effect.orDie)
+
+      yield* MoveSession.Service.use((service) =>
+        service.moveSession({ sessionID, destination: { directory: destination }, moveChanges: true }),
+      )
+
+      expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n")
+      expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("new\n")
+      expect(
+        yield* db
+          .select({ directory: SessionTable.directory, path: SessionTable.path })
+          .from(SessionTable)
+          .where(eq(SessionTable.id, sessionID))
+          .get(),
+      ).toEqual({ directory: destination, path: "packages" })
+    }),
+  )
+
+  it.live("moves nested session changes without cleaning unrelated files", () =>
+    Effect.gen(function* () {
+      const root = yield* Effect.acquireRelease(
+        Effect.promise(() => tmpdir()),
+        (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
+      )
+      yield* Effect.promise(() => initRepo(root.path))
+      const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
+      const sourceDirectory = abs(path.join(source, "packages"))
+      yield* Effect.promise(() => fs.mkdir(sourceDirectory))
+      yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "initial\n"))
+      yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "initial\n"))
+      yield* Effect.promise(() => $`git add packages/tracked.txt packages/staged.txt`.cwd(source).quiet())
+      yield* Effect.promise(() => $`git commit -m packages`.cwd(source).quiet())
+      const destination = abs(`${root.path}-move-nested-destination`)
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
+      )
+      yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(source).quiet())
+      const moved = abs(path.join(yield* Effect.promise(() => fs.realpath(destination)), "packages"))
+      yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "changed\n"))
+      yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "staged\n"))
+      yield* Effect.promise(() => $`git add packages/staged.txt`.cwd(source).quiet())
+      yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "untracked.txt"), "new\n"))
+      yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "unrelated\n"))
+      yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "unrelated\n"))
+
+      const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
+      const sessionID = SessionV2.ID.make("ses_move_nested_checkout")
+      const { db } = yield* Database.Service
+      yield* db
+        .insert(ProjectTable)
+        .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
+        .run()
+        .pipe(Effect.orDie)
+      yield* db
+        .insert(SessionTable)
+        .values({
+          id: sessionID,
+          project_id: projectID,
+          slug: "move-nested-checkout",
+          directory: sourceDirectory,
+          title: "move nested checkout",
+          version: "test",
+          time_created: 1,
+          time_updated: 1,
+        })
+        .run()
+        .pipe(Effect.orDie)
+
+      yield* MoveSession.Service.use((service) =>
+        service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
+      )
+
+      expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
+      expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "staged.txt"), "utf8"))).toBe("staged\n")
+      expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
+      expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "tracked.txt"), "utf8"))).toBe(
+        "initial\n",
+      )
+      expect(yield* Effect.promise(() => Bun.file(path.join(sourceDirectory, "untracked.txt")).exists())).toBe(false)
+      expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "staged.txt"), "utf8"))).toBe(
+        "staged\n",
+      )
+      expect(yield* Effect.promise(() => $`git status --porcelain -- packages/staged.txt`.cwd(source).text())).toBe(
+        "M  packages/staged.txt\n",
+      )
+      expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("unrelated\n")
+      expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n")
+    }),
+  )
+})

+ 97 - 3
packages/core/test/project-copy.test.ts

@@ -97,9 +97,11 @@ describe("ProjectCopy", () => {
       const input = yield* setup()
       const copy = yield* ProjectCopy.Service
       const events = yield* EventV2.Service
-      const target = abs(`${input.root.path}-copy-created`)
+      const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
+      const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-created"))
+      const target = abs(path.join(parent, "copy"))
       yield* Effect.addFinalizer(() =>
-        Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
+        Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
       )
       const fiber = yield* events
         .subscribe(ProjectCopy.Event.Updated)
@@ -110,8 +112,10 @@ describe("ProjectCopy", () => {
         projectID: input.projectID,
         strategy: "git_worktree",
         sourceDirectory: input.sourceDirectory,
-        directory: target,
+        directory: parent,
+        name: "copy",
       })
+      expect(created.directory).toBe(target)
       expect(yield* stored(input.projectID)).toEqual(
         [
           { directory: input.sourceDirectory, type: "main" as const },
@@ -127,6 +131,71 @@ describe("ProjectCopy", () => {
     }),
   )
 
+  it.live("adds a numeric suffix when a copy directory already exists", () =>
+    Effect.gen(function* () {
+      const input = yield* setup()
+      const copy = yield* ProjectCopy.Service
+      const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
+      const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-suffix"))
+      const target = abs(path.join(parent, "copy-3"))
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
+      )
+      yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy"), { recursive: true }))
+      yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy-2")))
+
+      const created = yield* copy.create({
+        projectID: input.projectID,
+        strategy: "git_worktree",
+        sourceDirectory: input.sourceDirectory,
+        directory: parent,
+        name: "copy",
+      })
+
+      expect(created.directory).toBe(target)
+      expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy")).then((item) => item.isDirectory()))).toBe(
+        true,
+      )
+      expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy-2")).then((item) => item.isDirectory()))).toBe(
+        true,
+      )
+
+      yield* copy.remove({ projectID: input.projectID, directory: created.directory })
+    }),
+  )
+
+  it.live("fails after ten copy directory conflicts", () =>
+    Effect.gen(function* () {
+      const input = yield* setup()
+      const copy = yield* ProjectCopy.Service
+      const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
+      const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-conflicts"))
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
+      )
+      yield* Effect.promise(() =>
+        Promise.all(
+          Array.from({ length: 10 }, (_, index) =>
+            fs.mkdir(path.join(parent, index === 0 ? "copy" : `copy-${index + 1}`), { recursive: true }),
+          ),
+        ),
+      )
+
+      const error = yield* copy
+        .create({
+          projectID: input.projectID,
+          strategy: "git_worktree",
+          sourceDirectory: input.sourceDirectory,
+          directory: parent,
+          name: "copy",
+        })
+        .pipe(Effect.flip)
+
+      expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
+      expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
+    }),
+  )
+
   it.live("does not publish an event when refresh finds no directory changes", () =>
     Effect.gen(function* () {
       const input = yield* setup()
@@ -181,6 +250,31 @@ describe("ProjectCopy", () => {
     }),
   )
 
+  it.live("refresh ignores stale git worktree registrations", () =>
+    Effect.gen(function* () {
+      const input = yield* setup()
+      const copy = yield* ProjectCopy.Service
+      const stale = abs(`${input.root.path}-copy-stale`)
+      const target = abs(`${input.root.path}-copy-after-stale`)
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
+      )
+      yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet())
+      yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
+      yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
+
+      yield* copy.refresh({ projectID: input.projectID })
+
+      const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
+      expect(yield* stored(input.projectID)).toEqual(
+        [
+          { directory: input.sourceDirectory, type: "main" as const },
+          { directory: discovered, type: "git_worktree" as const },
+        ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
+      )
+    }),
+  )
+
   it.live("refresh with no roots is a no-op", () =>
     Effect.gen(function* () {
       const copy = yield* ProjectCopy.Service

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

@@ -242,7 +242,6 @@ describe("SessionV2.create", () => {
           Effect.map((error) => (error instanceof SessionV2.OperationUnavailableError ? error.operation : "not-found")),
         )
 
-      expect(yield* unavailable(session.move({ sessionID: created.id, location }))).toBe("move")
       expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
       expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
       expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent")

+ 129 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-move-session.tsx

@@ -0,0 +1,129 @@
+import { useTerminalDimensions } from "@opentui/solid"
+import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
+import path from "path"
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
+import { useDialog } from "@tui/ui/dialog"
+import { useSDK } from "@tui/context/sdk"
+import { useTheme } from "@tui/context/theme"
+import { useKV } from "@tui/context/kv"
+import { useSync } from "@tui/context/sync"
+import { Global } from "@opencode-ai/core/global"
+import { Locale } from "@/util/locale"
+import "opentui-spinner/solid"
+
+const REFRESH_FRAMES = ["■", "⬝"]
+
+export type MoveSessionSelection = { type: "directory"; directory: string } | { type: "new" }
+
+export function DialogMoveSession(props: { projectID: string; onSelect: (selection: MoveSessionSelection) => void }) {
+  const dialog = useDialog()
+  const sdk = useSDK()
+  const dimensions = useTerminalDimensions()
+  const { theme } = useTheme()
+  const kv = useKV()
+  const sync = useSync()
+  const [refreshing, setRefreshing] = createSignal(false)
+
+  const [directories] = createResource(
+    () => props.projectID,
+    async (projectID) => {
+      setRefreshing(true)
+      const [, project] = await Promise.all([
+        sdk.client.experimental.projectCopy
+          .refresh({ projectID }, { throwOnError: true })
+          .finally(() => setRefreshing(false)),
+        sdk.client.project.current({}, { throwOnError: true }),
+      ])
+      const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
+      return {
+        directories: directories.data ?? [],
+        main: project.data?.id === projectID ? project.data.worktree : undefined,
+      }
+    },
+  )
+
+  const options = createMemo<DialogSelectOption<string | undefined>[]>(() => {
+    if (directories.loading) return [{ title: "Loading project directories...", value: undefined }]
+    if (directories.error) return [{ title: "Failed to load project directories", value: undefined }]
+    const data = directories()
+    const roots = data ? [...new Set(data.main ? [data.main, ...data.directories] : data.directories)] : []
+    if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
+    const subdirectories = sync.data.session
+      .filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
+      .map((session) => session.directory)
+      .filter((directory) => !roots.includes(directory))
+      .filter((directory, index, directories) => directories.indexOf(directory) === index)
+      .map((location) => ({
+        location,
+        root: roots
+          .filter((root) => {
+            const relative = path.relative(root, location)
+            return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
+          })
+          .toSorted((a, b) => b.length - a.length)[0],
+      }))
+      .filter((item): item is { location: string; root: string } => item.root !== undefined)
+    const list = [...roots.map((location) => ({ location, root: location })), ...subdirectories].toSorted((a, b) => {
+      const root = roots.indexOf(a.root) - roots.indexOf(b.root)
+      if (root !== 0) return root
+      if (a.location === a.root) return -1
+      if (b.location === b.root) return 1
+      return a.location.localeCompare(b.location)
+    })
+    const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
+    return list.map((item) => {
+      const title =
+        Global.Path.home &&
+        (item.location === Global.Path.home || item.location.startsWith(Global.Path.home + path.sep))
+          ? item.location.replace(Global.Path.home, "~")
+          : item.location
+      const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
+      const visible = Locale.truncateLeft(title, titleWidth)
+      const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
+      return {
+        title,
+        titleView: suffix ? (
+          <>
+            {visible.slice(0, split)}
+            <span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
+          </>
+        ) : undefined,
+        value: item.location,
+        category: item.root === data?.main ? "Project" : "Working copies",
+        titleWidth,
+        truncateTitle: "left" as const,
+      }
+    })
+  })
+
+  onMount(() => dialog.setSize("xlarge"))
+
+  return (
+    <box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}>
+      <DialogSelect
+        title="Move session"
+        options={options()}
+        onSelect={(option) => {
+          if (option.value) props.onSelect({ type: "directory", directory: option.value })
+        }}
+        actions={[
+          {
+            command: "dialog.move_session.new",
+            title: "new",
+            onTrigger: () => props.onSelect({ type: "new" }),
+          },
+        ]}
+        footer={
+          <Show when={refreshing()}>
+            <box flexDirection="row" gap={1}>
+              <Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>⬝</text>}>
+                <spinner color={theme.textMuted} frames={REFRESH_FRAMES} interval={160} />
+              </Show>
+              <text fg={theme.textMuted}>refreshing</text>
+            </box>
+          </Show>
+        }
+      />
+    </box>
+  )
+}

+ 1 - 1
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx

@@ -103,7 +103,7 @@ export function DialogWorkspaceFileChanges(props: {
       </scrollbox>
       <box paddingLeft={2} paddingRight={2}>
         <text fg={theme.textMuted} wrapMode="word">
-          Do you want to apply these changes after warping?
+          Do you want to move these changes with the session?
         </text>
       </box>
       <box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>

+ 58 - 163
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -51,18 +51,13 @@ import { useToast } from "../../ui/toast"
 import { useKV } from "../../context/kv"
 import { createFadeIn } from "../../util/signal"
 import { DialogSkill } from "../dialog-skill"
-import {
-  confirmWorkspaceFileChanges,
-  openWorkspaceSelect,
-  warpWorkspaceSession,
-  type WorkspaceSelection,
-} from "../dialog-workspace-create"
 import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
 import { useArgs } from "@tui/context/args"
 import { Flag } from "@opencode-ai/core/flag/flag"
-import { type WorkspaceStatus } from "../workspace-label"
 import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
 import { useTuiConfig } from "../../context/tui-config"
+import { usePromptWorkspace } from "./workspace"
+import { usePromptMove } from "./move"
 
 export type PromptProps = {
   sessionID?: string
@@ -195,109 +190,12 @@ export function Prompt(props: PromptProps) {
   })
   const editorContextLabelState = createMemo(() => editor.labelState())
   const [auto, setAuto] = createSignal<AutocompleteRef>()
-  const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
-  const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
-  const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
-  const [warpNotice, setWarpNotice] = createSignal<string>()
+  const workspace = usePromptWorkspace(props.sessionID)
+  const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
   const [cursorVersion, setCursorVersion] = createSignal(0)
   const currentProviderLabel = createMemo(() => local.model.parsed().provider)
   const hasRightContent = createMemo(() => Boolean(props.right))
 
-  function selectWorkspace(selection: WorkspaceSelection | undefined) {
-    setWorkspaceSelection(selection)
-  }
-
-  function setCreatingWorkspace(creating: boolean) {
-    setWorkspaceCreating(creating)
-  }
-
-  function showWarpNotice(name: string) {
-    setWarpNotice(`Warped to ${name}`)
-    setTimeout(() => setWarpNotice(undefined), 4000)
-  }
-
-  async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
-    setCreatingWorkspace(true)
-    let result
-    try {
-      result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
-    } catch (err) {
-      selectWorkspace(undefined)
-      setCreatingWorkspace(false)
-      toast.show({
-        title: "Creating workspace failed",
-        message: errorMessage(err),
-        variant: "error",
-      })
-      return
-    }
-    if (result.error || !result.data) {
-      selectWorkspace(undefined)
-      setCreatingWorkspace(false)
-      toast.show({
-        title: "Creating workspace failed",
-        message: errorMessage(result.error ?? "no response"),
-        variant: "error",
-      })
-      return
-    }
-
-    await project.workspace.sync()
-    const workspace = result.data
-    selectWorkspace({
-      type: "existing",
-      workspaceID: workspace.id,
-      workspaceType: workspace.type,
-      workspaceName: workspace.name,
-    })
-    setCreatingWorkspace(false)
-    return workspace
-  }
-
-  async function warpSession(selection: WorkspaceSelection) {
-    if (!props.sessionID) {
-      selectWorkspace(selection)
-      dialog.clear()
-      if (selection.type === "new") void createWorkspace(selection)
-      return
-    }
-    const sourceWorkspaceID = project.workspace.current()
-    const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
-    if (copyChanges === undefined) return
-    selectWorkspace(selection)
-    dialog.clear()
-
-    const workspace =
-      selection.type === "none"
-        ? { id: null, name: "local project" }
-        : selection.type === "existing"
-          ? { id: selection.workspaceID, name: selection.workspaceName }
-          : await createWorkspace(selection)
-    if (!workspace) return
-
-    const warped = await warpWorkspaceSession({
-      dialog,
-      sdk,
-      sync,
-      project,
-      toast,
-      sourceWorkspaceID,
-      workspaceID: workspace.id,
-      sessionID: props.sessionID,
-      copyChanges,
-    })
-    if (warped) showWarpNotice(workspace.name)
-  }
-
-  createEffect(() => {
-    if (!workspaceCreating()) {
-      setWorkspaceCreatingDots(3)
-      return
-    }
-    const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
-    onCleanup(() => clearInterval(timer))
-  })
-
   function promptModelWarning() {
     toast.show({
       variant: "warning",
@@ -623,16 +521,17 @@ export function Prompt(props: PromptProps) {
         enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
         slashName: "warp",
         run: () => {
-          void openWorkspaceSelect({
-            dialog,
-            sdk,
-            sync,
-            project,
-            toast,
-            onSelect: (selection) => {
-              void warpSession(selection)
-            },
-          })
+          workspace.open()
+        },
+      },
+      {
+        title: "Move session",
+        desc: "Move the session to another project directory",
+        name: "session.move",
+        category: "Session",
+        slashName: "move",
+        run: () => {
+          move.open()
         },
       },
     ].map((entry) => ({
@@ -656,6 +555,7 @@ export function Prompt(props: PromptProps) {
       "prompt.stash.list",
       "session.interrupt",
       "workspace.set",
+      "session.move",
     ]),
   }))
 
@@ -1025,7 +925,7 @@ export function Prompt(props: PromptProps) {
   }
 
   async function submitInner() {
-    setWarpNotice(undefined)
+    workspace.clearNotice()
 
     // IME: double-defer may fire before onContentChange flushes the last
     // composed character (e.g. Korean hangul) to the store, so read
@@ -1035,7 +935,7 @@ export function Prompt(props: PromptProps) {
       syncExtmarksWithPromptParts()
     }
     if (props.disabled) return false
-    if (workspaceCreating()) return false
+    if (workspace.creating() || move.creating()) return false
     if (auto()?.visible) return false
     if (!store.prompt.input) return false
     const agent = local.agent.current()
@@ -1058,16 +958,7 @@ export function Prompt(props: PromptProps) {
       dialog.replace(() => (
         <DialogWorkspaceUnavailable
           onRestore={() => {
-            void openWorkspaceSelect({
-              dialog,
-              sdk,
-              sync,
-              project,
-              toast,
-              onSelect: (selection) => {
-                void warpSession(selection)
-              },
-            })
+            workspace.open()
             return false
           }}
         />
@@ -1077,16 +968,22 @@ export function Prompt(props: PromptProps) {
 
     const variant = local.model.variant.current()
     let sessionID = props.sessionID
+    let finishMoveProgress = false
     if (sessionID == null) {
-      const workspace = workspaceSelection()
+      const selectedWorkspace = workspace.selection()
       const workspaceID = iife(() => {
-        if (!workspace) return undefined
-        if (workspace.type === "none") return undefined
-        if (workspace.type === "existing") return workspace.workspaceID
+        if (!selectedWorkspace) return undefined
+        if (selectedWorkspace.type === "none") return undefined
+        if (selectedWorkspace.type === "existing") return selectedWorkspace.workspaceID
         return undefined
       })
 
+      const directory = await move.getDirectory(store.prompt.input)
+      if (move.pending() && !directory) return false
+      finishMoveProgress = Boolean(move.progress())
+
       const res = await sdk.client.session.create({
+        directory,
         workspace: workspaceID,
         agent: agent.name,
         model: {
@@ -1097,6 +994,7 @@ export function Prompt(props: PromptProps) {
       })
 
       if (res.error) {
+        if (finishMoveProgress) move.finishSubmit()
         console.log("Creating a session failed:", res.error)
 
         toast.show({
@@ -1146,6 +1044,7 @@ export function Prompt(props: PromptProps) {
         : []
 
     if (store.mode === "shell") {
+      move.startSubmit()
       void sdk.client.session.shell({
         sessionID,
         agent: agent.name,
@@ -1164,6 +1063,7 @@ export function Prompt(props: PromptProps) {
         return sync.data.command.some((x) => x.name === command)
       })
     ) {
+      move.startSubmit()
       // Parse command from first line, preserve multi-line content in arguments
       const firstLineEnd = inputText.indexOf("\n")
       const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
@@ -1187,6 +1087,7 @@ export function Prompt(props: PromptProps) {
           })),
       })
     } else {
+      move.startSubmit()
       sdk.client.session
         .prompt({
           sessionID,
@@ -1231,6 +1132,7 @@ export function Prompt(props: PromptProps) {
       }, 50)
     }
     input.clear()
+    if (finishMoveProgress) move.finishSubmit()
     return true
   }
   const exit = useExit()
@@ -1427,29 +1329,6 @@ export function Prompt(props: PromptProps) {
     return `Ask anything... "${list()[store.placeholder % list().length]}"`
   })
 
-  const workspaceLabel = createMemo<
-    | { type: "new"; workspaceType: string }
-    | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
-    | undefined
-  >(() => {
-    const selected = workspaceSelection()
-    if (!selected) return
-    if (selected.type === "none") return
-    if (props.sessionID && !workspaceCreating()) return
-    if (selected.type === "new") {
-      return {
-        type: "new",
-        workspaceType: selected.workspaceType,
-      }
-    }
-    return {
-      type: "existing",
-      workspaceType: selected.workspaceType,
-      workspaceName: selected.workspaceName,
-      status: selected.type === "existing" ? "connected" : undefined,
-    }
-  })
-
   const spinnerDef = createMemo(() => {
     const agent =
       status().type !== "idle"
@@ -1474,6 +1353,7 @@ export function Prompt(props: PromptProps) {
     }
   })
   const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
+  const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
 
   return (
     <>
@@ -1717,25 +1597,25 @@ export function Prompt(props: PromptProps) {
                 </text>
               </box>
             </Match>
-            <Match when={warpNotice()}>
+            <Match when={workspace.notice()}>
               {(notice) => (
                 <box paddingLeft={3}>
                   <text fg={theme.accent}>{notice()}</text>
                 </box>
               )}
             </Match>
-            <Match when={workspaceLabel()}>
-              {(workspace) => (
+            <Match when={workspace.label()}>
+              {(label) => (
                 <box paddingLeft={3} flexDirection="row" gap={1}>
-                  <Show when={workspaceCreating()}>
+                  <Show when={workspace.creating()}>
                     <Spinner color={theme.accent} />
                   </Show>
-                  <text fg={workspaceCreating() ? theme.accent : theme.text}>
+                  <text fg={workspace.creating() ? theme.accent : theme.text}>
                     {(() => {
-                      const item = workspace()
+                      const item = label()
                       if (item.type === "new") {
-                        if (workspaceCreating())
-                          return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
+                        if (workspace.creating())
+                          return `Creating ${item.workspaceType}${".".repeat(workspace.creatingDots())}`
                         return (
                           <>
                             Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
@@ -1752,6 +1632,21 @@ export function Prompt(props: PromptProps) {
                 </box>
               )}
             </Match>
+            <Match when={move.progress()}>
+              {(progress) => (
+                <box paddingLeft={3}>
+                  <Spinner color={theme.accent}>
+                    {progress()}
+                    <span style={{ fg: theme.textMuted }}>{".".repeat(move.creatingDots())}</span>
+                  </Spinner>
+                </box>
+              )}
+            </Match>
+            <Match when={move.pendingNew()}>
+              <box paddingLeft={3}>
+                <text fg={theme.accent}>(new working copy)</text>
+              </box>
+            </Match>
             <Match when={true}>{props.hint ?? <text />}</Match>
           </Switch>
           <Show when={status().type !== "retry"}>

+ 158 - 0
packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx

@@ -0,0 +1,158 @@
+import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
+import path from "path"
+import { Global } from "@opencode-ai/core/global"
+import { errorMessage } from "@/util/error"
+import { useDialog } from "@tui/ui/dialog"
+import { useSDK } from "@tui/context/sdk"
+import { useSync } from "@tui/context/sync"
+import { useToast } from "@tui/ui/toast"
+import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
+import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
+import { useHomeSessionDestination } from "../../routes/home/session-destination"
+
+export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
+  const dialog = useDialog()
+  const sdk = useSDK()
+  const sync = useSync()
+  const toast = useToast()
+  const homeDestination = useHomeSessionDestination()
+  const [creating, setCreating] = createSignal(false)
+  const [creatingDots, setCreatingDots] = createSignal(3)
+  const [progress, setProgress] = createSignal<string>()
+
+  async function create(context?: string) {
+    const projectID = input.projectID()
+    if (!projectID) return
+    setCreating(true)
+    setProgress("Creating copy")
+    try {
+      const result = await sdk.client.experimental.projectCopy.create(
+        {
+          projectID,
+          strategy: "git_worktree",
+          directory: path.join(Global.Path.data, "worktree", projectID.slice(0, 6)),
+          context,
+        },
+        { throwOnError: true },
+      )
+      const directory = result.data?.directory
+      if (!directory) throw new Error("No project copy directory returned")
+      setProgress("Creating session")
+      return directory
+    } catch (err) {
+      homeDestination?.clear()
+      setProgress(undefined)
+      setCreating(false)
+      toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
+      return
+    }
+  }
+
+  function open() {
+    const projectID = input.projectID()
+    if (!projectID) return
+    dialog.replace(() => (
+      <DialogMoveSession
+        projectID={projectID}
+        onSelect={(selection) => {
+          const sessionID = input.sessionID()
+          if (!sessionID) {
+            homeDestination?.setDestination(selection)
+            dialog.clear()
+            return
+          }
+          void moveExistingSession(sessionID, selection)
+        }}
+      />
+    ))
+  }
+
+  function sessionContext(sessionID: string) {
+    const session = sync.session.get(sessionID)
+    const messages = (sync.data.message[sessionID] ?? [])
+      .slice(-6)
+      .map((message) =>
+        [
+          message.role + ":",
+          ...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])),
+        ].join(" "),
+      )
+    return [session?.title, ...messages].filter(Boolean).join("\n") || undefined
+  }
+
+  async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
+    const session = sync.session.get(sessionID)
+    const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined)
+    const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
+    if (!choice) return
+    dialog.clear()
+    const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory
+    if (!directory) {
+      setProgress(undefined)
+      dialog.clear()
+      return
+    }
+    setProgress("Moving session")
+    await sdk.client.experimental.controlPlane
+      .moveSession(
+        {
+          sessionID,
+          destination: { directory },
+          moveChanges: choice === "yes",
+        },
+        { throwOnError: true },
+      )
+      .then(() => dialog.clear())
+      .catch((error) => {
+        toast.error(error)
+        dialog.clear()
+      })
+      .finally(() => {
+        setProgress(undefined)
+        setCreating(false)
+      })
+  }
+
+  const pending = createMemo(() => Boolean(homeDestination?.destination()))
+  const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new")
+
+  async function getDirectory(context?: string) {
+    const value = homeDestination?.destination()
+    if (!value) return
+    if (value.type === "directory") {
+      return value.directory
+    }
+    return await create(context)
+  }
+
+  function startSubmit() {
+    if (progress()) setProgress("Submitting prompt")
+  }
+
+  function finishSubmit() {
+    homeDestination?.clear()
+    setProgress(undefined)
+    setCreating(false)
+  }
+
+  createEffect(() => {
+    if (!creating()) {
+      setCreatingDots(3)
+      return
+    }
+    const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
+    onCleanup(() => clearInterval(timer))
+  })
+
+  return {
+    creating,
+    creatingDots,
+    finishSubmit,
+    getDirectory,
+    open,
+    pending,
+    pendingNew,
+    progress,
+    startSubmit,
+  }
+}

+ 137 - 0
packages/opencode/src/cli/cmd/tui/component/prompt/workspace.tsx

@@ -0,0 +1,137 @@
+import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
+import { useDialog } from "@tui/ui/dialog"
+import { useSDK } from "@tui/context/sdk"
+import { useProject } from "@tui/context/project"
+import { useSync } from "@tui/context/sync"
+import { useToast } from "@tui/ui/toast"
+import { errorMessage } from "@/util/error"
+import {
+  confirmWorkspaceFileChanges,
+  openWorkspaceSelect,
+  warpWorkspaceSession,
+  type WorkspaceSelection,
+} from "../dialog-workspace-create"
+import type { WorkspaceStatus } from "../workspace-label"
+
+export function usePromptWorkspace(sessionID?: string) {
+  const dialog = useDialog()
+  const sdk = useSDK()
+  const project = useProject()
+  const sync = useSync()
+  const toast = useToast()
+  const [selection, setSelection] = createSignal<WorkspaceSelection>()
+  const [creating, setCreating] = createSignal(false)
+  const [creatingDots, setCreatingDots] = createSignal(3)
+  const [notice, setNotice] = createSignal<string>()
+
+  async function create(selection: Extract<WorkspaceSelection, { type: "new" }>) {
+    setCreating(true)
+    let result
+    try {
+      result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
+    } catch (err) {
+      setSelection(undefined)
+      setCreating(false)
+      toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
+      return
+    }
+    if (result.error || !result.data) {
+      setSelection(undefined)
+      setCreating(false)
+      toast.show({
+        title: "Creating workspace failed",
+        message: errorMessage(result.error ?? "no response"),
+        variant: "error",
+      })
+      return
+    }
+
+    await project.workspace.sync()
+    const workspace = result.data
+    setSelection({
+      type: "existing",
+      workspaceID: workspace.id,
+      workspaceType: workspace.type,
+      workspaceName: workspace.name,
+    })
+    setCreating(false)
+    return workspace
+  }
+
+  async function warp(selection: WorkspaceSelection) {
+    if (!sessionID) {
+      setSelection(selection)
+      dialog.clear()
+      if (selection.type === "new") void create(selection)
+      return
+    }
+    const sourceWorkspaceID = project.workspace.current()
+    const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
+    if (copyChanges === undefined) return
+    setSelection(selection)
+    dialog.clear()
+
+    const workspace =
+      selection.type === "none"
+        ? { id: null, name: "local project" }
+        : selection.type === "existing"
+          ? { id: selection.workspaceID, name: selection.workspaceName }
+          : await create(selection)
+    if (!workspace) return
+
+    const warped = await warpWorkspaceSession({
+      dialog,
+      sdk,
+      sync,
+      project,
+      toast,
+      sourceWorkspaceID,
+      workspaceID: workspace.id,
+      sessionID,
+      copyChanges,
+    })
+    if (warped) showNotice(workspace.name)
+  }
+
+  function showNotice(name: string) {
+    setNotice(`Warped to ${name}`)
+    setTimeout(() => setNotice(undefined), 4000)
+  }
+
+  function clearNotice() {
+    setNotice(undefined)
+  }
+
+  function open() {
+    void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp })
+  }
+
+  createEffect(() => {
+    if (!creating()) {
+      setCreatingDots(3)
+      return
+    }
+    const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
+    onCleanup(() => clearInterval(timer))
+  })
+
+  const label = createMemo<
+    | { type: "new"; workspaceType: string }
+    | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
+    | undefined
+  >(() => {
+    const selected = selection()
+    if (!selected) return
+    if (selected.type === "none") return
+    if (sessionID && !creating()) return
+    if (selected.type === "new") return { type: "new", workspaceType: selected.workspaceType }
+    return {
+      type: "existing",
+      workspaceType: selected.workspaceType,
+      workspaceName: selected.workspaceName,
+      status: selected.type === "existing" ? "connected" : undefined,
+    }
+  })
+
+  return { selection, creating, creatingDots, notice, label, open, warp, clearNotice }
+}

+ 1 - 0
packages/opencode/src/cli/cmd/tui/config/keybind.ts

@@ -204,6 +204,7 @@ export const Definitions = {
   "dialog.select.submit": keybind("return", "Submit selected dialog item"),
   "dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
   "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
+  "dialog.move_session.new": keybind("ctrl+w", "New project copy"),
   "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
   "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
   "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),

+ 16 - 0
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -253,6 +253,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           break
         }
 
+        case "session.next.moved": {
+          const result = Binary.search(store.session, event.properties.sessionID, (s) => s.id)
+          if (!result.found) break
+          setStore(
+            "session",
+            result.index,
+            produce((session) => {
+              session.directory = event.properties.location.directory
+              session.path = event.properties.subdirectory
+              session.workspaceID = event.properties.location.workspaceID
+              session.time.updated = event.properties.timestamp
+            }),
+          )
+          break
+        }
+
         case "session.status": {
           setStore("session_status", event.properties.sessionID, event.properties.status)
           break

+ 6 - 1
packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx

@@ -2,12 +2,17 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
 import type { InternalTuiPlugin } from "../../plugin/internal"
 import { createMemo, Match, Show, Switch } from "solid-js"
 import { Global } from "@opencode-ai/core/global"
+import { useHomeSessionDestination } from "../../routes/home/session-destination"
 
 const id = "internal:home-footer"
 
 function Directory(props: { api: TuiPluginApi }) {
   const theme = () => props.api.theme.current
+  const destination = useHomeSessionDestination()
   const dir = createMemo(() => {
+    const selected = destination?.destination()
+    if (selected?.type === "new") return
+    if (selected?.type === "directory") return selected.directory.replace(Global.Path.home, "~")
     const dir = props.api.state.path.directory || process.cwd()
     const out = dir.replace(Global.Path.home, "~")
     const branch = props.api.state.vcs?.branch
@@ -15,7 +20,7 @@ function Directory(props: { api: TuiPluginApi }) {
     return out
   })
 
-  return <text fg={theme().textMuted}>{dir()}</text>
+  return <Show when={dir()}>{(value) => <text fg={theme().textMuted}>{value()}</text>}</Show>
 }
 
 function Mcp(props: { api: TuiPluginApi }) {

+ 7 - 5
packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/footer.tsx

@@ -5,7 +5,7 @@ import { Global } from "@opencode-ai/core/global"
 
 const id = "internal:sidebar-footer"
 
-function View(props: { api: TuiPluginApi }) {
+function View(props: { api: TuiPluginApi; sessionID: string }) {
   const theme = () => props.api.theme.current
   const has = createMemo(() =>
     props.api.state.provider.some(
@@ -15,9 +15,11 @@ function View(props: { api: TuiPluginApi }) {
   const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
   const show = createMemo(() => !has() && !done())
   const path = createMemo(() => {
-    const dir = props.api.state.path.directory || process.cwd()
+    const session = props.api.state.session.get(props.sessionID)
+    const dir = session?.directory || props.api.state.path.directory || process.cwd()
     const out = dir.replace(Global.Path.home, "~")
-    const text = props.api.state.vcs?.branch ? out + ":" + props.api.state.vcs.branch : out
+    const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
+    const text = branch ? out + ":" + branch : out
     const list = text.split("/")
     return {
       parent: list.slice(0, -1).join("/"),
@@ -79,8 +81,8 @@ const tui: TuiPlugin = async (api) => {
   api.slots.register({
     order: 100,
     slots: {
-      sidebar_footer() {
-        return <View api={api} />
+      sidebar_footer(_ctx, props) {
+        return <View api={api} sessionID={props.session_id} />
       },
     },
   })

+ 3 - 2
packages/opencode/src/cli/cmd/tui/routes/home.tsx

@@ -11,6 +11,7 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
 import { useEditorContext } from "@tui/context/editor"
 import { useTerminalDimensions } from "@opentui/solid"
 import { useTuiConfig } from "../context/tui-config"
+import { HomeSessionDestinationProvider } from "./home/session-destination"
 
 let once = false
 const placeholder = {
@@ -66,7 +67,7 @@ export function Home() {
   })
 
   return (
-    <>
+    <HomeSessionDestinationProvider>
       <box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
         <box flexGrow={1} minHeight={0} />
         <box height={4} minHeight={0} flexShrink={1} />
@@ -88,6 +89,6 @@ export function Home() {
       <box width="100%" flexShrink={0}>
         <TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
       </box>
-    </>
+    </HomeSessionDestinationProvider>
   )
 }

+ 26 - 0
packages/opencode/src/cli/cmd/tui/routes/home/session-destination.tsx

@@ -0,0 +1,26 @@
+import { createContext, createSignal, useContext, type Accessor, type ParentProps, type Setter } from "solid-js"
+
+export type HomeSessionDestination = { type: "directory"; directory: string } | { type: "new" }
+
+type Context = {
+  destination: Accessor<HomeSessionDestination | undefined>
+  setDestination: Setter<HomeSessionDestination | undefined>
+  clear: () => void
+}
+
+const HomeSessionDestinationContext = createContext<Context>()
+
+export function HomeSessionDestinationProvider(props: ParentProps) {
+  const [destination, setDestination] = createSignal<HomeSessionDestination>()
+  return (
+    <HomeSessionDestinationContext.Provider
+      value={{ destination, setDestination, clear: () => setDestination(undefined) }}
+    >
+      {props.children}
+    </HomeSessionDestinationContext.Provider>
+  )
+}
+
+export function useHomeSessionDestination() {
+  return useContext(HomeSessionDestinationContext)
+}

+ 18 - 2
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -23,6 +23,7 @@ import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
 export interface DialogSelectProps<T> {
   title: string
   placeholder?: string
+  footer?: JSX.Element
   options: DialogSelectOption<T>[]
   flat?: boolean
   ref?: (ref: DialogSelectRef<T>) => void
@@ -49,10 +50,13 @@ export interface DialogSelectProps<T> {
 
 export interface DialogSelectOption<T = any> {
   title: string
+  titleView?: JSX.Element
   value: T
   description?: string
   details?: string[]
   footer?: JSX.Element | string
+  titleWidth?: number
+  truncateTitle?: boolean | "left"
   category?: string
   categoryView?: JSX.Element
   disabled?: boolean
@@ -472,7 +476,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                             </Show>
                             <Option
                               title={option.title}
+                              titleView={option.titleView}
                               footer={flatten() ? (option.category ?? option.footer) : option.footer}
+                              titleWidth={option.titleWidth}
+                              truncateTitle={option.truncateTitle}
                               description={option.description !== category ? option.description : undefined}
                               active={active()}
                               current={current()}
@@ -498,7 +505,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           </scrollbox>
         </Show>
       </box>
-      <Show when={visibleActions().length} fallback={<box flexShrink={0} />}>
+      <Show when={props.footer || visibleActions().length} fallback={<box flexShrink={0} />}>
         <box
           paddingRight={2}
           paddingLeft={4}
@@ -508,6 +515,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           paddingTop={1}
         >
           <box flexDirection="row" gap={2}>
+            {props.footer}
             <For each={left()}>
               {(item) => (
                 <text>
@@ -539,10 +547,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
 
 function Option(props: {
   title: string
+  titleView?: JSX.Element
   description?: string
   active?: boolean
   current?: boolean
   footer?: JSX.Element | string
+  titleWidth?: number
+  truncateTitle?: boolean | "left"
   gutter?: () => JSX.Element
   onMouseOver?: () => void
 }) {
@@ -569,7 +580,12 @@ function Option(props: {
         wrapMode="none"
         paddingLeft={3}
       >
-        {Locale.truncate(props.title, 61)}
+        {props.titleView ??
+          (props.truncateTitle === false
+            ? props.title
+            : props.truncateTitle === "left"
+              ? Locale.truncateLeft(props.title, props.titleWidth ?? 61)
+              : Locale.truncate(props.title, props.titleWidth ?? 61))}
         <Show when={props.description}>
           <span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
         </Show>

+ 2 - 0
packages/opencode/src/server/routes/instance/httpapi/api.ts

@@ -5,6 +5,7 @@ import { InstanceDisposed } from "@/server/event"
 import { Question } from "@/question"
 import { ConfigApi } from "./groups/config"
 import { ControlApi } from "./groups/control"
+import { ControlPlaneApi } from "./groups/control-plane"
 import { EventApi } from "./groups/event"
 import { ExperimentalApi } from "./groups/experimental"
 import { FileApi } from "./groups/file"
@@ -42,6 +43,7 @@ const EventSchema = Schema.Union([
 
 export const RootHttpApi = HttpApi.make("opencode-root")
   .addHttpApi(ControlApi)
+  .addHttpApi(ControlPlaneApi)
   .addHttpApi(GlobalApi)
   .middleware(SchemaErrorMiddleware)
   .middleware(Authorization)

+ 35 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/control-plane.ts

@@ -0,0 +1,35 @@
+import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
+import { Schema } from "effect"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import { described } from "./metadata"
+
+const root = "/experimental/control-plane"
+export const MoveSessionPayload = Schema.Struct({ ...MoveSession.Input.fields })
+
+export class ApiMoveSessionError extends Schema.ErrorClass<ApiMoveSessionError>("MoveSessionError")(
+  {
+    name: Schema.Literal("MoveSessionError"),
+    data: Schema.Struct({
+      message: Schema.String,
+    }),
+  },
+  { httpApiStatus: 400 },
+) {}
+
+export const ControlPlaneApi = HttpApi.make("controlPlane").add(
+  HttpApiGroup.make("controlPlane")
+    .add(
+      HttpApiEndpoint.post("moveSession", `${root}/move-session`, {
+        payload: MoveSessionPayload,
+        success: described(HttpApiSchema.NoContent, "Session moved"),
+        error: ApiMoveSessionError,
+      }).annotateMerge(
+        OpenApi.annotations({
+          identifier: "experimental.controlPlane.moveSession",
+          summary: "Move session",
+          description: "Move a session to another project directory, optionally transferring local changes.",
+        }),
+      ),
+    )
+    .annotateMerge(OpenApi.annotations({ title: "controlPlane", description: "Control-plane orchestration routes." })),
+)

+ 25 - 6
packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts

@@ -1,31 +1,50 @@
 import { ProjectCopy } from "@opencode-ai/core/project/copy"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { Schema } from "effect"
-import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
-import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
+import {
+  WorkspaceRoutingMiddleware,
+  WorkspaceRoutingQuery,
+  WorkspaceRoutingQueryFields,
+} from "../middleware/workspace-routing"
 import { described } from "./metadata"
 
 const root = "/experimental/project/:projectID/copy"
+const CreateQuery = Schema.Struct({
+  workspace: WorkspaceRoutingQueryFields.workspace,
+})
 
 export const CreatePayload = Schema.Struct({
   strategy: ProjectCopy.StrategyID,
   directory: ProjectCopy.CreateInput.fields.directory,
+  name: ProjectCopy.CreateInput.fields.name,
+  context: ProjectCopy.CreateInput.fields.context,
 })
 export const RemovePayload = Schema.Struct({
   directory: ProjectCopy.RemoveInput.fields.directory,
 })
 
+export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>("ProjectCopyError")(
+  {
+    name: Schema.Literal("ProjectCopyError"),
+    data: Schema.Struct({
+      message: Schema.String,
+    }),
+  },
+  { httpApiStatus: 400 },
+) {}
+
 export const ProjectCopyApi = HttpApi.make("projectCopy").add(
   HttpApiGroup.make("projectCopy")
     .add(
       HttpApiEndpoint.post("create", root, {
         params: { projectID: ProjectV2.ID },
-        query: WorkspaceRoutingQuery,
+        query: CreateQuery,
         payload: CreatePayload,
         success: described(ProjectCopy.Copy, "Project copy created"),
-        error: HttpApiError.BadRequest,
+        error: ApiProjectCopyError,
       }).annotateMerge(
         OpenApi.annotations({
           identifier: "experimental.projectCopy.create",
@@ -38,7 +57,7 @@ export const ProjectCopyApi = HttpApi.make("projectCopy").add(
         query: WorkspaceRoutingQuery,
         payload: RemovePayload,
         success: described(HttpApiSchema.NoContent, "Project copy removed"),
-        error: HttpApiError.BadRequest,
+        error: ApiProjectCopyError,
       }).annotateMerge(
         OpenApi.annotations({
           identifier: "experimental.projectCopy.remove",
@@ -51,7 +70,7 @@ export const ProjectCopyApi = HttpApi.make("projectCopy").add(
         query: WorkspaceRoutingQuery,
         payload: HttpApiSchema.NoContent,
         success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
-        error: HttpApiError.BadRequest,
+        error: ApiProjectCopyError,
       }).annotateMerge(
         OpenApi.annotations({
           identifier: "experimental.projectCopy.refresh",

+ 35 - 0
packages/opencode/src/server/routes/instance/httpapi/handlers/control-plane.ts

@@ -0,0 +1,35 @@
+import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
+import { SessionV2 } from "@opencode-ai/core/session"
+import { Effect } from "effect"
+import { HttpApiBuilder } from "effect/unstable/httpapi"
+import { RootHttpApi } from "../api"
+import { ApiMoveSessionError, MoveSessionPayload } from "../groups/control-plane"
+
+export const controlPlaneHandlers = HttpApiBuilder.group(RootHttpApi, "controlPlane", (handlers) =>
+  Effect.gen(function* () {
+    const service = yield* MoveSession.Service
+
+    const moveSession = Effect.fn("ControlPlaneHttpApi.moveSession")(function* (ctx: {
+      payload: typeof MoveSessionPayload.Type
+    }) {
+      yield* service.moveSession(ctx.payload).pipe(
+        Effect.mapError(
+          (error) =>
+            new ApiMoveSessionError({
+              name: "MoveSessionError",
+              data: { message: message(error) },
+            }),
+        ),
+      )
+    })
+
+    return handlers.handle("moveSession", moveSession)
+  }),
+)
+
+function message(error: MoveSession.Error) {
+  if (error instanceof SessionV2.NotFoundError) return `Session not found: ${error.sessionID}`
+  if (error instanceof MoveSession.DestinationProjectMismatchError)
+    return "Destination directory belongs to another project"
+  return error.message
+}

+ 103 - 4
packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts

@@ -2,26 +2,104 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { InstanceState } from "@/effect/instance-state"
-import { Effect } from "effect"
-import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
+import { Effect, Stream } from "effect"
+import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../api"
-import { CreatePayload, RemovePayload } from "../groups/project-copy"
+import { ApiProjectCopyError, CreatePayload, RemovePayload } from "../groups/project-copy"
+import { Agent } from "@/agent/agent"
+import { LLM } from "@/session/llm"
+import { LLMEvent } from "@opencode-ai/llm"
+import { MessageID, SessionID } from "@/session/schema"
+import { Provider } from "@/provider/provider"
+import { Slug } from "@opencode-ai/core/util/slug"
+
+const FALLBACK_AGENT: Agent.Info = {
+  name: "title",
+  mode: "primary" as const,
+  permission: [],
+  options: {},
+  native: true,
+  prompt: "",
+}
 
 function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
-  return effect.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
+  return effect.pipe(
+    Effect.mapError(
+      (error) =>
+        new ApiProjectCopyError({
+          name: "ProjectCopyError",
+          data: { message: message(error) },
+        }),
+    ),
+  )
 }
 
 export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
   Effect.gen(function* () {
+    const llm = yield* LLM.Service
+    const agent = yield* Agent.Service
+    const provider = yield* Provider.Service
     const service = yield* ProjectCopy.Service
 
+    const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) {
+      const text = context?.trim()
+      if (!text) return Slug.create()
+      const [titleAgent, fallback] = yield* Effect.all(
+        [
+          agent.get("title").pipe(Effect.catch(() => Effect.succeed(FALLBACK_AGENT))),
+          provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined))),
+        ],
+        { concurrency: 2 },
+      )
+      if (!fallback) return Slug.create()
+      const model = titleAgent.model
+        ? yield* provider.getModel(titleAgent.model.providerID, titleAgent.model.modelID)
+        : ((yield* provider.getSmallModel(fallback.providerID)) ??
+          (yield* provider.getModel(fallback.providerID, fallback.modelID)))
+      const sessionID = SessionID.descending()
+      const result = yield* llm
+        .stream({
+          agent: titleAgent,
+          user: {
+            id: MessageID.ascending(),
+            sessionID,
+            role: "user",
+            time: { created: Date.now() },
+            agent: titleAgent.name,
+            model: { providerID: model.providerID, modelID: model.id },
+          },
+          system: [],
+          small: true,
+          tools: {},
+          model,
+          sessionID,
+          retries: 2,
+          messages: [
+            {
+              role: "user",
+              content: `Generate a short filesystem-safe name for a project working copy based on this context:\n${text}`,
+            },
+          ],
+        })
+        .pipe(
+          Stream.filter(LLMEvent.is.textDelta),
+          Stream.map((event) => event.text),
+          Stream.mkString,
+        )
+      return slugify(result) || Slug.create()
+    })
+
     const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
       params: { projectID: ProjectV2.ID }
       payload: typeof CreatePayload.Type
     }) {
+      const name =
+        ctx.payload.name ??
+        (yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create()))))
       return yield* badRequest(
         service.create({
           ...ctx.payload,
+          name,
           projectID: ctx.params.projectID,
           sourceDirectory: AbsolutePath.make((yield* InstanceState.context).worktree),
         }),
@@ -51,3 +129,24 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
     return handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh)
   }),
 )
+
+function slugify(input: string) {
+  return input
+    .trim()
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, "-")
+    .replace(/^-+/, "")
+    .replace(/-+$/, "")
+}
+
+function message(error: ProjectCopy.Error) {
+  if (error instanceof ProjectCopy.SourceDirectoryNotFoundError)
+    return `Project copy source not found: ${error.directory}`
+  if (error instanceof ProjectCopy.DestinationExistsError)
+    return `Project copy destination already exists: ${error.directory}`
+  if (error instanceof ProjectCopy.DirectoryUnavailableError)
+    return `Project copy directory unavailable: ${error.directory}`
+  if (error instanceof ProjectCopy.StrategyNotFoundError)
+    return `Project copy strategy not found for: ${error.directory}`
+  return error.message
+}

+ 6 - 1
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -28,6 +28,7 @@ import { Plugin } from "@/plugin"
 import { Project } from "@/project/project"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { ProjectCopy } from "@opencode-ai/core/project/copy"
+import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
 import { ProviderAuth } from "@/provider/auth"
 import { ModelsDev } from "@opencode-ai/core/models-dev"
 import { Provider } from "@/provider/provider"
@@ -35,6 +36,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
 import { Question } from "@/question"
 import { Session } from "@/session/session"
 import { SessionCompaction } from "@/session/compaction"
+import { LLM } from "@/session/llm"
 import { SessionPrompt } from "@/session/prompt"
 import { SessionRevert } from "@/session/revert"
 import { SessionRunState } from "@/session/run-state"
@@ -70,6 +72,7 @@ import { PtyConnectApi } from "./groups/pty"
 import { eventHandlers } from "./handlers/event"
 import { configHandlers } from "./handlers/config"
 import { controlHandlers } from "./handlers/control"
+import { controlPlaneHandlers } from "./handlers/control-plane"
 import { experimentalHandlers } from "./handlers/experimental"
 import { fileHandlers } from "./handlers/file"
 import { globalHandlers } from "./handlers/global"
@@ -120,7 +123,7 @@ const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provi
 const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
 const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
 const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
-  Layer.provide([controlHandlers, globalHandlers]),
+  Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
   Layer.provide(schemaErrorLayer),
   Layer.provide(httpApiAuthLayer),
 )
@@ -215,6 +218,7 @@ export function createRoutes(
       Config.defaultLayer,
       Format.defaultLayer,
       LSP.defaultLayer,
+      LLM.defaultLayer,
       Installation.defaultLayer,
       MCP.defaultLayer,
       ModelsDev.defaultLayer,
@@ -223,6 +227,7 @@ export function createRoutes(
       Project.defaultLayer,
       ProjectV2.defaultLayer,
       ProjectCopy.defaultLayer,
+      MoveSession.defaultLayer,
       ProviderAuth.defaultLayer,
       Provider.defaultLayer,
       PtyTicket.defaultLayer,

+ 63 - 0
packages/opencode/test/server/httpapi-control-plane.test.ts

@@ -0,0 +1,63 @@
+import { NodeHttpServer } from "@effect/platform-node"
+import { describe, expect } from "bun:test"
+import { Context, Effect, Layer, Option, Ref } from "effect"
+import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
+import { HttpApiBuilder } from "effect/unstable/httpapi"
+import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { SessionV2 } from "@opencode-ai/core/session"
+import { Auth } from "../../src/auth"
+import { Config } from "../../src/config/config"
+import { Installation } from "../../src/installation"
+import { ServerAuth } from "../../src/server/auth"
+import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
+import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
+import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
+import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
+import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
+import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
+import { testEffect } from "../lib/effect"
+
+const input = MoveSession.Input.make({
+  sessionID: SessionV2.ID.make("ses_move"),
+  destination: { directory: AbsolutePath.make("/destination") },
+  moveChanges: true,
+})
+const called = Ref.makeUnsafe<MoveSession.Input | undefined>(undefined)
+
+const apiLayer = HttpRouter.serve(
+  HttpApiBuilder.layer(RootHttpApi).pipe(
+    Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
+    Layer.provide([authorizationLayer, schemaErrorLayer]),
+    // Raw HttpApi routes expose an opaque handler context at the request boundary.
+    // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
+    HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
+  ),
+  { disableListenLog: true, disableLogger: true },
+).pipe(
+  Layer.provideMerge(NodeHttpServer.layerTest),
+  Layer.provide(Layer.mock(Auth.Service)({})),
+  Layer.provide(Layer.mock(Config.Service)({})),
+  Layer.provide(Layer.mock(Installation.Service)({})),
+  Layer.provide(
+    Layer.mock(MoveSession.Service)({
+      moveSession: (value) => Ref.set(called, value),
+    }),
+  ),
+  Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })),
+)
+const it = testEffect(apiLayer)
+
+describe("control-plane HttpApi", () => {
+  it.live("moves a session through the root control-plane route", () =>
+    Effect.gen(function* () {
+      const response = yield* HttpClientRequest.post("/experimental/control-plane/move-session").pipe(
+        HttpClientRequest.setBody(HttpBody.jsonUnsafe(input)),
+        HttpClient.execute,
+      )
+
+      expect(response.status).toBe(204)
+      expect(yield* Ref.get(called)).toEqual(input)
+    }),
+  )
+})

+ 8 - 0
packages/opencode/test/server/httpapi-exercise/index.ts

@@ -506,6 +506,14 @@ const scenarios: Scenario[] = [
       body: {},
     }))
     .status(400),
+  http.protected
+    .post("/experimental/control-plane/move-session", "experimental.controlPlane.moveSession")
+    .global()
+    .at(() => ({
+      path: "/experimental/control-plane/move-session",
+      body: {},
+    }))
+    .status(400),
   http.protected
     .get("/experimental/tool", "tool.list")
     .at((ctx) => ({

+ 4 - 1
packages/opencode/test/server/httpapi-global.test.ts

@@ -6,10 +6,12 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { Auth } from "../../src/auth"
 import { Config } from "../../src/config/config"
 import { Installation } from "../../src/installation"
+import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
 import { ServerAuth } from "../../src/server/auth"
 import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
 import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
 import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
+import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
 import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
 import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
 import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
@@ -17,7 +19,7 @@ import { testEffect } from "../lib/effect"
 
 const apiLayer = HttpRouter.serve(
   HttpApiBuilder.layer(RootHttpApi).pipe(
-    Layer.provide([controlHandlers, globalHandlers]),
+    Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
     Layer.provide([authorizationLayer, schemaErrorLayer]),
     // Raw HttpApi routes expose an opaque handler context at the request boundary.
     // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
@@ -28,6 +30,7 @@ const apiLayer = HttpRouter.serve(
   Layer.provideMerge(NodeHttpServer.layerTest),
   Layer.provide(Layer.mock(Auth.Service)({})),
   Layer.provide(Layer.mock(Config.Service)({})),
+  Layer.provide(Layer.mock(MoveSession.Service)({})),
   Layer.provide(
     Layer.mock(Installation.Service)({
       method: () => Effect.succeed("npm"),

+ 5 - 4
packages/opencode/test/server/project-copy.test.ts

@@ -43,9 +43,10 @@ describe("project directories and copies endpoints", () => {
         const projectID = (yield* json<{ id: string }>(current)).id
         const base = `/project/${projectID}`
         const copies = `/experimental/project/${projectID}/copy`
-        const createdDirectory = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
+        const createdParent = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
+        const createdDirectory = path.join(createdParent, "copy")
         yield* Effect.addFinalizer(() =>
-          Effect.promise(() => fs.rm(createdDirectory, { recursive: true, force: true })).pipe(Effect.ignore),
+          Effect.promise(() => fs.rm(createdParent, { recursive: true, force: true })).pipe(Effect.ignore),
         )
 
         const initial = yield* request(test.directory, `${base}/directories`)
@@ -55,11 +56,11 @@ describe("project directories and copies endpoints", () => {
         const create = yield* request(test.directory, copies, {
           method: "POST",
           headers: { "content-type": "application/json" },
-          body: JSON.stringify({ strategy: "git_worktree", directory: createdDirectory }),
+          body: JSON.stringify({ strategy: "git_worktree", directory: createdParent, name: "copy" }),
         })
         expect(create.status).toBe(200)
         const created = yield* json<{ directory: string }>(create)
-        expect(created.directory).toContain("-http-copy")
+        expect(created.directory).toBe(createdDirectory)
 
         const listed = yield* request(test.directory, `${base}/directories`)
         expect(yield* json<string[]>(listed)).toContain(created.directory)

+ 271 - 226
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -34,6 +34,8 @@ import type {
   ExperimentalConsoleListOrgsErrors,
   ExperimentalConsoleListOrgsResponses,
   ExperimentalConsoleSwitchOrgResponses,
+  ExperimentalControlPlaneMoveSessionErrors,
+  ExperimentalControlPlaneMoveSessionResponses,
   ExperimentalProjectCopyCreateErrors,
   ExperimentalProjectCopyCreateResponses,
   ExperimentalProjectCopyRefreshErrors,
@@ -108,6 +110,7 @@ import type {
   McpRemoteConfig,
   McpStatusErrors,
   McpStatusResponses,
+  MoveSessionDestination,
   OutputFormat,
   Part as Part2,
   PartDeleteErrors,
@@ -532,184 +535,17 @@ export class App extends HeyApiClient {
   }
 }
 
-export class Config extends HeyApiClient {
-  /**
-   * Get global configuration
-   *
-   * Retrieve the current global OpenCode configuration settings and preferences.
-   */
-  public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
-    return (options?.client ?? this.client).get<GlobalConfigGetResponses, GlobalConfigGetErrors, ThrowOnError>({
-      url: "/global/config",
-      ...options,
-    })
-  }
-
-  /**
-   * Update global configuration
-   *
-   * Update global OpenCode configuration settings and preferences.
-   */
-  public update<ThrowOnError extends boolean = false>(
-    parameters?: {
-      config?: Config3
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }])
-    return (options?.client ?? this.client).patch<GlobalConfigUpdateResponses, GlobalConfigUpdateErrors, ThrowOnError>({
-      url: "/global/config",
-      ...options,
-      ...params,
-      headers: {
-        "Content-Type": "application/json",
-        ...options?.headers,
-        ...params.headers,
-      },
-    })
-  }
-}
-
-export class Global extends HeyApiClient {
-  /**
-   * Get health
-   *
-   * Get health information about the OpenCode server.
-   */
-  public health<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
-    return (options?.client ?? this.client).get<GlobalHealthResponses, GlobalHealthErrors, ThrowOnError>({
-      url: "/global/health",
-      ...options,
-    })
-  }
-
-  /**
-   * Get global events
-   *
-   * Subscribe to global events from the OpenCode system using server-sent events.
-   */
-  public event<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
-    return (options?.client ?? this.client).sse.get<GlobalEventResponses, GlobalEventErrors, ThrowOnError>({
-      url: "/global/event",
-      ...options,
-    })
-  }
-
-  /**
-   * Dispose instance
-   *
-   * Clean up and dispose all OpenCode instances, releasing all resources.
-   */
-  public dispose<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
-    return (options?.client ?? this.client).post<GlobalDisposeResponses, GlobalDisposeErrors, ThrowOnError>({
-      url: "/global/dispose",
-      ...options,
-    })
-  }
-
-  /**
-   * Upgrade opencode
-   *
-   * Upgrade opencode to the specified version or latest if not specified.
-   */
-  public upgrade<ThrowOnError extends boolean = false>(
-    parameters?: {
-      target?: string
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }])
-    return (options?.client ?? this.client).post<GlobalUpgradeResponses, GlobalUpgradeErrors, ThrowOnError>({
-      url: "/global/upgrade",
-      ...options,
-      ...params,
-      headers: {
-        "Content-Type": "application/json",
-        ...options?.headers,
-        ...params.headers,
-      },
-    })
-  }
-
-  private _config?: Config
-  get config(): Config {
-    return (this._config ??= new Config({ client: this.client }))
-  }
-}
-
-export class Event extends HeyApiClient {
+export class ControlPlane extends HeyApiClient {
   /**
-   * Subscribe to events
+   * Move session
    *
-   * Get events
+   * Move a session to another project directory, optionally transferring local changes.
    */
-  public subscribe<ThrowOnError extends boolean = false>(
+  public moveSession<ThrowOnError extends boolean = false>(
     parameters?: {
-      directory?: string
-      workspace?: string
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "query", key: "directory" },
-            { in: "query", key: "workspace" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
-      url: "/event",
-      ...options,
-      ...params,
-    })
-  }
-}
-
-export class Config2 extends HeyApiClient {
-  /**
-   * Get configuration
-   *
-   * Retrieve the current OpenCode configuration settings and preferences.
-   */
-  public get<ThrowOnError extends boolean = false>(
-    parameters?: {
-      directory?: string
-      workspace?: string
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "query", key: "directory" },
-            { in: "query", key: "workspace" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).get<ConfigGetResponses, ConfigGetErrors, ThrowOnError>({
-      url: "/config",
-      ...options,
-      ...params,
-    })
-  }
-
-  /**
-   * Update configuration
-   *
-   * Update OpenCode configuration settings and preferences.
-   */
-  public update<ThrowOnError extends boolean = false>(
-    parameters?: {
-      directory?: string
-      workspace?: string
-      config?: Config3
+      sessionID?: string
+      destination?: MoveSessionDestination
+      moveChanges?: boolean
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -718,15 +554,19 @@ export class Config2 extends HeyApiClient {
       [
         {
           args: [
-            { in: "query", key: "directory" },
-            { in: "query", key: "workspace" },
-            { key: "config", map: "body" },
+            { in: "body", key: "sessionID" },
+            { in: "body", key: "destination" },
+            { in: "body", key: "moveChanges" },
           ],
         },
       ],
     )
-    return (options?.client ?? this.client).patch<ConfigUpdateResponses, ConfigUpdateErrors, ThrowOnError>({
-      url: "/config",
+    return (options?.client ?? this.client).post<
+      ExperimentalControlPlaneMoveSessionResponses,
+      ExperimentalControlPlaneMoveSessionErrors,
+      ThrowOnError
+    >({
+      url: "/experimental/control-plane/move-session",
       ...options,
       ...params,
       headers: {
@@ -736,36 +576,6 @@ export class Config2 extends HeyApiClient {
       },
     })
   }
-
-  /**
-   * List config providers
-   *
-   * Get a list of all configured AI providers and their default models.
-   */
-  public providers<ThrowOnError extends boolean = false>(
-    parameters?: {
-      directory?: string
-      workspace?: string
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "query", key: "directory" },
-            { in: "query", key: "workspace" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).get<ConfigProvidersResponses, ConfigProvidersErrors, ThrowOnError>({
-      url: "/config/providers",
-      ...options,
-      ...params,
-    })
-  }
 }
 
 export class Console extends HeyApiClient {
@@ -1021,10 +831,11 @@ export class ProjectCopy extends HeyApiClient {
   public create<ThrowOnError extends boolean = false>(
     parameters: {
       projectID: string
-      query_directory?: string
       workspace?: string
       strategy?: "git_worktree"
-      body_directory?: string
+      directory?: string
+      name?: string
+      context?: string
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -1034,18 +845,11 @@ export class ProjectCopy extends HeyApiClient {
         {
           args: [
             { in: "path", key: "projectID" },
-            {
-              in: "query",
-              key: "query_directory",
-              map: "directory",
-            },
             { in: "query", key: "workspace" },
             { in: "body", key: "strategy" },
-            {
-              in: "body",
-              key: "body_directory",
-              map: "directory",
-            },
+            { in: "body", key: "directory" },
+            { in: "body", key: "name" },
+            { in: "body", key: "context" },
           ],
         },
       ],
@@ -1377,6 +1181,11 @@ export class Workspace extends HeyApiClient {
 }
 
 export class Experimental extends HeyApiClient {
+  private _controlPlane?: ControlPlane
+  get controlPlane(): ControlPlane {
+    return (this._controlPlane ??= new ControlPlane({ client: this.client }))
+  }
+
   private _console?: Console
   get console(): Console {
     return (this._console ??= new Console({ client: this.client }))
@@ -1403,6 +1212,242 @@ export class Experimental extends HeyApiClient {
   }
 }
 
+export class Config extends HeyApiClient {
+  /**
+   * Get global configuration
+   *
+   * Retrieve the current global OpenCode configuration settings and preferences.
+   */
+  public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
+    return (options?.client ?? this.client).get<GlobalConfigGetResponses, GlobalConfigGetErrors, ThrowOnError>({
+      url: "/global/config",
+      ...options,
+    })
+  }
+
+  /**
+   * Update global configuration
+   *
+   * Update global OpenCode configuration settings and preferences.
+   */
+  public update<ThrowOnError extends boolean = false>(
+    parameters?: {
+      config?: Config3
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }])
+    return (options?.client ?? this.client).patch<GlobalConfigUpdateResponses, GlobalConfigUpdateErrors, ThrowOnError>({
+      url: "/global/config",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+}
+
+export class Global extends HeyApiClient {
+  /**
+   * Get health
+   *
+   * Get health information about the OpenCode server.
+   */
+  public health<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
+    return (options?.client ?? this.client).get<GlobalHealthResponses, GlobalHealthErrors, ThrowOnError>({
+      url: "/global/health",
+      ...options,
+    })
+  }
+
+  /**
+   * Get global events
+   *
+   * Subscribe to global events from the OpenCode system using server-sent events.
+   */
+  public event<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
+    return (options?.client ?? this.client).sse.get<GlobalEventResponses, GlobalEventErrors, ThrowOnError>({
+      url: "/global/event",
+      ...options,
+    })
+  }
+
+  /**
+   * Dispose instance
+   *
+   * Clean up and dispose all OpenCode instances, releasing all resources.
+   */
+  public dispose<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
+    return (options?.client ?? this.client).post<GlobalDisposeResponses, GlobalDisposeErrors, ThrowOnError>({
+      url: "/global/dispose",
+      ...options,
+    })
+  }
+
+  /**
+   * Upgrade opencode
+   *
+   * Upgrade opencode to the specified version or latest if not specified.
+   */
+  public upgrade<ThrowOnError extends boolean = false>(
+    parameters?: {
+      target?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }])
+    return (options?.client ?? this.client).post<GlobalUpgradeResponses, GlobalUpgradeErrors, ThrowOnError>({
+      url: "/global/upgrade",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
+  private _config?: Config
+  get config(): Config {
+    return (this._config ??= new Config({ client: this.client }))
+  }
+}
+
+export class Event extends HeyApiClient {
+  /**
+   * Subscribe to events
+   *
+   * Get events
+   */
+  public subscribe<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
+      url: "/event",
+      ...options,
+      ...params,
+    })
+  }
+}
+
+export class Config2 extends HeyApiClient {
+  /**
+   * Get configuration
+   *
+   * Retrieve the current OpenCode configuration settings and preferences.
+   */
+  public get<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).get<ConfigGetResponses, ConfigGetErrors, ThrowOnError>({
+      url: "/config",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * Update configuration
+   *
+   * Update OpenCode configuration settings and preferences.
+   */
+  public update<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+      config?: Config3
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+            { key: "config", map: "body" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).patch<ConfigUpdateResponses, ConfigUpdateErrors, ThrowOnError>({
+      url: "/config",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
+  /**
+   * List config providers
+   *
+   * Get a list of all configured AI providers and their default models.
+   */
+  public providers<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).get<ConfigProvidersResponses, ConfigProvidersErrors, ThrowOnError>({
+      url: "/config/providers",
+      ...options,
+      ...params,
+    })
+  }
+}
+
 export class Tool extends HeyApiClient {
   /**
    * List tools
@@ -5712,6 +5757,11 @@ export class OpencodeClient extends HeyApiClient {
     return (this._app ??= new App({ client: this.client }))
   }
 
+  private _experimental?: Experimental
+  get experimental(): Experimental {
+    return (this._experimental ??= new Experimental({ client: this.client }))
+  }
+
   private _global?: Global
   get global(): Global {
     return (this._global ??= new Global({ client: this.client }))
@@ -5727,11 +5777,6 @@ export class OpencodeClient extends HeyApiClient {
     return (this._config ??= new Config2({ client: this.client }))
   }
 
-  private _experimental?: Experimental
-  get experimental(): Experimental {
-    return (this._experimental ??= new Experimental({ client: this.client }))
-  }
-
   private _tool?: Tool
   get tool(): Tool {
     return (this._tool ??= new Tool({ client: this.client }))

+ 102 - 12
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -17,6 +17,7 @@ export type Event =
   | EventMessagePartRemoved
   | EventSessionNextAgentSwitched
   | EventSessionNextModelSwitched
+  | EventSessionNextMoved
   | EventSessionNextPrompted
   | EventSessionNextSynthetic
   | EventSessionNextShellStarted
@@ -136,6 +137,13 @@ export type InvalidRequestError = {
   field?: string
 }
 
+export type MoveSessionError = {
+  name: "MoveSessionError"
+  data: {
+    message: string
+  }
+}
+
 export type SnapshotFileDiff = {
   file?: string
   patch?: string
@@ -812,6 +820,16 @@ export type GlobalEvent = {
           }
         }
       }
+    | {
+        id: string
+        type: "session.next.moved"
+        properties: {
+          timestamp: number
+          sessionID: string
+          location: LocationRef
+          subdirectory?: string
+        }
+      }
     | {
         id: string
         type: "session.next.prompted"
@@ -1556,6 +1574,7 @@ export type GlobalEvent = {
     | SyncEventMessagePartRemoved
     | SyncEventSessionNextAgentSwitched
     | SyncEventSessionNextModelSwitched
+    | SyncEventSessionNextMoved
     | SyncEventSessionNextPrompted
     | SyncEventSessionNextSynthetic
     | SyncEventSessionNextShellStarted
@@ -2397,6 +2416,13 @@ export type ProjectNotFoundError = {
   message: string
 }
 
+export type ProjectCopyError = {
+  name: "ProjectCopyError"
+  data: {
+    message: string
+  }
+}
+
 export type PtyNotFoundError = {
   _tag: "PtyNotFoundError"
   ptyID: string
@@ -2751,6 +2777,10 @@ export type EventTuiSessionSelect2 = {
   }
 }
 
+export type MoveSessionDestination = {
+  directory: string
+}
+
 export type ModelV2Info = {
   id: string
   providerID: string
@@ -2821,6 +2851,11 @@ export type ModelV2Info = {
   }
 }
 
+export type LocationRef = {
+  directory: string
+  workspaceID?: string
+}
+
 export type PromptSource = {
   start: number
   end: number
@@ -3112,6 +3147,23 @@ export type SyncEventSessionNextModelSwitched = {
   }
 }
 
+export type SyncEventSessionNextMoved = {
+  type: "sync"
+  id: string
+  syncEvent: {
+    type: "session.next.moved.1"
+    id: string
+    seq: number
+    aggregateID: string
+    data: {
+      timestamp: number
+      sessionID: string
+      location: LocationRef
+      subdirectory?: string
+    }
+  }
+}
+
 export type SyncEventSessionNextPrompted = {
   type: "sync"
   id: string
@@ -3588,11 +3640,6 @@ export type AgentV2Info = {
   permissions: PermissionV2Ruleset
 }
 
-export type LocationRef = {
-  directory: string
-  workspaceID?: string
-}
-
 export type SessionV2Info = {
   id: string
   parentID?: string
@@ -4147,6 +4194,17 @@ export type EventSessionNextModelSwitched = {
   }
 }
 
+export type EventSessionNextMoved = {
+  id: string
+  type: "session.next.moved"
+  properties: {
+    timestamp: number
+    sessionID: string
+    location: LocationRef
+    subdirectory?: string
+  }
+}
+
 export type EventSessionNextPrompted = {
   id: string
   type: "session.next.prompted"
@@ -5002,6 +5060,37 @@ export type AppLogResponses = {
 
 export type AppLogResponse = AppLogResponses[keyof AppLogResponses]
 
+export type ExperimentalControlPlaneMoveSessionData = {
+  body?: {
+    sessionID: string
+    destination: MoveSessionDestination
+    moveChanges?: boolean
+  }
+  path?: never
+  query?: never
+  url: "/experimental/control-plane/move-session"
+}
+
+export type ExperimentalControlPlaneMoveSessionErrors = {
+  /**
+   * MoveSessionError | InvalidRequestError
+   */
+  400: MoveSessionError | InvalidRequestError
+}
+
+export type ExperimentalControlPlaneMoveSessionError =
+  ExperimentalControlPlaneMoveSessionErrors[keyof ExperimentalControlPlaneMoveSessionErrors]
+
+export type ExperimentalControlPlaneMoveSessionResponses = {
+  /**
+   * Session moved
+   */
+  204: void
+}
+
+export type ExperimentalControlPlaneMoveSessionResponse =
+  ExperimentalControlPlaneMoveSessionResponses[keyof ExperimentalControlPlaneMoveSessionResponses]
+
 export type GlobalHealthData = {
   body?: never
   path?: never
@@ -6596,9 +6685,9 @@ export type ExperimentalProjectCopyRemoveData = {
 
 export type ExperimentalProjectCopyRemoveErrors = {
   /**
-   * BadRequest | InvalidRequestError
+   * ProjectCopyError | InvalidRequestError
    */
-  400: EffectHttpApiErrorBadRequest | InvalidRequestError
+  400: ProjectCopyError | InvalidRequestError
 }
 
 export type ExperimentalProjectCopyRemoveError =
@@ -6618,12 +6707,13 @@ export type ExperimentalProjectCopyCreateData = {
   body?: {
     strategy: "git_worktree"
     directory: string
+    name?: string
+    context?: string
   }
   path: {
     projectID: string
   }
   query?: {
-    directory?: string
     workspace?: string
   }
   url: "/experimental/project/{projectID}/copy"
@@ -6631,9 +6721,9 @@ export type ExperimentalProjectCopyCreateData = {
 
 export type ExperimentalProjectCopyCreateErrors = {
   /**
-   * BadRequest | InvalidRequestError
+   * ProjectCopyError | InvalidRequestError
    */
-  400: EffectHttpApiErrorBadRequest | InvalidRequestError
+  400: ProjectCopyError | InvalidRequestError
 }
 
 export type ExperimentalProjectCopyCreateError =
@@ -6663,9 +6753,9 @@ export type ExperimentalProjectCopyRefreshData = {
 
 export type ExperimentalProjectCopyRefreshErrors = {
   /**
-   * BadRequest | InvalidRequestError
+   * ProjectCopyError | InvalidRequestError
    */
-  400: EffectHttpApiErrorBadRequest | InvalidRequestError
+  400: ProjectCopyError | InvalidRequestError
 }
 
 export type ExperimentalProjectCopyRefreshError =

+ 270 - 28
packages/sdk/openapi.json

@@ -212,6 +212,66 @@
         ]
       }
     },
+    "/experimental/control-plane/move-session": {
+      "post": {
+        "tags": ["controlPlane"],
+        "operationId": "experimental.controlPlane.moveSession",
+        "parameters": [],
+        "responses": {
+          "204": {
+            "description": "Session moved"
+          },
+          "400": {
+            "description": "MoveSessionError | InvalidRequestError",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "anyOf": [
+                    {
+                      "$ref": "#/components/schemas/MoveSessionError"
+                    },
+                    {
+                      "$ref": "#/components/schemas/InvalidRequestError"
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        },
+        "description": "Move a session to another project directory, optionally transferring local changes.",
+        "summary": "Move session",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "sessionID": {
+                    "type": "string",
+                    "pattern": "^ses"
+                  },
+                  "destination": {
+                    "$ref": "#/components/schemas/MoveSessionDestination"
+                  },
+                  "moveChanges": {
+                    "type": "boolean"
+                  }
+                },
+                "required": ["sessionID", "destination"],
+                "additionalProperties": false
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.controlPlane.moveSession({\n  ...\n})"
+          }
+        ]
+      }
+    },
     "/global/health": {
       "get": {
         "tags": ["global"],
@@ -3774,14 +3834,6 @@
             },
             "required": true
           },
-          {
-            "name": "directory",
-            "in": "query",
-            "schema": {
-              "type": "string"
-            },
-            "required": false
-          },
           {
             "name": "workspace",
             "in": "query",
@@ -3803,13 +3855,13 @@
             }
           },
           "400": {
-            "description": "BadRequest | InvalidRequestError",
+            "description": "ProjectCopyError | InvalidRequestError",
             "content": {
               "application/json": {
                 "schema": {
                   "anyOf": [
                     {
-                      "$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
+                      "$ref": "#/components/schemas/ProjectCopyError"
                     },
                     {
                       "$ref": "#/components/schemas/InvalidRequestError"
@@ -3834,6 +3886,12 @@
                   },
                   "directory": {
                     "type": "string"
+                  },
+                  "name": {
+                    "type": "string"
+                  },
+                  "context": {
+                    "type": "string"
                   }
                 },
                 "required": ["strategy", "directory"],
@@ -3883,13 +3941,13 @@
             "description": "Project copy removed"
           },
           "400": {
-            "description": "BadRequest | InvalidRequestError",
+            "description": "ProjectCopyError | InvalidRequestError",
             "content": {
               "application/json": {
                 "schema": {
                   "anyOf": [
                     {
-                      "$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
+                      "$ref": "#/components/schemas/ProjectCopyError"
                     },
                     {
                       "$ref": "#/components/schemas/InvalidRequestError"
@@ -3961,13 +4019,13 @@
             "description": "Project copies refreshed"
           },
           "400": {
-            "description": "BadRequest | InvalidRequestError",
+            "description": "ProjectCopyError | InvalidRequestError",
             "content": {
               "application/json": {
                 "schema": {
                   "anyOf": [
                     {
-                      "$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
+                      "$ref": "#/components/schemas/ProjectCopyError"
                     },
                     {
                       "$ref": "#/components/schemas/InvalidRequestError"
@@ -11953,6 +12011,9 @@
           {
             "$ref": "#/components/schemas/EventSessionNextModelSwitched"
           },
+          {
+            "$ref": "#/components/schemas/EventSessionNextMoved"
+          },
           {
             "$ref": "#/components/schemas/EventSessionNextPrompted"
           },
@@ -12312,6 +12373,27 @@
         "required": ["_tag", "message"],
         "additionalProperties": false
       },
+      "MoveSessionError": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "type": "string",
+            "enum": ["MoveSessionError"]
+          },
+          "data": {
+            "type": "object",
+            "properties": {
+              "message": {
+                "type": "string"
+              }
+            },
+            "required": ["message"],
+            "additionalProperties": false
+          }
+        },
+        "required": ["name", "data"],
+        "additionalProperties": false
+      },
       "SnapshotFileDiff": {
         "type": "object",
         "properties": {
@@ -14356,6 +14438,40 @@
                 "required": ["id", "type", "properties"],
                 "additionalProperties": false
               },
+              {
+                "type": "object",
+                "properties": {
+                  "id": {
+                    "type": "string"
+                  },
+                  "type": {
+                    "type": "string",
+                    "enum": ["session.next.moved"]
+                  },
+                  "properties": {
+                    "type": "object",
+                    "properties": {
+                      "timestamp": {
+                        "type": "number"
+                      },
+                      "sessionID": {
+                        "type": "string",
+                        "pattern": "^ses"
+                      },
+                      "location": {
+                        "$ref": "#/components/schemas/LocationRef"
+                      },
+                      "subdirectory": {
+                        "type": "string"
+                      }
+                    },
+                    "required": ["timestamp", "sessionID", "location"],
+                    "additionalProperties": false
+                  }
+                },
+                "required": ["id", "type", "properties"],
+                "additionalProperties": false
+              },
               {
                 "type": "object",
                 "properties": {
@@ -16782,6 +16898,9 @@
               {
                 "$ref": "#/components/schemas/SyncEventSessionNextModelSwitched"
               },
+              {
+                "$ref": "#/components/schemas/SyncEventSessionNextMoved"
+              },
               {
                 "$ref": "#/components/schemas/SyncEventSessionNextPrompted"
               },
@@ -19094,6 +19213,27 @@
         "required": ["_tag", "projectID", "message"],
         "additionalProperties": false
       },
+      "ProjectCopyError": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "type": "string",
+            "enum": ["ProjectCopyError"]
+          },
+          "data": {
+            "type": "object",
+            "properties": {
+              "message": {
+                "type": "string"
+              }
+            },
+            "required": ["message"],
+            "additionalProperties": false
+          }
+        },
+        "required": ["name", "data"],
+        "additionalProperties": false
+      },
       "PtyNotFoundError": {
         "type": "object",
         "properties": {
@@ -20117,6 +20257,16 @@
         "required": ["id", "type", "properties"],
         "additionalProperties": false
       },
+      "MoveSessionDestination": {
+        "type": "object",
+        "properties": {
+          "directory": {
+            "type": "string"
+          }
+        },
+        "required": ["directory"],
+        "additionalProperties": false
+      },
       "ModelV2Info": {
         "type": "object",
         "properties": {
@@ -20355,6 +20505,20 @@
         ],
         "additionalProperties": false
       },
+      "LocationRef": {
+        "type": "object",
+        "properties": {
+          "directory": {
+            "type": "string"
+          },
+          "workspaceID": {
+            "type": "string",
+            "pattern": "^wrk"
+          }
+        },
+        "required": ["directory"],
+        "additionalProperties": false
+      },
       "PromptSource": {
         "type": "object",
         "properties": {
@@ -21200,6 +21364,60 @@
         "required": ["type", "id", "syncEvent"],
         "additionalProperties": false
       },
+      "SyncEventSessionNextMoved": {
+        "type": "object",
+        "properties": {
+          "type": {
+            "type": "string",
+            "enum": ["sync"]
+          },
+          "id": {
+            "type": "string"
+          },
+          "syncEvent": {
+            "type": "object",
+            "properties": {
+              "type": {
+                "type": "string",
+                "enum": ["session.next.moved.1"]
+              },
+              "id": {
+                "type": "string"
+              },
+              "seq": {
+                "type": "number"
+              },
+              "aggregateID": {
+                "type": "string"
+              },
+              "data": {
+                "type": "object",
+                "properties": {
+                  "timestamp": {
+                    "type": "number"
+                  },
+                  "sessionID": {
+                    "type": "string",
+                    "pattern": "^ses"
+                  },
+                  "location": {
+                    "$ref": "#/components/schemas/LocationRef"
+                  },
+                  "subdirectory": {
+                    "type": "string"
+                  }
+                },
+                "required": ["timestamp", "sessionID", "location"],
+                "additionalProperties": false
+              }
+            },
+            "required": ["type", "id", "seq", "aggregateID", "data"],
+            "additionalProperties": false
+          }
+        },
+        "required": ["type", "id", "syncEvent"],
+        "additionalProperties": false
+      },
       "SyncEventSessionNextPrompted": {
         "type": "object",
         "properties": {
@@ -22653,20 +22871,6 @@
         "required": ["id", "request", "mode", "hidden", "permissions"],
         "additionalProperties": false
       },
-      "LocationRef": {
-        "type": "object",
-        "properties": {
-          "directory": {
-            "type": "string"
-          },
-          "workspaceID": {
-            "type": "string",
-            "pattern": "^wrk"
-          }
-        },
-        "required": ["directory"],
-        "additionalProperties": false
-      },
       "SessionV2Info": {
         "type": "object",
         "properties": {
@@ -24279,6 +24483,40 @@
         "required": ["id", "type", "properties"],
         "additionalProperties": false
       },
+      "EventSessionNextMoved": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string"
+          },
+          "type": {
+            "type": "string",
+            "enum": ["session.next.moved"]
+          },
+          "properties": {
+            "type": "object",
+            "properties": {
+              "timestamp": {
+                "type": "number"
+              },
+              "sessionID": {
+                "type": "string",
+                "pattern": "^ses"
+              },
+              "location": {
+                "$ref": "#/components/schemas/LocationRef"
+              },
+              "subdirectory": {
+                "type": "string"
+              }
+            },
+            "required": ["timestamp", "sessionID", "location"],
+            "additionalProperties": false
+          }
+        },
+        "required": ["id", "type", "properties"],
+        "additionalProperties": false
+      },
       "EventSessionNextPrompted": {
         "type": "object",
         "properties": {
@@ -26564,6 +26802,10 @@
       "name": "control",
       "description": "Control plane routes."
     },
+    {
+      "name": "controlPlane",
+      "description": "Control-plane orchestration routes."
+    },
     {
       "name": "global",
       "description": "Global server routes."