feat(core): moving sessions (#30640)

This commit is contained in:
James Long 2026-06-04 15:07:46 -04:00 committed by GitHub
commit ba1b660d57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2180 additions and 476 deletions

View file

@ -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),
)

View file

@ -1,7 +1,7 @@
export * as Git from "./git" export * as Git from "./git"
import path from "path" 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 { ChildProcess } from "effect/unstable/process"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
@ -33,6 +33,13 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
cause: Schema.optional(Schema.Defect), 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 { export interface Interface {
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined> readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | 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 fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
readonly checkout: (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 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 worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeRemove: (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> readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
@ -159,6 +170,156 @@ export const layer = Layer.effect(
execute(directory, proc)(["reset", "--hard", target]), 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* ( const worktree = Effect.fnUntraced(function* (
operation: "create" | "remove" | "list", operation: "create" | "remove" | "list",
repo: Repo, repo: Repo,
@ -216,6 +377,10 @@ export const layer = Layer.effect(
fetchBranch, fetchBranch,
checkout, checkout,
reset, reset,
patch,
applyPatch,
resetChanges,
softResetChanges,
worktreeCreate, worktreeCreate,
worktreeRemove, worktreeRemove,
worktreeList, worktreeList,

View file

@ -25,11 +25,17 @@ export function makeStrategies(input: {
yield* input.git.worktreeRemove({ repo: found, directory }) yield* input.git.worktreeRemove({ repo: found, directory })
}), }),
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (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) => return yield* Effect.forEach(entries, (entry) =>
entry === directory entry === core
? Effect.succeed(undefined) ? 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))) ).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined)))
}), }),
detect: Effect.fn("ProjectCopy.GitWorktree.detect")(function* (inputDirectory) { detect: Effect.fn("ProjectCopy.GitWorktree.detect")(function* (inputDirectory) {

View file

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

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import { WorkspaceTable } from "../control-plane/workspace.sql"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater" import { SessionMessageUpdater } from "./message-updater"
import { SessionInput } from "./input" import { SessionInput } from "./input"
import { WorkspaceV2 } from "../workspace"
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql" import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema" import type { DeepMutable } from "../schema"
@ -245,6 +246,19 @@ export const layer = Layer.effectDiscard(
.run() .run()
.pipe(Effect.orDie), .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) => yield* events.project(SessionV1.Event.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
) )

View file

@ -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")
}),
)
})

View file

@ -97,9 +97,11 @@ describe("ProjectCopy", () => {
const input = yield* setup() const input = yield* setup()
const copy = yield* ProjectCopy.Service const copy = yield* ProjectCopy.Service
const events = yield* EventV2.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(() => 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 const fiber = yield* events
.subscribe(ProjectCopy.Event.Updated) .subscribe(ProjectCopy.Event.Updated)
@ -110,8 +112,10 @@ describe("ProjectCopy", () => {
projectID: input.projectID, projectID: input.projectID,
strategy: "git_worktree", strategy: "git_worktree",
sourceDirectory: input.sourceDirectory, sourceDirectory: input.sourceDirectory,
directory: target, directory: parent,
name: "copy",
}) })
expect(created.directory).toBe(target)
expect(yield* stored(input.projectID)).toEqual( expect(yield* stored(input.projectID)).toEqual(
[ [
{ directory: input.sourceDirectory, type: "main" as const }, { 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", () => it.live("does not publish an event when refresh finds no directory changes", () =>
Effect.gen(function* () { Effect.gen(function* () {
const input = yield* setup() 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", () => it.live("refresh with no roots is a no-op", () =>
Effect.gen(function* () { Effect.gen(function* () {
const copy = yield* ProjectCopy.Service const copy = yield* ProjectCopy.Service

View file

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

View file

@ -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>
)
}

View file

@ -103,7 +103,7 @@ export function DialogWorkspaceFileChanges(props: {
</scrollbox> </scrollbox>
<box paddingLeft={2} paddingRight={2}> <box paddingLeft={2} paddingRight={2}>
<text fg={theme.textMuted} wrapMode="word"> <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> </text>
</box> </box>
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}> <box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>

View file

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

View file

@ -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,
}
}

View file

@ -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 }
}

View file

@ -204,6 +204,7 @@ export const Definitions = {
"dialog.select.submit": keybind("return", "Submit selected dialog item"), "dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"), "dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), "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.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),

View file

@ -253,6 +253,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break 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": { case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status) setStore("session_status", event.properties.sessionID, event.properties.status)
break break

View file

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

View file

@ -5,7 +5,7 @@ import { Global } from "@opencode-ai/core/global"
const id = "internal:sidebar-footer" const id = "internal:sidebar-footer"
function View(props: { api: TuiPluginApi }) { function View(props: { api: TuiPluginApi; sessionID: string }) {
const theme = () => props.api.theme.current const theme = () => props.api.theme.current
const has = createMemo(() => const has = createMemo(() =>
props.api.state.provider.some( 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 done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
const show = createMemo(() => !has() && !done()) const show = createMemo(() => !has() && !done())
const path = createMemo(() => { 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 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("/") const list = text.split("/")
return { return {
parent: list.slice(0, -1).join("/"), parent: list.slice(0, -1).join("/"),
@ -79,8 +81,8 @@ const tui: TuiPlugin = async (api) => {
api.slots.register({ api.slots.register({
order: 100, order: 100,
slots: { slots: {
sidebar_footer() { sidebar_footer(_ctx, props) {
return <View api={api} /> return <View api={api} sessionID={props.session_id} />
}, },
}, },
}) })

View file

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

View file

@ -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)
}

View file

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

View file

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

View file

@ -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." })),
)

View file

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

View file

@ -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
}

View file

@ -2,26 +2,104 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { Effect } from "effect" import { Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api" 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>) { 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) => export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
const llm = yield* LLM.Service
const agent = yield* Agent.Service
const provider = yield* Provider.Service
const service = yield* ProjectCopy.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: { const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
params: { projectID: ProjectV2.ID } params: { projectID: ProjectV2.ID }
payload: typeof CreatePayload.Type payload: typeof CreatePayload.Type
}) { }) {
const name =
ctx.payload.name ??
(yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create()))))
return yield* badRequest( return yield* badRequest(
service.create({ service.create({
...ctx.payload, ...ctx.payload,
name,
projectID: ctx.params.projectID, projectID: ctx.params.projectID,
sourceDirectory: AbsolutePath.make((yield* InstanceState.context).worktree), 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) 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
}

View file

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

View file

@ -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)
}),
)
})

View file

@ -506,6 +506,14 @@ const scenarios: Scenario[] = [
body: {}, body: {},
})) }))
.status(400), .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 http.protected
.get("/experimental/tool", "tool.list") .get("/experimental/tool", "tool.list")
.at((ctx) => ({ .at((ctx) => ({

View file

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

View file

@ -43,9 +43,10 @@ describe("project directories and copies endpoints", () => {
const projectID = (yield* json<{ id: string }>(current)).id const projectID = (yield* json<{ id: string }>(current)).id
const base = `/project/${projectID}` const base = `/project/${projectID}`
const copies = `/experimental/project/${projectID}/copy` 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(() => 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`) 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, { const create = yield* request(test.directory, copies, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, 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) expect(create.status).toBe(200)
const created = yield* json<{ directory: string }>(create) 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`) const listed = yield* request(test.directory, `${base}/directories`)
expect(yield* json<string[]>(listed)).toContain(created.directory) expect(yield* json<string[]>(listed)).toContain(created.directory)

View file

@ -34,6 +34,8 @@ import type {
ExperimentalConsoleListOrgsErrors, ExperimentalConsoleListOrgsErrors,
ExperimentalConsoleListOrgsResponses, ExperimentalConsoleListOrgsResponses,
ExperimentalConsoleSwitchOrgResponses, ExperimentalConsoleSwitchOrgResponses,
ExperimentalControlPlaneMoveSessionErrors,
ExperimentalControlPlaneMoveSessionResponses,
ExperimentalProjectCopyCreateErrors, ExperimentalProjectCopyCreateErrors,
ExperimentalProjectCopyCreateResponses, ExperimentalProjectCopyCreateResponses,
ExperimentalProjectCopyRefreshErrors, ExperimentalProjectCopyRefreshErrors,
@ -108,6 +110,7 @@ import type {
McpRemoteConfig, McpRemoteConfig,
McpStatusErrors, McpStatusErrors,
McpStatusResponses, McpStatusResponses,
MoveSessionDestination,
OutputFormat, OutputFormat,
Part as Part2, Part as Part2,
PartDeleteErrors, PartDeleteErrors,
@ -532,33 +535,38 @@ export class App extends HeyApiClient {
} }
} }
export class Config extends HeyApiClient { export class ControlPlane extends HeyApiClient {
/** /**
* Get global configuration * Move session
* *
* Retrieve the current global OpenCode configuration settings and preferences. * Move a session to another project directory, optionally transferring local changes.
*/ */
public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) { public moveSession<ThrowOnError extends boolean = false>(
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?: { parameters?: {
config?: Config3 sessionID?: string
destination?: MoveSessionDestination
moveChanges?: boolean
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) const params = buildClientParams(
return (options?.client ?? this.client).patch<GlobalConfigUpdateResponses, GlobalConfigUpdateErrors, ThrowOnError>({ [parameters],
url: "/global/config", [
{
args: [
{ in: "body", key: "sessionID" },
{ in: "body", key: "destination" },
{ in: "body", key: "moveChanges" },
],
},
],
)
return (options?.client ?? this.client).post<
ExperimentalControlPlaneMoveSessionResponses,
ExperimentalControlPlaneMoveSessionErrors,
ThrowOnError
>({
url: "/experimental/control-plane/move-session",
...options, ...options,
...params, ...params,
headers: { headers: {
@ -570,204 +578,6 @@ export class Config extends HeyApiClient {
} }
} }
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 Console extends HeyApiClient { export class Console extends HeyApiClient {
/** /**
* Get active Console provider metadata * Get active Console provider metadata
@ -1021,10 +831,11 @@ export class ProjectCopy extends HeyApiClient {
public create<ThrowOnError extends boolean = false>( public create<ThrowOnError extends boolean = false>(
parameters: { parameters: {
projectID: string projectID: string
query_directory?: string
workspace?: string workspace?: string
strategy?: "git_worktree" strategy?: "git_worktree"
body_directory?: string directory?: string
name?: string
context?: string
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
@ -1034,18 +845,11 @@ export class ProjectCopy extends HeyApiClient {
{ {
args: [ args: [
{ in: "path", key: "projectID" }, { in: "path", key: "projectID" },
{
in: "query",
key: "query_directory",
map: "directory",
},
{ in: "query", key: "workspace" }, { in: "query", key: "workspace" },
{ in: "body", key: "strategy" }, { in: "body", key: "strategy" },
{ { in: "body", key: "directory" },
in: "body", { in: "body", key: "name" },
key: "body_directory", { in: "body", key: "context" },
map: "directory",
},
], ],
}, },
], ],
@ -1377,6 +1181,11 @@ export class Workspace extends HeyApiClient {
} }
export class Experimental extends HeyApiClient { export class Experimental extends HeyApiClient {
private _controlPlane?: ControlPlane
get controlPlane(): ControlPlane {
return (this._controlPlane ??= new ControlPlane({ client: this.client }))
}
private _console?: Console private _console?: Console
get console(): Console { get console(): Console {
return (this._console ??= new Console({ client: this.client })) 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 { export class Tool extends HeyApiClient {
/** /**
* List tools * List tools
@ -5712,6 +5757,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._app ??= new App({ client: this.client })) 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 private _global?: Global
get global(): Global { get global(): Global {
return (this._global ??= new Global({ client: this.client })) 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 })) 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 private _tool?: Tool
get tool(): Tool { get tool(): Tool {
return (this._tool ??= new Tool({ client: this.client })) return (this._tool ??= new Tool({ client: this.client }))

View file

@ -17,6 +17,7 @@ export type Event =
| EventMessagePartRemoved | EventMessagePartRemoved
| EventSessionNextAgentSwitched | EventSessionNextAgentSwitched
| EventSessionNextModelSwitched | EventSessionNextModelSwitched
| EventSessionNextMoved
| EventSessionNextPrompted | EventSessionNextPrompted
| EventSessionNextSynthetic | EventSessionNextSynthetic
| EventSessionNextShellStarted | EventSessionNextShellStarted
@ -136,6 +137,13 @@ export type InvalidRequestError = {
field?: string field?: string
} }
export type MoveSessionError = {
name: "MoveSessionError"
data: {
message: string
}
}
export type SnapshotFileDiff = { export type SnapshotFileDiff = {
file?: string file?: string
patch?: 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 id: string
type: "session.next.prompted" type: "session.next.prompted"
@ -1556,6 +1574,7 @@ export type GlobalEvent = {
| SyncEventMessagePartRemoved | SyncEventMessagePartRemoved
| SyncEventSessionNextAgentSwitched | SyncEventSessionNextAgentSwitched
| SyncEventSessionNextModelSwitched | SyncEventSessionNextModelSwitched
| SyncEventSessionNextMoved
| SyncEventSessionNextPrompted | SyncEventSessionNextPrompted
| SyncEventSessionNextSynthetic | SyncEventSessionNextSynthetic
| SyncEventSessionNextShellStarted | SyncEventSessionNextShellStarted
@ -2397,6 +2416,13 @@ export type ProjectNotFoundError = {
message: string message: string
} }
export type ProjectCopyError = {
name: "ProjectCopyError"
data: {
message: string
}
}
export type PtyNotFoundError = { export type PtyNotFoundError = {
_tag: "PtyNotFoundError" _tag: "PtyNotFoundError"
ptyID: string ptyID: string
@ -2751,6 +2777,10 @@ export type EventTuiSessionSelect2 = {
} }
} }
export type MoveSessionDestination = {
directory: string
}
export type ModelV2Info = { export type ModelV2Info = {
id: string id: string
providerID: string providerID: string
@ -2821,6 +2851,11 @@ export type ModelV2Info = {
} }
} }
export type LocationRef = {
directory: string
workspaceID?: string
}
export type PromptSource = { export type PromptSource = {
start: number start: number
end: 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 = { export type SyncEventSessionNextPrompted = {
type: "sync" type: "sync"
id: string id: string
@ -3588,11 +3640,6 @@ export type AgentV2Info = {
permissions: PermissionV2Ruleset permissions: PermissionV2Ruleset
} }
export type LocationRef = {
directory: string
workspaceID?: string
}
export type SessionV2Info = { export type SessionV2Info = {
id: string id: string
parentID?: 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 = { export type EventSessionNextPrompted = {
id: string id: string
type: "session.next.prompted" type: "session.next.prompted"
@ -5002,6 +5060,37 @@ export type AppLogResponses = {
export type AppLogResponse = AppLogResponses[keyof 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 = { export type GlobalHealthData = {
body?: never body?: never
path?: never path?: never
@ -6596,9 +6685,9 @@ export type ExperimentalProjectCopyRemoveData = {
export type ExperimentalProjectCopyRemoveErrors = { export type ExperimentalProjectCopyRemoveErrors = {
/** /**
* BadRequest | InvalidRequestError * ProjectCopyError | InvalidRequestError
*/ */
400: EffectHttpApiErrorBadRequest | InvalidRequestError 400: ProjectCopyError | InvalidRequestError
} }
export type ExperimentalProjectCopyRemoveError = export type ExperimentalProjectCopyRemoveError =
@ -6618,12 +6707,13 @@ export type ExperimentalProjectCopyCreateData = {
body?: { body?: {
strategy: "git_worktree" strategy: "git_worktree"
directory: string directory: string
name?: string
context?: string
} }
path: { path: {
projectID: string projectID: string
} }
query?: { query?: {
directory?: string
workspace?: string workspace?: string
} }
url: "/experimental/project/{projectID}/copy" url: "/experimental/project/{projectID}/copy"
@ -6631,9 +6721,9 @@ export type ExperimentalProjectCopyCreateData = {
export type ExperimentalProjectCopyCreateErrors = { export type ExperimentalProjectCopyCreateErrors = {
/** /**
* BadRequest | InvalidRequestError * ProjectCopyError | InvalidRequestError
*/ */
400: EffectHttpApiErrorBadRequest | InvalidRequestError 400: ProjectCopyError | InvalidRequestError
} }
export type ExperimentalProjectCopyCreateError = export type ExperimentalProjectCopyCreateError =
@ -6663,9 +6753,9 @@ export type ExperimentalProjectCopyRefreshData = {
export type ExperimentalProjectCopyRefreshErrors = { export type ExperimentalProjectCopyRefreshErrors = {
/** /**
* BadRequest | InvalidRequestError * ProjectCopyError | InvalidRequestError
*/ */
400: EffectHttpApiErrorBadRequest | InvalidRequestError 400: ProjectCopyError | InvalidRequestError
} }
export type ExperimentalProjectCopyRefreshError = export type ExperimentalProjectCopyRefreshError =

View file

@ -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": { "/global/health": {
"get": { "get": {
"tags": ["global"], "tags": ["global"],
@ -3774,14 +3834,6 @@
}, },
"required": true "required": true
}, },
{
"name": "directory",
"in": "query",
"schema": {
"type": "string"
},
"required": false
},
{ {
"name": "workspace", "name": "workspace",
"in": "query", "in": "query",
@ -3803,13 +3855,13 @@
} }
}, },
"400": { "400": {
"description": "BadRequest | InvalidRequestError", "description": "ProjectCopyError | InvalidRequestError",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"anyOf": [ "anyOf": [
{ {
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest" "$ref": "#/components/schemas/ProjectCopyError"
}, },
{ {
"$ref": "#/components/schemas/InvalidRequestError" "$ref": "#/components/schemas/InvalidRequestError"
@ -3834,6 +3886,12 @@
}, },
"directory": { "directory": {
"type": "string" "type": "string"
},
"name": {
"type": "string"
},
"context": {
"type": "string"
} }
}, },
"required": ["strategy", "directory"], "required": ["strategy", "directory"],
@ -3883,13 +3941,13 @@
"description": "Project copy removed" "description": "Project copy removed"
}, },
"400": { "400": {
"description": "BadRequest | InvalidRequestError", "description": "ProjectCopyError | InvalidRequestError",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"anyOf": [ "anyOf": [
{ {
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest" "$ref": "#/components/schemas/ProjectCopyError"
}, },
{ {
"$ref": "#/components/schemas/InvalidRequestError" "$ref": "#/components/schemas/InvalidRequestError"
@ -3961,13 +4019,13 @@
"description": "Project copies refreshed" "description": "Project copies refreshed"
}, },
"400": { "400": {
"description": "BadRequest | InvalidRequestError", "description": "ProjectCopyError | InvalidRequestError",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"anyOf": [ "anyOf": [
{ {
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest" "$ref": "#/components/schemas/ProjectCopyError"
}, },
{ {
"$ref": "#/components/schemas/InvalidRequestError" "$ref": "#/components/schemas/InvalidRequestError"
@ -11953,6 +12011,9 @@
{ {
"$ref": "#/components/schemas/EventSessionNextModelSwitched" "$ref": "#/components/schemas/EventSessionNextModelSwitched"
}, },
{
"$ref": "#/components/schemas/EventSessionNextMoved"
},
{ {
"$ref": "#/components/schemas/EventSessionNextPrompted" "$ref": "#/components/schemas/EventSessionNextPrompted"
}, },
@ -12312,6 +12373,27 @@
"required": ["_tag", "message"], "required": ["_tag", "message"],
"additionalProperties": false "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": { "SnapshotFileDiff": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -14356,6 +14438,40 @@
"required": ["id", "type", "properties"], "required": ["id", "type", "properties"],
"additionalProperties": false "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", "type": "object",
"properties": { "properties": {
@ -16782,6 +16898,9 @@
{ {
"$ref": "#/components/schemas/SyncEventSessionNextModelSwitched" "$ref": "#/components/schemas/SyncEventSessionNextModelSwitched"
}, },
{
"$ref": "#/components/schemas/SyncEventSessionNextMoved"
},
{ {
"$ref": "#/components/schemas/SyncEventSessionNextPrompted" "$ref": "#/components/schemas/SyncEventSessionNextPrompted"
}, },
@ -19094,6 +19213,27 @@
"required": ["_tag", "projectID", "message"], "required": ["_tag", "projectID", "message"],
"additionalProperties": false "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": { "PtyNotFoundError": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -20117,6 +20257,16 @@
"required": ["id", "type", "properties"], "required": ["id", "type", "properties"],
"additionalProperties": false "additionalProperties": false
}, },
"MoveSessionDestination": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
},
"ModelV2Info": { "ModelV2Info": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -20355,6 +20505,20 @@
], ],
"additionalProperties": false "additionalProperties": false
}, },
"LocationRef": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"workspaceID": {
"type": "string",
"pattern": "^wrk"
}
},
"required": ["directory"],
"additionalProperties": false
},
"PromptSource": { "PromptSource": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -21200,6 +21364,60 @@
"required": ["type", "id", "syncEvent"], "required": ["type", "id", "syncEvent"],
"additionalProperties": false "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": { "SyncEventSessionNextPrompted": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -22653,20 +22871,6 @@
"required": ["id", "request", "mode", "hidden", "permissions"], "required": ["id", "request", "mode", "hidden", "permissions"],
"additionalProperties": false "additionalProperties": false
}, },
"LocationRef": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"workspaceID": {
"type": "string",
"pattern": "^wrk"
}
},
"required": ["directory"],
"additionalProperties": false
},
"SessionV2Info": { "SessionV2Info": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -24279,6 +24483,40 @@
"required": ["id", "type", "properties"], "required": ["id", "type", "properties"],
"additionalProperties": false "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": { "EventSessionNextPrompted": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -26564,6 +26802,10 @@
"name": "control", "name": "control",
"description": "Control plane routes." "description": "Control plane routes."
}, },
{
"name": "controlPlane",
"description": "Control-plane orchestration routes."
},
{ {
"name": "global", "name": "global",
"description": "Global server routes." "description": "Global server routes."