瀏覽代碼

feat(core): refactor project copies for v2 (#31943)

James Long 2 月之前
父節點
當前提交
c2e6b18076
共有 33 個文件被更改,包括 1461 次插入829 次删除
  1. 154 49
      packages/core/schema.json
  2. 1 0
      packages/core/src/database/migration.gen.ts
  3. 27 0
      packages/core/src/database/migration/20260612174303_project_dir_strategy.ts
  4. 10 25
      packages/core/src/database/schema.gen.ts
  5. 12 0
      packages/core/src/location-layer.ts
  6. 21 47
      packages/core/src/project.ts
  7. 11 19
      packages/core/src/project/copy-strategies.ts
  8. 152 132
      packages/core/src/project/copy.ts
  9. 162 0
      packages/core/src/project/directories.ts
  10. 20 0
      packages/core/src/project/schema.ts
  11. 6 5
      packages/core/src/project/sql.ts
  12. 2 1
      packages/core/test/location-layer.test.ts
  13. 4 1
      packages/core/test/move-session.test.ts
  14. 104 29
      packages/core/test/project-copy.test.ts
  15. 69 0
      packages/core/test/project-directories.test.ts
  16. 5 50
      packages/core/test/project.test.ts
  17. 15 25
      packages/opencode/src/project/project.ts
  18. 13 69
      packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts
  19. 28 102
      packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts
  20. 46 10
      packages/opencode/test/project/project-directory.test.ts
  21. 3 3
      packages/opencode/test/project/project.test.ts
  22. 12 0
      packages/opencode/test/server/httpapi-exercise/index.ts
  23. 20 8
      packages/opencode/test/server/project-copy.test.ts
  24. 136 96
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  25. 146 109
      packages/sdk/js/src/v2/gen/types.gen.ts
  26. 2 0
      packages/server/src/api.ts
  27. 12 3
      packages/server/src/groups/location.ts
  28. 57 0
      packages/server/src/groups/project-copy.ts
  29. 2 0
      packages/server/src/handlers.ts
  30. 69 0
      packages/server/src/handlers/project-copy.ts
  31. 124 41
      packages/tui/src/component/dialog-move-session.tsx
  32. 15 3
      packages/tui/src/component/prompt/move.tsx
  33. 1 2
      packages/tui/src/context/project.tsx

+ 154 - 49
packages/core/schema.json

@@ -1,8 +1,10 @@
 {
   "version": "7",
   "dialect": "sqlite",
-  "id": "abd2f920-b822-49af-b8a7-2e48367d424f",
-  "prevIds": ["f25f9126-c7dc-4882-9ff4-af27e11d2da1"],
+  "id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
+  "prevIds": [
+    "abd2f920-b822-49af-b8a7-2e48367d424f"
+  ],
   "ddl": [
     {
       "name": "workspace",
@@ -622,7 +624,7 @@
     },
     {
       "type": "text",
-      "notNull": true,
+      "notNull": false,
       "autoincrement": false,
       "default": null,
       "generated": null,
@@ -630,6 +632,16 @@
       "entityType": "columns",
       "table": "project_directory"
     },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "strategy",
+      "entityType": "columns",
+      "table": "project_directory"
+    },
     {
       "type": "integer",
       "notNull": true,
@@ -1501,9 +1513,13 @@
       "table": "session_share"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1512,9 +1528,13 @@
       "table": "workspace"
     },
     {
-      "columns": ["active_account_id"],
+      "columns": [
+        "active_account_id"
+      ],
       "tableTo": "account",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "SET NULL",
       "nameExplicit": false,
@@ -1523,9 +1543,13 @@
       "table": "account_state"
     },
     {
-      "columns": ["aggregate_id"],
+      "columns": [
+        "aggregate_id"
+      ],
       "tableTo": "event_sequence",
-      "columnsTo": ["aggregate_id"],
+      "columnsTo": [
+        "aggregate_id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1534,9 +1558,13 @@
       "table": "event"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1545,9 +1573,13 @@
       "table": "permission"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1556,9 +1588,13 @@
       "table": "project_directory"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1567,9 +1603,13 @@
       "table": "message"
     },
     {
-      "columns": ["message_id"],
+      "columns": [
+        "message_id"
+      ],
       "tableTo": "message",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1578,9 +1618,13 @@
       "table": "part"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1589,9 +1633,13 @@
       "table": "session_context_epoch"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1600,9 +1648,13 @@
       "table": "session_input"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1611,9 +1663,13 @@
       "table": "session_message"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1622,9 +1678,13 @@
       "table": "session"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1633,9 +1693,13 @@
       "table": "todo"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1644,133 +1708,174 @@
       "table": "session_share"
     },
     {
-      "columns": ["email", "url"],
+      "columns": [
+        "email",
+        "url"
+      ],
       "nameExplicit": false,
       "name": "control_account_pk",
       "entityType": "pks",
       "table": "control_account"
     },
     {
-      "columns": ["project_id", "directory"],
+      "columns": [
+        "project_id",
+        "directory"
+      ],
       "nameExplicit": false,
       "name": "project_directory_pk",
       "entityType": "pks",
       "table": "project_directory"
     },
     {
-      "columns": ["session_id", "position"],
+      "columns": [
+        "session_id",
+        "position"
+      ],
       "nameExplicit": false,
       "name": "todo_pk",
       "entityType": "pks",
       "table": "todo"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "workspace_pk",
       "table": "workspace",
       "entityType": "pks"
     },
     {
-      "columns": ["name"],
+      "columns": [
+        "name"
+      ],
       "nameExplicit": false,
       "name": "data_migration_pk",
       "table": "data_migration",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "account_state_pk",
       "table": "account_state",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "account_pk",
       "table": "account",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "credential_pk",
       "table": "credential",
       "entityType": "pks"
     },
     {
-      "columns": ["aggregate_id"],
+      "columns": [
+        "aggregate_id"
+      ],
       "nameExplicit": false,
       "name": "event_sequence_pk",
       "table": "event_sequence",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "event_pk",
       "table": "event",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "permission_pk",
       "table": "permission",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "project_pk",
       "table": "project",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "message_pk",
       "table": "message",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "part_pk",
       "table": "part",
       "entityType": "pks"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "nameExplicit": false,
       "name": "session_context_epoch_pk",
       "table": "session_context_epoch",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "session_input_pk",
       "table": "session_input",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "session_message_pk",
       "table": "session_message",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "session_pk",
       "table": "session",
       "entityType": "pks"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "nameExplicit": false,
       "name": "session_share_pk",
       "table": "session_share",
@@ -2088,4 +2193,4 @@
     }
   ],
   "renames": []
-}
+}

+ 1 - 0
packages/core/src/database/migration.gen.ts

@@ -36,5 +36,6 @@ export const migrations = (
     import("./migration/20260605042240_add_context_epoch_agent"),
     import("./migration/20260611035744_credential"),
     import("./migration/20260611192811_lush_chimera"),
+    import("./migration/20260612174303_project_dir_strategy"),
   ])
 ).map((module) => module.default) satisfies DatabaseMigration.Migration[]

+ 27 - 0
packages/core/src/database/migration/20260612174303_project_dir_strategy.ts

@@ -0,0 +1,27 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+export default {
+  id: "20260612174303_project_dir_strategy",
+  up(tx) {
+    return Effect.gen(function* () {
+      yield* tx.run(`ALTER TABLE \`project_directory\` ADD \`strategy\` text;`)
+      yield* tx.run(`PRAGMA foreign_keys=OFF;`)
+      yield* tx.run(`
+        CREATE TABLE \`__new_project_directory\` (
+          \`project_id\` text NOT NULL,
+          \`directory\` text NOT NULL,
+          \`type\` text,
+          \`strategy\` text,
+          \`time_created\` integer NOT NULL,
+          CONSTRAINT \`project_directory_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
+          CONSTRAINT \`fk_project_directory_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
+        );
+      `)
+      yield* tx.run(`INSERT INTO \`__new_project_directory\`(\`project_id\`, \`directory\`, \`type\`, \`time_created\`) SELECT \`project_id\`, \`directory\`, \`type\`, \`time_created\` FROM \`project_directory\`;`)
+      yield* tx.run(`DROP TABLE \`project_directory\`;`)
+      yield* tx.run(`ALTER TABLE \`__new_project_directory\` RENAME TO \`project_directory\`;`)
+      yield* tx.run(`PRAGMA foreign_keys=ON;`)
+    })
+  },
+} satisfies DatabaseMigration.Migration

+ 10 - 25
packages/core/src/database/schema.gen.ts

@@ -101,7 +101,8 @@ export default {
         CREATE TABLE \`project_directory\` (
           \`project_id\` text NOT NULL,
           \`directory\` text NOT NULL,
-          \`type\` text NOT NULL,
+          \`type\` text,
+          \`strategy\` text,
           \`time_created\` integer NOT NULL,
           CONSTRAINT \`project_directory_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
           CONSTRAINT \`fk_project_directory_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
@@ -240,32 +241,16 @@ export default {
       `)
       yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
       yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
-      yield* tx.run(
-        `CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`,
-      )
-      yield* tx.run(
-        `CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`,
-      )
+      yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`)
+      yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`)
       yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
       yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
-      yield* tx.run(
-        `CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
-      )
-      yield* tx.run(
-        `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
-      )
-      yield* tx.run(
-        `CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
-      )
-      yield* tx.run(
-        `CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
-      )
-      yield* tx.run(
-        `CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
-      )
-      yield* tx.run(
-        `CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
-      )
+      yield* tx.run(`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`)
+      yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`)
+      yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`)
+      yield* tx.run(`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
+      yield* tx.run(`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`)
+      yield* tx.run(`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`)
       yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
       yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
       yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)

+ 12 - 0
packages/core/src/location-layer.ts

@@ -9,11 +9,14 @@ import { CommandV2 } from "./command"
 import { AgentV2 } from "./agent"
 import { PluginBoot } from "./plugin/boot"
 import { Project } from "./project"
+import { ProjectCopy } from "./project/copy"
+import { ProjectDirectories } from "./project/directories"
 import { EventV2 } from "./event"
 import { Credential } from "./credential"
 import { Npm } from "./npm"
 import { ModelsDev } from "./models-dev"
 import { FSUtil } from "./fs-util"
+import { Git } from "./git"
 import { Global } from "./global"
 import { Database } from "./database/database"
 import { PermissionV2 } from "./permission"
@@ -63,6 +66,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
       CommandV2.locationLayer,
       AgentV2.locationLayer,
       PluginBoot.locationLayer,
+      ProjectCopy.locationLayer,
       FileSystem.locationLayer,
       Watcher.locationLayer,
       Pty.locationLayer,
@@ -98,6 +102,11 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
       Layer.provide(skillGuidance),
       Layer.provide(referenceGuidance),
     )
+
+    // Kick off a background project copy refresh to update locations now that we
+    // have a location
+    const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services))
+
     return Layer.mergeAll(
       boot,
       services,
@@ -110,6 +119,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
       runner,
       builtInTools,
       referenceGuidance,
+      projectCopyRefresh,
     ).pipe(Layer.fresh)
   },
   idleTimeToLive: "60 minutes",
@@ -120,10 +130,12 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
     Npm.defaultLayer,
     ModelsDev.defaultLayer,
     FSUtil.defaultLayer,
+    Git.defaultLayer,
     AppProcess.defaultLayer,
     Global.defaultLayer,
     Ripgrep.defaultLayer,
     Database.defaultLayer,
+    ProjectDirectories.defaultLayer,
     SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
     PermissionSaved.defaultLayer,
     RepositoryCache.defaultLayer,

+ 21 - 47
packages/core/src/project.ts

@@ -2,60 +2,41 @@ export * as ProjectV2 from "./project"
 export * as Project from "./project"
 
 import { Context, Effect, Layer, Schema } from "effect"
-import { asc, desc, eq } from "drizzle-orm"
 import path from "path"
-import { AbsolutePath, withStatics } from "./schema"
+import { AbsolutePath } from "./schema"
 import { FSUtil } from "./fs-util"
-import { Database } from "./database/database"
 import { Git } from "./git"
 import { LayerNode } from "./effect/layer-node"
 import { Hash } from "./util/hash"
-import { ProjectDirectoryTable } from "./project/sql"
+import { ProjectDirectories } from "./project/directories"
+import { ProjectSchema } from "./project/schema"
 
-export const ID = Schema.String.pipe(
-  Schema.brand("Project.ID"),
-  withStatics((schema) => ({
-    global: schema.make("global"),
-  })),
-)
-export type ID = typeof ID.Type
+export const ID = ProjectSchema.ID
+export type ID = ProjectSchema.ID
 
-export const Vcs = Schema.Union([
-  Schema.Struct({
-    type: Schema.Literal("git"),
-    store: AbsolutePath,
-  }),
-])
-export type Vcs = typeof Vcs.Type
+export const Vcs = ProjectSchema.Vcs
+export type Vcs = ProjectSchema.Vcs
 
 export class Info extends Schema.Class<Info>("Project.Info")({
   id: ID,
 }) {}
 
-export const DirectoriesInput = Schema.Struct({
-  projectID: ID,
-}).annotate({ identifier: "Project.DirectoriesInput" })
+export const DirectoriesInput = ProjectDirectories.ListInput
 export type DirectoriesInput = typeof DirectoriesInput.Type
 
-export const Directories = Schema.Array(
-  Schema.Struct({
-    directory: AbsolutePath,
-    type: Schema.Literals(["main", "root", "git_worktree"]),
-  }),
-).annotate({ identifier: "Project.Directories" })
+export const Directories = ProjectDirectories.ListOutput
 export type Directories = typeof Directories.Type
 
+export interface Resolved {
+  readonly previous?: ID
+  readonly id: ID
+  readonly directory: AbsolutePath
+  readonly vcs?: Vcs
+}
+
 export interface Interface {
   readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
-  readonly resolve: (input: AbsolutePath) => Effect.Effect<
-    {
-      previous?: ID
-      id: ID
-      directory: AbsolutePath
-      vcs?: Vcs
-    },
-    never
-  >
+  readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
   /**
    * Temporary bridge method for writing the resolved project ID to the repo-local cache.
    *
@@ -73,19 +54,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const db = (yield* Database.Service).db
     const fs = yield* FSUtil.Service
     const git = yield* Git.Service
+    const projectDirectories = yield* ProjectDirectories.Service
 
     const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
-      const rows = yield* db
-        .select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
-        .from(ProjectDirectoryTable)
-        .where(eq(ProjectDirectoryTable.project_id, input.projectID))
-        .orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
-        .all()
-        .pipe(Effect.orDie)
-      return rows.map((row) => ({ directory: AbsolutePath.make(row.directory), type: row.type }))
+      return yield* projectDirectories.list(input.projectID)
     })
 
     const cached = Effect.fnUntraced(function* (dir: string) {
@@ -156,8 +130,8 @@ export const layer = Layer.effect(
 )
 
 export const defaultLayer = layer.pipe(
-  Layer.provide(Database.defaultLayer),
   Layer.provide(FSUtil.defaultLayer),
   Layer.provide(Git.defaultLayer),
+  Layer.provideMerge(ProjectDirectories.defaultLayer),
 )
-export const node = LayerNode.make(layer, [Database.node, FSUtil.node, Git.node])
+export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node])

+ 11 - 19
packages/core/src/project/copy-strategies.ts

@@ -1,20 +1,18 @@
 import path from "path"
 import { Effect } from "effect"
 import { AbsolutePath } from "../schema"
-import { FSUtil } from "../fs-util"
 import { Git } from "../git"
-import { DirectoryUnavailableError, type Copy, type Strategy, type StrategyID } from "./copy"
+import { DirectoryUnavailableError, StrategyID, type ListEntry, type Strategy } from "./copy"
 
-export function makeStrategies(input: {
+export function makeGitWorktreeStrategy(input: {
   git: Git.Interface
-  fs: FSUtil.Interface
   canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
 }) {
   const repo = (sourceDirectory: AbsolutePath) =>
     ({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo
 
-  const gitWorktree: Strategy = {
-    id: "git_worktree",
+  return {
+    id: StrategyID.make("git_worktree"),
     create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
       yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
       return { directory: yield* input.canonical(options.directory) }
@@ -30,18 +28,12 @@ export function makeStrategies(input: {
       const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store
       const entries = yield* input.git.worktreeList(found)
       return yield* Effect.forEach(entries, (entry) =>
-        entry === core
-          ? Effect.succeed(undefined)
-          : input.canonical(entry).pipe(
-              Effect.map((directory) => ({ directory })),
-              Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
-            ),
-      ).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined)))
-    }),
-    detect: Effect.fn("ProjectCopy.GitWorktree.detect")(function* (inputDirectory) {
-      return yield* input.fs.isFile(path.join(inputDirectory, ".git"))
-    }),
-  }
+        input.canonical(entry).pipe(
+          Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const),
+          Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
+        ),
+      ).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
 
-  return new Map<StrategyID, Strategy>([[gitWorktree.id, gitWorktree]])
+    }),
+  } satisfies Strategy
 }

+ 152 - 132
packages/core/src/project/copy.ts

@@ -1,34 +1,29 @@
 export * as ProjectCopy from "./copy"
 
-import { and, eq, inArray } from "drizzle-orm"
 import { Context, Effect, Layer, Schema } from "effect"
 import path from "path"
 import { AbsolutePath } from "../schema"
 import { FSUtil } from "../fs-util"
 import { Git } from "../git"
-import { Database } from "../database/database"
-import { EventV2 } from "../event"
 import { LayerNode } from "../effect/layer-node"
 import { Project } from "../project"
-import { ProjectDirectoryTable } from "./sql"
-import { makeStrategies } from "./copy-strategies"
+import { ProjectDirectories } from "./directories"
+import { makeGitWorktreeStrategy } from "./copy-strategies"
 import { Slug } from "../util/slug"
+import { EventV2 } from "../event"
+import { Database } from "../database/database"
+import { Location } from "../location"
+import { PluginBoot } from "../plugin/boot"
 
-export const StrategyID = Schema.Literal("git_worktree")
+export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
 export type StrategyID = typeof StrategyID.Type
 
-export const DetectInput = Schema.Struct({
-  directory: AbsolutePath,
-}).annotate({ identifier: "ProjectCopy.DetectInput" })
-export type DetectInput = typeof DetectInput.Type
-
 export const CreateInput = Schema.Struct({
   projectID: Project.ID,
   strategy: StrategyID,
   sourceDirectory: AbsolutePath,
   directory: AbsolutePath,
   name: Schema.optional(Schema.String),
-  context: Schema.optional(Schema.String),
 }).annotate({ identifier: "ProjectCopy.CreateInput" })
 export type CreateInput = typeof CreateInput.Type
 
@@ -44,12 +39,22 @@ export const RefreshInput = Schema.Struct({
 }).annotate({ identifier: "ProjectCopy.RefreshInput" })
 export type RefreshInput = typeof RefreshInput.Type
 
+export const RefreshResult = Schema.Struct({
+  updated: Schema.Array(AbsolutePath),
+  removed: Schema.Array(AbsolutePath),
+}).annotate({ identifier: "ProjectCopy.RefreshResult" })
+export type RefreshResult = typeof RefreshResult.Type
+
 export const Copy = Schema.Struct({
   directory: AbsolutePath,
 }).annotate({ identifier: "ProjectCopy.Copy" })
 export type Copy = typeof Copy.Type
 
-export type DirectoryType = "main" | "root" | StrategyID
+export const ListEntry = Schema.Struct({
+  directory: AbsolutePath,
+  type: Schema.Literals(["root", "copy"]),
+}).annotate({ identifier: "ProjectCopy.ListEntry" })
+export type ListEntry = typeof ListEntry.Type
 
 export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
   "ProjectCopy.SourceDirectoryNotFoundError",
@@ -66,16 +71,27 @@ export class DirectoryUnavailableError extends Schema.TaggedErrorClass<Directory
   { directory: AbsolutePath },
 ) {}
 
-export class StrategyNotFoundError extends Schema.TaggedErrorClass<StrategyNotFoundError>()(
-  "ProjectCopy.StrategyNotFoundError",
+export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
+  "ProjectCopy.InvalidDirectoryError",
   { directory: AbsolutePath },
 ) {}
 
+export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
+  "ProjectCopy.StrategyUnavailableError",
+  { strategy: StrategyID },
+) {}
+
+export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
+  "ProjectCopy.DuplicateStrategyError",
+  { strategy: StrategyID },
+) {}
+
 export type Error =
   | SourceDirectoryNotFoundError
   | DestinationExistsError
   | DirectoryUnavailableError
-  | StrategyNotFoundError
+  | InvalidDirectoryError
+  | StrategyUnavailableError
   | Git.WorktreeError
 
 export interface Strategy {
@@ -88,8 +104,7 @@ export interface Strategy {
     directory: AbsolutePath
     force: boolean
   }) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
-  readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
-  readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
+  readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
 }
 
 export const Event = {
@@ -100,21 +115,46 @@ export const Event = {
 }
 
 export interface Interface {
-  readonly detect: (input: DetectInput) => Effect.Effect<StrategyID | undefined>
+  readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
   readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
   readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
-  readonly refresh: (input: RefreshInput) => Effect.Effect<void, Error>
+  readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
 
+export const refreshAfterBoot = Effect.gen(function* () {
+  const location = yield* Location.Service
+  const boot = yield* PluginBoot.Service
+  const copies = yield* Service
+  yield* Effect.gen(function* () {
+    yield* boot.wait()
+    yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
+    const result = yield* copies.refresh({ projectID: location.project.id })
+    yield* Effect.logInfo("project copy refresh done", {
+      projectID: location.project.id,
+      updated: result.updated,
+      removed: result.removed,
+    })
+  }).pipe(
+    Effect.catchCause((cause) => Effect.logWarning("project copy refresh failed", { cause })),
+    Effect.forkScoped,
+    Effect.asVoid,
+  )
+})
+
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const fs = yield* FSUtil.Service
     const git = yield* Git.Service
-    const events = yield* EventV2.Service
+    const directories = yield* ProjectDirectories.Service
     const db = (yield* Database.Service).db
+    const events = yield* EventV2.Service
+
+    const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
+      if (update) yield* events.publish(Event.Updated, { projectID })
+    })
 
     const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
       const resolved = AbsolutePath.make(FSUtil.resolve(input))
@@ -122,76 +162,34 @@ export const layer = Layer.effect(
       return resolved
     })
 
-    const registry = makeStrategies({ git, fs, canonical })
+    const registry = new Map<StrategyID, Strategy>()
 
-    const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
-      const sourceDirectory = yield* canonical(input)
-      const row = yield* db
-        .select({ directory: ProjectDirectoryTable.directory })
-        .from(ProjectDirectoryTable)
-        .where(
-          and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, sourceDirectory)),
-        )
-        .get()
-        .pipe(Effect.orDie)
-      if (!row) return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
-      return sourceDirectory
+    const register = Effect.fn("ProjectCopy.register")(function* (strategy: Strategy) {
+      if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
+      registry.set(strategy.id, strategy)
     })
 
-    const insert = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath, type: StrategyID) {
-      return yield* db
-        .transaction(
-          (tx) =>
-            Effect.gen(function* () {
-              const row = yield* tx
-                .select({ directory: ProjectDirectoryTable.directory })
-                .from(ProjectDirectoryTable)
-                .where(
-                  and(
-                    eq(ProjectDirectoryTable.project_id, projectID),
-                    eq(ProjectDirectoryTable.directory, copyDirectory),
-                  ),
-                )
-                .get()
-              if (row) return false
-              yield* tx
-                .insert(ProjectDirectoryTable)
-                .values({ project_id: projectID, directory: copyDirectory, type })
-                .run()
-              return true
-            }),
-          { behavior: "immediate" },
-        )
-        .pipe(Effect.orDie)
-    })
+    // Register default strategies
+    yield* register(makeGitWorktreeStrategy({ git, canonical })).pipe(Effect.orDie)
 
-    const removeStored = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath) {
-      return (
-        (yield* db
-          .delete(ProjectDirectoryTable)
-          .where(
-            and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, copyDirectory)),
-          )
-          .returning({ directory: ProjectDirectoryTable.directory })
-          .get()
-          .pipe(Effect.orDie)) !== undefined
-      )
-    })
+    const strategies = () => Array.from(registry.values())
 
-    const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
-      if (update) yield* events.publish(Event.Updated, { projectID })
+    const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
+      const sourceDirectory = yield* canonical(input)
+      if (!(yield* directories.contains({ projectID, directory: sourceDirectory })))
+        return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
+      return sourceDirectory
     })
 
-    const strategy = (id: StrategyID) => registry.get(id) as Strategy
-
-    const detect = Effect.fn("ProjectCopy.detect")(function* (input: DetectInput) {
-      for (const strategy of registry.values()) {
-        if (yield* strategy.detect(input.directory)) return strategy.id
-      }
-      return undefined
+    const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
+      const found = registry.get(id)
+      if (!found) return yield* new StrategyUnavailableError({ strategy: id })
+      return found
     })
 
     const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
+      const selected = yield* getStrategy(input.strategy)
+      const sourceDirectory = yield* source(input.sourceDirectory, input.projectID)
       yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
       const name = input.name ?? Slug.create()
       let suffix = 1
@@ -202,78 +200,100 @@ export const layer = Layer.effect(
         copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
       }
 
-      const result = yield* strategy(input.strategy).create({
+      const result = yield* selected.create({
         directory: copyDirectory,
-        sourceDirectory: yield* source(input.sourceDirectory, input.projectID),
+        sourceDirectory,
       })
-      yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy))
+      yield* changed(
+        input.projectID,
+        yield* directories.create({
+          projectID: input.projectID,
+          directory: result.directory,
+          strategy: input.strategy,
+          behavior: "replace",
+        }),
+      )
       return result
     })
 
     const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
       const copyDirectory = yield* canonical(input.directory)
-      const id = yield* detect({ directory: copyDirectory })
-      if (!id) return yield* new StrategyNotFoundError({ directory: copyDirectory })
-      yield* strategy(id).remove({ directory: copyDirectory, force: input.force })
-      yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory))
+      const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
+      if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
+      yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
+        directory: copyDirectory,
+        force: input.force,
+      })
+      yield* changed(
+        input.projectID,
+        yield* directories.remove({ projectID: input.projectID, directory: copyDirectory }),
+      )
     })
 
     const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
-      const roots = yield* db
-        .select({ directory: ProjectDirectoryTable.directory })
-        .from(ProjectDirectoryTable)
-        .where(
-          and(
-            eq(ProjectDirectoryTable.project_id, input.projectID),
-            inArray(ProjectDirectoryTable.type, ["main", "root"]),
-          ),
-        )
-        .all()
-        .pipe(Effect.orDie)
-      const sourceDirectories = yield* Effect.forEach(roots, (item) => canonical(AbsolutePath.make(item.directory)), {
-        concurrency: "unbounded",
-      })
+      const stored = yield* directories.list(input.projectID)
+      const checked = yield* Effect.forEach(
+        stored,
+        (item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
+        { concurrency: "unbounded" },
+      )
+      const sourceDirectories = checked
+        .filter((item) => item.strategy === undefined && item.exists)
+        .map((item) => item.directory)
       const discovered = yield* Effect.forEach(
         sourceDirectories,
         (sourceDirectory) =>
-          Effect.forEach(registry.values(), (strategy) =>
-            strategy
-              .list(sourceDirectory)
-              .pipe(Effect.map((items) => items.map((item) => ({ ...item, type: strategy.id })))),
+          Effect.forEach(strategies(), (strategy) =>
+            strategy.list(sourceDirectory).pipe(
+              Effect.map((items) =>
+                items.map((item) => ({
+                  directory: item.directory,
+                  strategy: item.type === "copy" ? strategy.id : undefined,
+                })),
+              ),
+            ),
           ),
         { concurrency: "unbounded" },
       ).pipe(
         Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
       )
-      const stored = yield* db
-        .select({ directory: ProjectDirectoryTable.directory })
-        .from(ProjectDirectoryTable)
-        .where(eq(ProjectDirectoryTable.project_id, input.projectID))
-        .all()
-        .pipe(Effect.orDie)
-      const inserted = yield* Effect.forEach(discovered, (item) =>
-        insert(input.projectID, item.directory, item.type),
-      ).pipe(Effect.map((items) => items.some(Boolean)))
-      const removed = yield* Effect.forEach(stored, (item) =>
-        fs
-          .isDir(item.directory)
-          .pipe(
-            Effect.flatMap((exists) =>
-              exists ? Effect.succeed(false) : removeStored(input.projectID, AbsolutePath.make(item.directory)),
+      const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
+      const result = yield* db
+        .transaction((tx) =>
+          Effect.all({
+            updated: Effect.forEach(discovered, (item) =>
+              directories.create(
+                {
+                  projectID: input.projectID,
+                  directory: item.directory,
+                  strategy: item.strategy,
+                  behavior: "replace",
+                },
+                tx,
+              ),
             ),
-          ),
-      ).pipe(Effect.map((items) => items.some(Boolean)))
-      yield* changed(input.projectID, inserted || removed)
+            removed: Effect.forEach(removed, (directory) =>
+              directories.remove({ projectID: input.projectID, directory }, tx),
+            ),
+          }),
+        )
+        .pipe(Effect.orDie)
+      const changes = {
+        updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
+        removed: removed.filter((_, index) => result.removed[index]),
+      }
+      yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
+      return changes
     })
 
-    return Service.of({ detect, create, remove, refresh })
+    return Service.of({
+      register,
+      create,
+      remove,
+      refresh,
+    })
   }),
 )
 
-export const defaultLayer = layer.pipe(
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(FSUtil.defaultLayer),
-  Layer.provide(Git.defaultLayer),
-  Layer.provide(EventV2.defaultLayer),
-)
-export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node])
+export const locationLayer = layer
+export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node])

+ 162 - 0
packages/core/src/project/directories.ts

@@ -0,0 +1,162 @@
+export * as ProjectDirectories from "./directories"
+
+import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
+import { Context, Effect, Layer, Schema } from "effect"
+import { Database } from "../database/database"
+import { LayerNode } from "../effect/layer-node"
+import { AbsolutePath, optionalOmitUndefined } from "../schema"
+import { ProjectSchema } from "./schema"
+import { ProjectDirectoryTable } from "./sql"
+import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
+
+export interface Directory {
+  readonly directory: AbsolutePath
+  readonly strategy?: string
+}
+
+export const CreateInput = Schema.Struct({
+  projectID: ProjectSchema.ID,
+  directory: AbsolutePath,
+  strategy: Schema.optional(Schema.String),
+  behavior: Schema.Literals(["ignore", "replace"]).pipe(Schema.optional),
+})
+export type CreateInput = typeof CreateInput.Type
+
+export const RemoveInput = Schema.Struct({
+  projectID: ProjectSchema.ID,
+  directory: AbsolutePath,
+})
+export type RemoveInput = typeof RemoveInput.Type
+
+type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
+export type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
+
+export const ListInput = Schema.Struct({
+  projectID: ProjectSchema.ID,
+}).annotate({ identifier: "Project.DirectoriesInput" })
+export type ListInput = typeof ListInput.Type
+
+export const ListOutput = Schema.Array(
+  Schema.Struct({
+    directory: AbsolutePath,
+    strategy: optionalOmitUndefined(Schema.String),
+  }),
+).annotate({ identifier: "Project.Directories" })
+export type ListOutput = typeof ListOutput.Type
+
+export interface Interface {
+  readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<ReadonlyArray<Directory>>
+  readonly get: (input: {
+    projectID: ProjectSchema.ID
+    directory: AbsolutePath
+  }) => Effect.Effect<Directory | undefined>
+  readonly contains: (input: { projectID: ProjectSchema.ID; directory: AbsolutePath }) => Effect.Effect<boolean>
+  readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect<boolean>
+  readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect<boolean>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectDirectories") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const db = (yield* Database.Service).db
+
+    const create = Effect.fn("ProjectDirectories.create")(function* (input: CreateInput, tx?: Transaction) {
+      const insert = (tx ?? db)
+        .insert(ProjectDirectoryTable)
+        .values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
+      const query =
+        input.behavior === "replace"
+          ? insert.onConflictDoUpdate({
+              target: [ProjectDirectoryTable.project_id, ProjectDirectoryTable.directory],
+              set: { strategy: input.strategy ?? null },
+              setWhere: input.strategy
+                ? or(isNull(ProjectDirectoryTable.strategy), ne(ProjectDirectoryTable.strategy, input.strategy))
+                : isNotNull(ProjectDirectoryTable.strategy),
+            })
+          : insert.onConflictDoNothing()
+      return (
+        (yield* query
+          .returning({ directory: ProjectDirectoryTable.directory })
+          .get()
+          .pipe(Effect.orDie)) !== undefined
+      )
+    })
+
+    const remove = Effect.fn("ProjectDirectories.remove")(function* (input: RemoveInput, tx?: Transaction) {
+      return (
+        (yield* (tx ?? db)
+          .delete(ProjectDirectoryTable)
+          .where(
+            and(
+              eq(ProjectDirectoryTable.project_id, input.projectID),
+              eq(ProjectDirectoryTable.directory, input.directory),
+            ),
+          )
+          .returning({ directory: ProjectDirectoryTable.directory })
+          .get()
+          .pipe(Effect.orDie)) !== undefined
+      )
+    })
+
+    const list = Effect.fn("ProjectDirectories.list")(function* (projectID: ProjectSchema.ID) {
+      const rows = yield* db
+        .select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
+        .from(ProjectDirectoryTable)
+        .where(eq(ProjectDirectoryTable.project_id, projectID))
+        .orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
+        .all()
+        .pipe(Effect.orDie)
+      return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
+    })
+
+    const contains = Effect.fn("ProjectDirectories.contains")(function* (input: {
+      projectID: ProjectSchema.ID
+      directory: AbsolutePath
+    }) {
+      return (
+        (yield* db
+          .select({ directory: ProjectDirectoryTable.directory })
+          .from(ProjectDirectoryTable)
+          .where(
+            and(
+              eq(ProjectDirectoryTable.project_id, input.projectID),
+              eq(ProjectDirectoryTable.directory, input.directory),
+            ),
+          )
+          .get()
+          .pipe(Effect.orDie)) !== undefined
+      )
+    })
+
+    const get = Effect.fn("ProjectDirectories.get")(function* (input: {
+      projectID: ProjectSchema.ID
+      directory: AbsolutePath
+    }) {
+      const row = yield* db
+        .select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
+        .from(ProjectDirectoryTable)
+        .where(
+          and(
+            eq(ProjectDirectoryTable.project_id, input.projectID),
+            eq(ProjectDirectoryTable.directory, input.directory),
+          ),
+        )
+        .get()
+        .pipe(Effect.orDie)
+      return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
+    })
+
+    return Service.of({
+      list,
+      get,
+      contains,
+      create,
+      remove,
+    })
+  }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
+export const node = LayerNode.make(layer, [Database.node])

+ 20 - 0
packages/core/src/project/schema.ts

@@ -0,0 +1,20 @@
+export * as ProjectSchema from "./schema"
+
+import { Schema } from "effect"
+import { AbsolutePath, withStatics } from "../schema"
+
+export const ID = Schema.String.pipe(
+  Schema.brand("Project.ID"),
+  withStatics((schema) => ({
+    global: schema.make("global"),
+  })),
+)
+export type ID = typeof ID.Type
+
+export const Vcs = Schema.Union([
+  Schema.Struct({
+    type: Schema.Literal("git"),
+    store: AbsolutePath,
+  }),
+])
+export type Vcs = typeof Vcs.Type

+ 6 - 5
packages/core/src/project/sql.ts

@@ -1,10 +1,10 @@
 import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
 import * as DatabasePath from "../database/path"
 import { Timestamps } from "../database/schema.sql"
-import { ProjectV2 } from "../project"
+import { ProjectSchema } from "./schema"
 
 export const ProjectTable = sqliteTable("project", {
-  id: text().$type<ProjectV2.ID>().primaryKey(),
+  id: text().$type<ProjectSchema.ID>().primaryKey(),
   worktree: DatabasePath.absoluteColumn().notNull(),
   vcs: text(),
   name: text(),
@@ -21,11 +21,12 @@ export const ProjectDirectoryTable = sqliteTable(
   "project_directory",
   {
     project_id: text()
-      .$type<ProjectV2.ID>()
+      .$type<ProjectSchema.ID>()
       .notNull()
       .references(() => ProjectTable.id, { onDelete: "cascade" }),
-    directory: text().notNull(),
-    type: text().$type<"main" | "root" | "git_worktree">().notNull(),
+    directory: DatabasePath.absoluteColumn().notNull(),
+    type: text().$type<"main" | "root" | "git_worktree">(),
+    strategy: text(),
     time_created: integer()
       .notNull()
       .$default(() => Date.now()),

+ 2 - 1
packages/core/test/location-layer.test.ts

@@ -27,7 +27,7 @@ import { ApplicationTools } from "../src/tool/application-tools"
 const applicationTools = ApplicationTools.layer
 const it = testEffect(
   Layer.merge(
-    applicationTools,
+    Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
     LocationServiceMap.layer.pipe(
       Layer.provide(applicationTools),
       Layer.provide(
@@ -135,4 +135,5 @@ describe("LocationServiceMap", () => {
       ),
     ),
   )
+
 })

+ 4 - 1
packages/core/test/move-session.test.ts

@@ -11,6 +11,7 @@ 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 { ProjectDirectories } from "@opencode-ai/core/project/directories"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -22,11 +23,13 @@ import { testEffect } from "./lib/effect"
 
 const database = Database.layerFromPath(":memory:")
 const events = EventV2.layer.pipe(Layer.provide(database))
+const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
 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),
+  Layer.provide(directories),
 )
 const store = SessionStore.layer.pipe(Layer.provide(database))
 const sessions = SessionV2.layer.pipe(
@@ -45,7 +48,7 @@ const layer = MoveSession.layer.pipe(
   Layer.provide(sessions),
 )
 const it = testEffect(
-  Layer.mergeAll(layer, database, events, project, projector, store, SessionExecution.noopLayer, sessions),
+  Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions),
 )
 
 function abs(input: string) {

+ 104 - 29
packages/core/test/project-copy.test.ts

@@ -12,23 +12,28 @@ import { EventV2 } from "@opencode-ai/core/event"
 import { Project } from "@opencode-ai/core/project"
 import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
 import { ProjectCopy } from "@opencode-ai/core/project/copy"
+import { ProjectDirectories } from "@opencode-ai/core/project/directories"
 import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
 
 const databaseLayer = Database.layerFromPath(":memory:")
 const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
+const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
 const copyLayer = ProjectCopy.layer.pipe(
   Layer.provide(databaseLayer),
+  Layer.provide(directoriesLayer),
   Layer.provide(eventLayer),
   Layer.provide(FSUtil.defaultLayer),
   Layer.provide(Git.defaultLayer),
 )
-const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer))
+const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer))
 
 function abs(input: string) {
   return AbsolutePath.make(input)
 }
 
+const gitWorktree = ProjectCopy.StrategyID.make("git_worktree")
+
 async function initRepo(directory: string) {
   await $`git init`.cwd(directory).quiet()
   await $`git config core.fsmonitor false`.cwd(directory).quiet()
@@ -55,7 +60,7 @@ function setup() {
       .pipe(Effect.orDie)
     yield* db
       .insert(ProjectDirectoryTable)
-      .values({ project_id: projectID, directory: sourceDirectory, type: "main" })
+      .values({ project_id: projectID, directory: sourceDirectory })
       .run()
       .pipe(Effect.orDie)
     return { root, sourceDirectory, projectID, db }
@@ -65,7 +70,7 @@ function setup() {
 function stored(projectID: Project.ID) {
   return Database.Service.use(({ db }) =>
     db
-      .select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
+      .select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
       .from(ProjectDirectoryTable)
       .where(eq(ProjectDirectoryTable.project_id, projectID))
       .all()
@@ -77,18 +82,40 @@ function stored(projectID: Project.ID) {
 }
 
 describe("ProjectCopy", () => {
-  it.live("detects linked git worktrees but not root checkouts", () =>
+  it.effect("accepts arbitrary non-empty strategy ids", () =>
+    Effect.sync(() => {
+      expect(String(ProjectCopy.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
+      expect(() => ProjectCopy.StrategyID.make("  acme/snapshot  ")).toThrow()
+      expect(() => ProjectCopy.StrategyID.make("   ")).toThrow()
+    }),
+  )
+
+  it.effect("rejects duplicate strategies and reports unavailable ids", () =>
     Effect.gen(function* () {
       const input = yield* setup()
       const copy = yield* ProjectCopy.Service
-      const target = abs(`${input.root.path}-copy-detected`)
-      yield* Effect.addFinalizer(() =>
-        Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
-      )
-      yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
+      const strategy: ProjectCopy.Strategy = {
+        id: ProjectCopy.StrategyID.make("test/duplicate"),
+        create: () => Effect.die("unused"),
+        remove: () => Effect.die("unused"),
+        list: () => Effect.succeed([]),
+      }
+
+      yield* copy.register(strategy)
+      expect(yield* copy.register(strategy).pipe(Effect.flip)).toBeInstanceOf(ProjectCopy.DuplicateStrategyError)
 
-      expect(yield* copy.detect({ directory: input.sourceDirectory })).toBeUndefined()
-      expect(yield* copy.detect({ directory: target })).toBe("git_worktree")
+      const unavailable = ProjectCopy.StrategyID.make("acme/missing")
+      const error = yield* copy
+        .create({
+          projectID: input.projectID,
+          strategy: unavailable,
+          sourceDirectory: input.sourceDirectory,
+          directory: abs(`${input.root.path}-missing-strategy`),
+          name: "copy",
+        })
+        .pipe(Effect.flip)
+      expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
+      if (error instanceof ProjectCopy.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
     }),
   )
 
@@ -110,7 +137,7 @@ describe("ProjectCopy", () => {
 
       const created = yield* copy.create({
         projectID: input.projectID,
-        strategy: "git_worktree",
+        strategy: gitWorktree,
         sourceDirectory: input.sourceDirectory,
         directory: parent,
         name: "copy",
@@ -118,15 +145,15 @@ describe("ProjectCopy", () => {
       expect(created.directory).toBe(target)
       expect(yield* stored(input.projectID)).toEqual(
         [
-          { directory: input.sourceDirectory, type: "main" as const },
-          { directory: created.directory, type: "git_worktree" as const },
+          { directory: input.sourceDirectory, strategy: null },
+          { directory: created.directory, strategy: "git_worktree" },
         ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
       )
       expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
 
       yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
 
-      expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
+      expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
       expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
     }),
   )
@@ -142,7 +169,7 @@ describe("ProjectCopy", () => {
       )
       const created = yield* copy.create({
         projectID: input.projectID,
-        strategy: "git_worktree",
+        strategy: gitWorktree,
         sourceDirectory: input.sourceDirectory,
         directory: parent,
         name: "copy",
@@ -158,7 +185,7 @@ describe("ProjectCopy", () => {
         expect(error.operation).toBe("remove")
         expect(error.forceRequired).toBe(true)
       }
-      expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, type: "git_worktree" })
+      expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git_worktree" })
       expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
 
       yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
@@ -166,6 +193,28 @@ describe("ProjectCopy", () => {
     }),
   )
 
+  it.live("preserves copies whose stored strategy is unavailable", () =>
+    Effect.gen(function* () {
+      const input = yield* setup()
+      const copy = yield* ProjectCopy.Service
+      const unavailable = abs(`${input.root.path}-copy-unavailable`)
+      yield* Effect.promise(() => fs.mkdir(unavailable))
+      yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true })))
+      yield* input.db
+        .insert(ProjectDirectoryTable)
+        .values({ project_id: input.projectID, directory: unavailable, strategy: "acme/missing" })
+        .run()
+        .pipe(Effect.orDie)
+
+      const error = yield* copy
+        .remove({ projectID: input.projectID, directory: unavailable, force: false })
+        .pipe(Effect.flip)
+
+      expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
+      expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
+    }),
+  )
+
   it.live("adds a numeric suffix when a copy directory already exists", () =>
     Effect.gen(function* () {
       const input = yield* setup()
@@ -181,7 +230,7 @@ describe("ProjectCopy", () => {
 
       const created = yield* copy.create({
         projectID: input.projectID,
-        strategy: "git_worktree",
+        strategy: gitWorktree,
         sourceDirectory: input.sourceDirectory,
         directory: parent,
         name: "copy",
@@ -219,7 +268,7 @@ describe("ProjectCopy", () => {
       const error = yield* copy
         .create({
           projectID: input.projectID,
-          strategy: "git_worktree",
+          strategy: gitWorktree,
           sourceDirectory: input.sourceDirectory,
           directory: parent,
           name: "copy",
@@ -227,7 +276,8 @@ describe("ProjectCopy", () => {
         .pipe(Effect.flip)
 
       expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
-      expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
+      if (error instanceof ProjectCopy.DestinationExistsError)
+        expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
     }),
   )
 
@@ -263,25 +313,30 @@ describe("ProjectCopy", () => {
         Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
       )
       yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
+      yield* input.db
+        .insert(ProjectDirectoryTable)
+        .values({ project_id: input.projectID, directory: target })
+        .run()
+        .pipe(Effect.orDie)
       const fiber = yield* events
         .subscribe(ProjectCopy.Event.Updated)
         .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
       yield* Effect.yieldNow
 
-      yield* copy.refresh({ projectID: input.projectID })
-
       const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
+      expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
+
       expect(yield* stored(input.projectID)).toEqual(
         [
-          { directory: input.sourceDirectory, type: "main" as const },
-          { directory: discovered, type: "git_worktree" as const },
+          { directory: input.sourceDirectory, strategy: null },
+          { directory: discovered, strategy: "git_worktree" },
         ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
       )
       expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
 
       yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
-      yield* copy.refresh({ projectID: input.projectID })
-      expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
+      expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
+      expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
     }),
   )
 
@@ -303,8 +358,8 @@ describe("ProjectCopy", () => {
       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 },
+          { directory: input.sourceDirectory, strategy: null },
+          { directory: discovered, strategy: "git_worktree" },
         ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
       )
     }),
@@ -314,7 +369,27 @@ describe("ProjectCopy", () => {
     Effect.gen(function* () {
       const copy = yield* ProjectCopy.Service
 
-      yield* copy.refresh({ projectID: Project.ID.make("missing-project") })
+      expect(yield* copy.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
+        updated: [],
+        removed: [],
+      })
+    }),
+  )
+
+  it.live("refresh removes missing ordinary checkouts", () =>
+    Effect.gen(function* () {
+      const input = yield* setup()
+      const missing = abs(`${input.root.path}-missing-checkout`)
+      yield* input.db
+        .insert(ProjectDirectoryTable)
+        .values({ project_id: input.projectID, directory: missing })
+        .run()
+        .pipe(Effect.orDie)
+      const copy = yield* ProjectCopy.Service
+
+      expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
+
+      expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
     }),
   )
 })

+ 69 - 0
packages/core/test/project-directories.test.ts

@@ -0,0 +1,69 @@
+import { describe, expect } from "bun:test"
+import { Effect, Layer, Schema } from "effect"
+import { Database } from "@opencode-ai/core/database/database"
+import { EventV2 } from "@opencode-ai/core/event"
+import { Project } from "@opencode-ai/core/project"
+import { ProjectDirectories } from "@opencode-ai/core/project/directories"
+import { ProjectTable } from "@opencode-ai/core/project/sql"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { testEffect } from "./lib/effect"
+
+const database = Database.layerFromPath(":memory:")
+const events = EventV2.layer.pipe(Layer.provide(database))
+const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
+const it = testEffect(Layer.mergeAll(database, events, directories))
+
+const projectID = Project.ID.make("project-directories")
+const directory = AbsolutePath.make("/tmp/project-directories")
+
+function setup() {
+  return Database.Service.use(({ db }) =>
+    db
+      .insert(ProjectTable)
+      .values({ id: projectID, worktree: directory, sandboxes: [], time_created: 1, time_updated: 1 })
+      .onConflictDoNothing()
+      .run()
+      .pipe(Effect.orDie),
+  )
+}
+
+describe("ProjectDirectories", () => {
+  it.effect("decodes directory schemas", () =>
+    Effect.sync(() => {
+      expect(Schema.decodeUnknownSync(ProjectDirectories.ListInput)({ projectID })).toEqual({ projectID })
+      expect(Schema.decodeUnknownSync(ProjectDirectories.ListOutput)([{ directory }])).toEqual([{ directory }])
+    }),
+  )
+
+  it.effect("creates once and ignores conflicts", () =>
+    Effect.gen(function* () {
+      yield* setup()
+      const service = yield* ProjectDirectories.Service
+
+      expect(yield* service.create({ projectID, directory })).toBe(true)
+      expect(yield* service.create({ projectID, directory, strategy: "git_worktree" })).toBe(false)
+      expect(yield* service.list(projectID)).toEqual([{ directory, strategy: undefined }])
+    }),
+  )
+
+  it.effect("replaces the strategy when requested", () =>
+    Effect.gen(function* () {
+      yield* setup()
+      const service = yield* ProjectDirectories.Service
+      yield* service.create({ projectID, directory, strategy: "old/strategy" })
+
+      expect(
+        yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" }),
+      ).toBe(true)
+      expect(
+        yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" }),
+      ).toBe(false)
+      expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(true)
+      expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(false)
+      expect(
+        yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" }),
+      ).toBe(true)
+      expect(yield* service.list(projectID)).toEqual([{ directory, strategy: "new/strategy" }])
+    }),
+  )
+})

+ 5 - 50
packages/core/test/project.test.ts

@@ -4,24 +4,27 @@ import fs from "fs/promises"
 import path from "path"
 import { Effect, Layer, Schema } from "effect"
 import { ProjectV2 } from "@opencode-ai/core/project"
-import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
 import { Database } from "@opencode-ai/core/database/database"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Git } from "@opencode-ai/core/git"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { Hash } from "@opencode-ai/core/util/hash"
+import { ProjectDirectories } from "@opencode-ai/core/project/directories"
 import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
 
 const databaseLayer = Database.layerFromPath(":memory:")
+const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
 const it = testEffect(
   Layer.mergeAll(
     ProjectV2.layer.pipe(
-      Layer.provide(databaseLayer),
       Layer.provide(FSUtil.defaultLayer),
       Layer.provide(Git.defaultLayer),
+      Layer.provide(directoriesLayer),
+      Layer.provide(databaseLayer),
     ),
     databaseLayer,
+    directoriesLayer,
   ),
 )
 
@@ -51,54 +54,6 @@ async function rootCommit(dir: string) {
   return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
 }
 
-describe("Project directories schemas", () => {
-  it.effect("decodes project directory input and inline directory results", () =>
-    Effect.sync(() => {
-      expect(Schema.decodeUnknownSync(ProjectV2.DirectoriesInput)({ projectID: ProjectV2.ID.make("project") })).toEqual(
-        {
-          projectID: ProjectV2.ID.make("project"),
-        },
-      )
-      expect(
-        Schema.decodeUnknownSync(ProjectV2.Directories)([
-          { directory: AbsolutePath.make("/tmp/project"), type: "main" },
-        ]),
-      ).toEqual([{ directory: AbsolutePath.make("/tmp/project"), type: "main" }])
-    }),
-  )
-
-  it.effect("lists stored project directories newest first for the requested project", () =>
-    Effect.gen(function* () {
-      const project = yield* ProjectV2.Service
-      const { db } = yield* Database.Service
-      const projectID = ProjectV2.ID.make("directories-project")
-      const otherID = ProjectV2.ID.make("directories-other")
-      yield* db
-        .insert(ProjectTable)
-        .values([
-          { id: projectID, worktree: AbsolutePath.make("/repo"), sandboxes: [], time_created: 1, time_updated: 1 },
-          { id: otherID, worktree: AbsolutePath.make("/other"), sandboxes: [], time_created: 1, time_updated: 1 },
-        ])
-        .run()
-        .pipe(Effect.orDie)
-      yield* db
-        .insert(ProjectDirectoryTable)
-        .values([
-          { project_id: projectID, directory: AbsolutePath.make("/repo/z"), type: "root", time_created: 2 },
-          { project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main", time_created: 1 },
-          { project_id: otherID, directory: AbsolutePath.make("/other"), type: "main", time_created: 3 },
-        ])
-        .run()
-        .pipe(Effect.orDie)
-
-      expect(yield* project.directories({ projectID })).toEqual([
-        { directory: AbsolutePath.make("/repo/z"), type: "root" },
-        { directory: AbsolutePath.make("/repo/a"), type: "main" },
-      ])
-    }),
-  )
-})
-
 describe("ProjectV2.resolve", () => {
   it.live("returns global for non-git directory", () =>
     Effect.gen(function* () {

+ 15 - 25
packages/opencode/src/project/project.ts

@@ -2,6 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { and, eq, sql } from "drizzle-orm"
 import { Database } from "@opencode-ai/core/database/database"
 import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
+import { ProjectDirectories } from "@opencode-ai/core/project/directories"
 import { SessionTable } from "@opencode-ai/core/session/sql"
 import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
 import { Flag } from "@opencode-ai/core/flag/flag"
@@ -14,7 +15,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { AppProcess } from "@opencode-ai/core/process"
 import { ProjectV2 } from "@opencode-ai/core/project"
-import { ProjectCopy } from "@opencode-ai/core/project/copy"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -138,7 +138,7 @@ export const layer = Layer.effect(
     const proc = yield* AppProcess.Service
     const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
     const projectV2 = yield* ProjectV2.Service
-    const projectCopy = yield* ProjectCopy.Service
+    const projectDirectories = yield* ProjectDirectories.Service
     const events = yield* EventV2Bridge.Service
     const flags = yield* RuntimeFlags.Service
     const { db } = yield* Database.Service
@@ -197,6 +197,12 @@ export const layer = Layer.effect(
                   .run()
               }
 
+              // Project directories may be shared across distinct
+              // checkouts which have diverged. Clear the directory
+              // list and rely on it being re-populated to ensure
+              // accuracy
+              yield* d.delete(ProjectDirectoryTable).where(eq(ProjectDirectoryTable.project_id, oldID)).run()
+
               yield* d
                 .update(SessionTable)
                 .set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })
@@ -221,27 +227,11 @@ export const layer = Layer.effect(
     }) {
       if (input.projectID === ProjectV2.ID.global) return
       const opened = AbsolutePath.make(FSUtil.resolve(input.directory))
-      const type = yield* projectCopy.detect({ directory: opened })
-
-      yield* db
-        .transaction(
-          (d) =>
-            Effect.gen(function* () {
-              const hasMain = yield* d
-                .select({ directory: ProjectDirectoryTable.directory })
-                .from(ProjectDirectoryTable)
-                .where(
-                  and(eq(ProjectDirectoryTable.project_id, input.projectID), eq(ProjectDirectoryTable.type, "main")),
-                )
-                .get()
-              yield* d
-                .insert(ProjectDirectoryTable)
-                .values({ directory: opened, project_id: input.projectID, type: type ?? (hasMain ? "root" : "main") })
-                .onConflictDoNothing()
-                .run()
-            }),
-          { behavior: "immediate" },
-        )
+      yield* projectDirectories
+        .create({
+          directory: opened,
+          projectID: input.projectID,
+        })
         .pipe(
           Effect.catchCause((cause) =>
             Effect.logWarning("project directory persistence failed", { projectID: input.projectID, cause }),
@@ -505,7 +495,7 @@ export const layer = Layer.effect(
 export const defaultLayer = layer.pipe(
   Layer.provide(EventV2Bridge.defaultLayer),
   Layer.provide(ProjectV2.defaultLayer),
-  Layer.provide(ProjectCopy.defaultLayer),
+  Layer.provide(ProjectDirectories.defaultLayer),
   Layer.provide(AppProcess.defaultLayer),
   Layer.provide(CrossSpawnSpawner.defaultLayer),
   Layer.provide(FSUtil.defaultLayer),
@@ -520,7 +510,7 @@ export const node = LayerNode.make(layer, [
   AppProcess.node,
   CrossSpawnSpawner.node,
   ProjectV2.node,
-  ProjectCopy.node,
+  ProjectDirectories.node,
   EventV2Bridge.node,
   RuntimeFlags.node,
   Database.node,

+ 13 - 69
packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts

@@ -1,87 +1,31 @@
-import { ProjectCopy } from "@opencode-ai/core/project/copy"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { Schema } from "effect"
-import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
-import {
-  WorkspaceRoutingMiddleware,
-  WorkspaceRoutingQuery,
-  WorkspaceRoutingQueryFields,
-} from "../middleware/workspace-routing"
-import { described } from "./metadata"
+import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
 
-const root = "/experimental/project/:projectID/copy"
-const CopyQuery = Schema.Struct({
-  workspace: WorkspaceRoutingQueryFields.workspace,
+export const GenerateNamePayload = Schema.Struct({
+  context: Schema.optional(Schema.String),
 })
 
-export const CreatePayload = Schema.Struct({
-  strategy: ProjectCopy.StrategyID,
-  directory: ProjectCopy.CreateInput.fields.directory,
-  name: ProjectCopy.CreateInput.fields.name,
-  context: ProjectCopy.CreateInput.fields.context,
-})
-export const RemovePayload = Schema.Struct({
-  directory: ProjectCopy.RemoveInput.fields.directory,
-  force: ProjectCopy.RemoveInput.fields.force,
-})
-
-export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>("ProjectCopyError")(
-  {
-    name: Schema.Literal("ProjectCopyError"),
-    data: Schema.Struct({
-      message: Schema.String,
-      forceRequired: Schema.optional(Schema.Boolean),
-    }),
-  },
-  { httpApiStatus: 400 },
-) {}
-
-export const ProjectCopyApi = HttpApi.make("projectCopy").add(
-  HttpApiGroup.make("projectCopy")
+export const ProjectCopyApi = HttpApi.make("projectCopyName").add(
+  HttpApiGroup.make("projectCopyName")
     .add(
-      HttpApiEndpoint.post("create", root, {
-        params: { projectID: ProjectV2.ID },
-        query: CopyQuery,
-        payload: CreatePayload,
-        success: described(ProjectCopy.Copy, "Project copy created"),
-        error: ApiProjectCopyError,
-      }).annotateMerge(
-        OpenApi.annotations({
-          identifier: "experimental.projectCopy.create",
-          summary: "Create project copy",
-          description: "Create a local physical copy of a project using the selected strategy.",
-        }),
-      ),
-      HttpApiEndpoint.delete("remove", root, {
-        params: { projectID: ProjectV2.ID },
-        query: CopyQuery,
-        payload: RemovePayload,
-        success: described(HttpApiSchema.NoContent, "Project copy removed"),
-        error: ApiProjectCopyError,
-      }).annotateMerge(
-        OpenApi.annotations({
-          identifier: "experimental.projectCopy.remove",
-          summary: "Remove project copy",
-          description: "Remove a local physical copy of a project using the selected strategy.",
-        }),
-      ),
-      HttpApiEndpoint.post("refresh", `${root}/refresh`, {
+      HttpApiEndpoint.post("generateName", "/experimental/project/:projectID/copy/generate-name", {
         params: { projectID: ProjectV2.ID },
         query: WorkspaceRoutingQuery,
-        payload: HttpApiSchema.NoContent,
-        success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
-        error: ApiProjectCopyError,
+        payload: GenerateNamePayload,
+        success: Schema.Struct({ name: Schema.String }),
       }).annotateMerge(
         OpenApi.annotations({
-          identifier: "experimental.projectCopy.refresh",
-          summary: "Refresh project copies",
-          description: "Discover local project copies using one or all configured strategies.",
+          identifier: "experimental.projectCopy.generateName",
+          summary: "Generate project copy name",
+          description: "Generate a short name for a project copy from task context.",
         }),
       ),
     )
-    .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
+    .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy naming routes." }))
     .middleware(InstanceContextMiddleware)
     .middleware(WorkspaceRoutingMiddleware)
     .middleware(Authorization),

+ 28 - 102
packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts

@@ -1,75 +1,45 @@
-import { ProjectCopy } from "@opencode-ai/core/project/copy"
-import { Git } from "@opencode-ai/core/git"
-import { ProjectV2 } from "@opencode-ai/core/project"
-import { AbsolutePath } from "@opencode-ai/core/schema"
-import { InstanceState } from "@/effect/instance-state"
-import { Effect, Stream } from "effect"
-import { HttpApiBuilder } from "effect/unstable/httpapi"
-import { InstanceHttpApi } from "../api"
-import { ApiProjectCopyError, CreatePayload, RemovePayload } from "../groups/project-copy"
 import { Agent } from "@/agent/agent"
+import { Provider } from "@/provider/provider"
 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"
+import { LLMEvent } from "@opencode-ai/llm"
+import { Effect, Stream } from "effect"
+import { HttpApiBuilder } from "effect/unstable/httpapi"
+import { InstanceHttpApi } from "../api"
 
-const FALLBACK_AGENT: Agent.Info = {
-  name: "title",
-  mode: "primary" as const,
+const COPY_NAME_AGENT: Agent.Info = {
+  name: "project-copy-name",
+  mode: "primary",
   permission: [],
   options: {},
   native: true,
   prompt: "",
 }
 
-function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
-  return effect.pipe(
-    Effect.mapError(
-      (error) =>
-        new ApiProjectCopyError({
-          name: "ProjectCopyError",
-          data: {
-            message: message(error),
-            forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
-          },
-        }),
-    ),
-  )
-}
-
-export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
+export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopyName", (handlers) =>
   Effect.gen(function* () {
     const llm = yield* LLM.Service
-    const agent = yield* Agent.Service
     const provider = yield* Provider.Service
-    const service = yield* ProjectCopy.Service
 
     const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) {
       const text = context?.trim()
       if (!text) return Slug.create()
-      const [titleAgent, fallback] = yield* Effect.all(
-        [
-          agent.get("title").pipe(Effect.catch(() => Effect.succeed(FALLBACK_AGENT))),
-          provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined))),
-        ],
-        { concurrency: 2 },
-      )
+      const fallback = yield* provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined)))
       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 model =
+        (yield* provider.getSmallModel(fallback.providerID)) ??
+        (yield* provider.getModel(fallback.providerID, fallback.modelID))
       const sessionID = SessionID.descending()
       const result = yield* llm
         .stream({
-          agent: titleAgent,
+          agent: COPY_NAME_AGENT,
           user: {
             id: MessageID.ascending(),
             sessionID,
             role: "user",
             time: { created: Date.now() },
-            agent: titleAgent.name,
+            agent: COPY_NAME_AGENT.name,
             model: { providerID: model.providerID, modelID: model.id },
           },
           system: [],
@@ -78,12 +48,7 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
           model,
           sessionID,
           retries: 2,
-          messages: [
-            {
-              role: "user",
-              content: `Generate a short 3-4 word name that describes this task:\n${text}`,
-            },
-          ],
+          messages: [{ role: "user", content: `Generate a short 2-3 word name that describes this task:\n${text}` }],
         })
         .pipe(
           Stream.filter(LLMEvent.is.textDelta),
@@ -91,47 +56,20 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
           Stream.mkString,
         )
       const output = result.trim()
-      return output ? slugify(output.split(/\s+/).slice(0, 4).join(" ")) : Slug.create()
-    })
-
-    const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
-      params: { projectID: ProjectV2.ID }
-      payload: typeof CreatePayload.Type
-    }) {
-      const name =
-        ctx.payload.name ??
-        (yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create()))))
-      return yield* badRequest(
-        service.create({
-          ...ctx.payload,
-          name,
-          projectID: ctx.params.projectID,
-          sourceDirectory: AbsolutePath.make((yield* InstanceState.context).worktree),
-        }),
-      )
-    })
-
-    const remove = Effect.fn("ProjectCopyHttpApi.remove")(function* (ctx: {
-      params: { projectID: ProjectV2.ID }
-      payload: typeof RemovePayload.Type
-    }) {
-      yield* badRequest(
-        service.remove({
-          ...ctx.payload,
-          projectID: ctx.params.projectID,
-        }),
-      )
-    })
-
-    const refresh = Effect.fn("ProjectCopyHttpApi.refresh")(function* (ctx: { params: { projectID: ProjectV2.ID } }) {
-      yield* badRequest(
-        service.refresh({
-          projectID: ctx.params.projectID,
-        }),
-      )
+      return output ? slugify(output.split(/\s+/).slice(0, 3).join(" ")) : Slug.create()
     })
 
-    return handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh)
+    return handlers.handle("generateName", (ctx) =>
+      generateName(ctx.payload.context).pipe(
+        Effect.catchCause((cause) =>
+          Effect.logWarning("project copy name generation failed", {
+            projectID: ctx.params.projectID,
+            cause,
+          }).pipe(Effect.as(Slug.create())),
+        ),
+        Effect.map((name) => ({ name })),
+      ),
+    )
   }),
 )
 
@@ -143,15 +81,3 @@ function slugify(input: string) {
     .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
-}

+ 46 - 10
packages/opencode/test/project/project-directory.test.ts

@@ -26,7 +26,7 @@ function directories(projectID: ProjectV2.ID) {
         Effect.orDie,
         Effect.map((rows) =>
           rows
-            .map((row) => ({ directory: row.directory, type: row.type }))
+            .map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
             .toSorted((a, b) => a.directory.localeCompare(b.directory)),
         ),
       ),
@@ -41,7 +41,9 @@ describe("Project directory persistence", () => {
 
       const result = yield* project.fromDirectory(tmp)
 
-      expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }])
+      expect(yield* directories(result.project.id)).toEqual([
+        { directory: AbsolutePath.make(tmp), strategy: undefined },
+      ])
     }),
   )
 
@@ -54,7 +56,9 @@ describe("Project directory persistence", () => {
       const next = yield* project.fromDirectory(tmp)
 
       expect(next.project.id).toBe(result.project.id)
-      expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }])
+      expect(yield* directories(result.project.id)).toEqual([
+        { directory: AbsolutePath.make(tmp), strategy: undefined },
+      ])
     }),
   )
 
@@ -73,8 +77,8 @@ describe("Project directory persistence", () => {
 
       expect(yield* directories(main.project.id)).toEqual(
         [
-          { directory: tmp, type: "main" as const },
-          { directory: worktree, type: "git_worktree" as const },
+          { directory: AbsolutePath.make(tmp), strategy: undefined },
+          { directory: AbsolutePath.make(worktree), strategy: undefined },
         ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
       )
     }),
@@ -92,7 +96,9 @@ describe("Project directory persistence", () => {
 
       const result = yield* project.fromDirectory(worktree)
 
-      expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
+      expect(yield* directories(result.project.id)).toEqual([
+        { directory: AbsolutePath.make(worktree), strategy: undefined },
+      ])
     }),
   )
 
@@ -113,8 +119,8 @@ describe("Project directory persistence", () => {
 
       expect(yield* directories(main.project.id)).toEqual(
         [
-          { directory: tmp, type: "main" as const },
-          { directory: clone, type: "root" as const },
+          { directory: AbsolutePath.make(tmp), strategy: undefined },
+          { directory: AbsolutePath.make(clone), strategy: undefined },
         ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
       )
     }),
@@ -134,7 +140,9 @@ describe("Project directory persistence", () => {
 
       const result = yield* project.fromDirectory(worktree)
 
-      expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
+      expect(yield* directories(result.project.id)).toEqual([
+        { directory: AbsolutePath.make(worktree), strategy: undefined },
+      ])
     }),
   )
 
@@ -163,7 +171,35 @@ describe("Project directory persistence", () => {
 
       yield* project.fromDirectory(tmp)
 
-      expect(yield* directories(remoteID)).toEqual([{ directory: tmp, type: "main" }])
+      expect(yield* directories(remoteID)).toEqual([
+        { directory: AbsolutePath.make(tmp), strategy: undefined },
+      ])
+    }),
+  )
+
+  it.live("clears stale directories when the project id changes", () =>
+    Effect.gen(function* () {
+      const tmp = yield* tmpdirScoped({ git: true })
+      const project = yield* Project.Service
+      const original = yield* project.fromDirectory(tmp)
+      const stale = AbsolutePath.make(tmp + "-stale-checkout")
+      const { db } = yield* Database.Service
+      yield* db
+        .insert(ProjectDirectoryTable)
+        .values({ project_id: original.project.id, directory: stale })
+        .run()
+        .pipe(Effect.orDie)
+      const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-directory-test/migration"))
+      yield* Effect.promise(() =>
+        $`git remote add origin git@github.com:project-directory-test/migration.git`.cwd(tmp).quiet(),
+      )
+
+      yield* project.fromDirectory(tmp)
+
+      expect(yield* directories(original.project.id)).toEqual([])
+      expect(yield* directories(remoteID)).toEqual([
+        { directory: AbsolutePath.make(tmp), strategy: undefined },
+      ])
     }),
   )
 })

+ 3 - 3
packages/opencode/test/project/project.test.ts

@@ -19,7 +19,7 @@ import { NodePath } from "@effect/platform-node"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { AppProcess } from "@opencode-ai/core/process"
 import { ProjectV2 } from "@opencode-ai/core/project"
-import { ProjectCopy } from "@opencode-ai/core/project/copy"
+import { ProjectDirectories } from "@opencode-ai/core/project/directories"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { testEffect } from "../lib/effect"
 import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -73,7 +73,7 @@ function projectLayerWithFailure(failArg: string) {
     Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))),
     Layer.provide(mockGitFailure(failArg)),
     Layer.provide(ProjectV2.defaultLayer),
-    Layer.provide(ProjectCopy.defaultLayer),
+    Layer.provide(ProjectDirectories.defaultLayer),
     Layer.provide(EventV2Bridge.defaultLayer),
     Layer.provide(FSUtil.defaultLayer),
     Layer.provide(NodePath.layer),
@@ -86,7 +86,7 @@ function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.laye
   return Project.layer.pipe(
     Layer.provide(EventV2Bridge.defaultLayer),
     Layer.provide(ProjectV2.defaultLayer),
-    Layer.provide(ProjectCopy.defaultLayer),
+    Layer.provide(ProjectDirectories.defaultLayer),
     Layer.provide(AppProcess.defaultLayer),
     Layer.provide(FSUtil.defaultLayer),
     Layer.provide(NodePath.layer),

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

@@ -222,6 +222,18 @@ const scenarios: Scenario[] = [
       headers: ctx.headers(),
     }))
     .json(200, array, "status"),
+  http.protected
+    .post("/experimental/project/{projectID}/copy/generate-name", "experimental.projectCopy.generateName")
+    .seeded((ctx) => ctx.project())
+    .at((ctx) => ({
+      path: route("/experimental/project/{projectID}/copy/generate-name", { projectID: ctx.state.id }),
+      headers: ctx.headers(),
+      body: {},
+    }))
+    .json(200, (body) => {
+      object(body)
+      check(typeof body.name === "string" && body.name.length > 0, "generated copy name should be non-empty")
+    }),
   http.protected
     .post("/experimental/project/{projectID}/copy", "experimental.projectCopy.create")
     .seeded((ctx) => ctx.project())

+ 20 - 8
packages/opencode/test/server/project-copy.test.ts

@@ -34,7 +34,7 @@ function json<T>(response: HttpClientResponse.HttpClientResponse) {
 }
 
 describe("project directories and copies endpoints", () => {
-  type ProjectDirectory = { directory: string; type: "main" | "root" | "git_worktree" }
+  type ProjectDirectory = { directory: string; strategy?: string }
 
   it.instance(
     "lists directories and manages git worktree copies",
@@ -44,7 +44,7 @@ describe("project directories and copies endpoints", () => {
         const current = yield* request(test.directory, "/project/current")
         const projectID = (yield* json<{ id: string }>(current)).id
         const base = `/project/${projectID}`
-        const copies = `/experimental/project/${projectID}/copy`
+        const copies = `/experimental/project/${projectID}/copy?location%5Bdirectory%5D=${encodeURIComponent(test.directory)}`
         const createdParent = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
         const createdDirectory = path.join(createdParent, "copy")
         yield* Effect.addFinalizer(() =>
@@ -53,7 +53,15 @@ describe("project directories and copies endpoints", () => {
 
         const initial = yield* request(test.directory, `${base}/directories`)
         expect(initial.status).toBe(200)
-        expect(yield* json<ProjectDirectory[]>(initial)).toEqual([{ directory: test.directory, type: "main" }])
+        expect(yield* json<ProjectDirectory[]>(initial)).toEqual([{ directory: test.directory }])
+
+        const generated = yield* request(test.directory, `/experimental/project/${projectID}/copy/generate-name`, {
+          method: "POST",
+          headers: { "content-type": "application/json" },
+          body: JSON.stringify({ context: undefined }),
+        })
+        expect(generated.status).toBe(200)
+        expect((yield* json<{ name: string }>(generated)).name).toBeString()
 
         const create = yield* request(test.directory, copies, {
           method: "POST",
@@ -67,7 +75,7 @@ describe("project directories and copies endpoints", () => {
         const listed = yield* request(test.directory, `${base}/directories`)
         expect(yield* json<ProjectDirectory[]>(listed)).toContainEqual({
           directory: created.directory,
-          type: "git_worktree",
+          strategy: "git_worktree",
         })
 
         yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
@@ -94,14 +102,18 @@ describe("project directories and copies endpoints", () => {
           Effect.promise(() => fs.rm(externalDirectory, { recursive: true, force: true })).pipe(Effect.ignore),
         )
         yield* Effect.promise(() => $`git worktree add --detach ${externalDirectory} HEAD`.cwd(test.directory).quiet())
-        const refresh = yield* request(test.directory, `${copies}/refresh`, {
+        const refresh = yield* request(
+          test.directory,
+          `/experimental/project/${projectID}/copy/refresh?location%5Bdirectory%5D=${encodeURIComponent(test.directory)}`,
+          {
           method: "POST",
-        })
+          },
+        )
         expect(refresh.status).toBe(204)
         const refreshed = yield* request(test.directory, `${base}/directories`)
         expect(yield* json<ProjectDirectory[]>(refreshed)).toEqual([
-          { directory: externalDirectory, type: "git_worktree" },
-          { directory: test.directory, type: "main" },
+          { directory: externalDirectory, strategy: "git_worktree" },
+          { directory: test.directory },
         ])
       }),
     { git: true },

+ 136 - 96
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -36,12 +36,8 @@ import type {
   ExperimentalConsoleSwitchOrgResponses,
   ExperimentalControlPlaneMoveSessionErrors,
   ExperimentalControlPlaneMoveSessionResponses,
-  ExperimentalProjectCopyCreateErrors,
-  ExperimentalProjectCopyCreateResponses,
-  ExperimentalProjectCopyRefreshErrors,
-  ExperimentalProjectCopyRefreshResponses,
-  ExperimentalProjectCopyRemoveErrors,
-  ExperimentalProjectCopyRemoveResponses,
+  ExperimentalProjectCopyGenerateNameErrors,
+  ExperimentalProjectCopyGenerateNameResponses,
   ExperimentalResourceListErrors,
   ExperimentalResourceListResponses,
   ExperimentalSessionBackgroundErrors,
@@ -303,6 +299,12 @@ import type {
   V2PermissionSavedListResponses,
   V2PermissionSavedRemoveErrors,
   V2PermissionSavedRemoveResponses,
+  V2ProjectCopyCreateErrors,
+  V2ProjectCopyCreateResponses,
+  V2ProjectCopyRefreshErrors,
+  V2ProjectCopyRefreshResponses,
+  V2ProjectCopyRemoveErrors,
+  V2ProjectCopyRemoveResponses,
   V2ProviderGetErrors,
   V2ProviderGetResponses,
   V2ProviderListErrors,
@@ -842,60 +844,15 @@ export class Resource extends HeyApiClient {
 
 export class ProjectCopy extends HeyApiClient {
   /**
-   * Remove project copy
+   * Generate project copy name
    *
-   * Remove a local physical copy of a project using the selected strategy.
+   * Generate a short name for a project copy from task context.
    */
-  public remove<ThrowOnError extends boolean = false>(
+  public generateName<ThrowOnError extends boolean = false>(
     parameters: {
       projectID: string
-      workspace?: string
       directory?: string
-      force?: boolean
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "path", key: "projectID" },
-            { in: "query", key: "workspace" },
-            { in: "body", key: "directory" },
-            { in: "body", key: "force" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).delete<
-      ExperimentalProjectCopyRemoveResponses,
-      ExperimentalProjectCopyRemoveErrors,
-      ThrowOnError
-    >({
-      url: "/experimental/project/{projectID}/copy",
-      ...options,
-      ...params,
-      headers: {
-        "Content-Type": "application/json",
-        ...options?.headers,
-        ...params.headers,
-      },
-    })
-  }
-
-  /**
-   * Create project copy
-   *
-   * Create a local physical copy of a project using the selected strategy.
-   */
-  public create<ThrowOnError extends boolean = false>(
-    parameters: {
-      projectID: string
       workspace?: string
-      strategy?: "git_worktree"
-      directory?: string
-      name?: string
       context?: string
     },
     options?: Options<never, ThrowOnError>,
@@ -906,21 +863,19 @@ export class ProjectCopy extends HeyApiClient {
         {
           args: [
             { in: "path", key: "projectID" },
+            { in: "query", key: "directory" },
             { in: "query", key: "workspace" },
-            { in: "body", key: "strategy" },
-            { in: "body", key: "directory" },
-            { in: "body", key: "name" },
             { in: "body", key: "context" },
           ],
         },
       ],
     )
     return (options?.client ?? this.client).post<
-      ExperimentalProjectCopyCreateResponses,
-      ExperimentalProjectCopyCreateErrors,
+      ExperimentalProjectCopyGenerateNameResponses,
+      ExperimentalProjectCopyGenerateNameErrors,
       ThrowOnError
     >({
-      url: "/experimental/project/{projectID}/copy",
+      url: "/experimental/project/{projectID}/copy/generate-name",
       ...options,
       ...params,
       headers: {
@@ -930,42 +885,6 @@ export class ProjectCopy extends HeyApiClient {
       },
     })
   }
-
-  /**
-   * Refresh project copies
-   *
-   * Discover local project copies using one or all configured strategies.
-   */
-  public refresh<ThrowOnError extends boolean = false>(
-    parameters: {
-      projectID: string
-      directory?: string
-      workspace?: string
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "path", key: "projectID" },
-            { in: "query", key: "directory" },
-            { in: "query", key: "workspace" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).post<
-      ExperimentalProjectCopyRefreshResponses,
-      ExperimentalProjectCopyRefreshErrors,
-      ThrowOnError
-    >({
-      url: "/experimental/project/{projectID}/copy/refresh",
-      ...options,
-      ...params,
-    })
-  }
 }
 
 export class Adapter extends HeyApiClient {
@@ -6241,6 +6160,122 @@ export class Reference extends HeyApiClient {
   }
 }
 
+export class ProjectCopy2 extends HeyApiClient {
+  public remove<ThrowOnError extends boolean = false>(
+    parameters: {
+      projectID: string
+      location?: {
+        directory?: string
+        workspace?: string
+      }
+      directory?: string
+      force?: boolean
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "projectID" },
+            { in: "query", key: "location" },
+            { in: "body", key: "directory" },
+            { in: "body", key: "force" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).delete<
+      V2ProjectCopyRemoveResponses,
+      V2ProjectCopyRemoveErrors,
+      ThrowOnError
+    >({
+      url: "/experimental/project/{projectID}/copy",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
+  public create<ThrowOnError extends boolean = false>(
+    parameters: {
+      projectID: string
+      location?: {
+        directory?: string
+        workspace?: string
+      }
+      strategy?: string
+      directory?: string
+      name?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "projectID" },
+            { in: "query", key: "location" },
+            { in: "body", key: "strategy" },
+            { in: "body", key: "directory" },
+            { in: "body", key: "name" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<V2ProjectCopyCreateResponses, V2ProjectCopyCreateErrors, ThrowOnError>(
+      {
+        url: "/experimental/project/{projectID}/copy",
+        ...options,
+        ...params,
+        headers: {
+          "Content-Type": "application/json",
+          ...options?.headers,
+          ...params.headers,
+        },
+      },
+    )
+  }
+
+  public refresh<ThrowOnError extends boolean = false>(
+    parameters: {
+      projectID: string
+      location?: {
+        directory?: string
+        workspace?: string
+      }
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "projectID" },
+            { in: "query", key: "location" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<
+      V2ProjectCopyRefreshResponses,
+      V2ProjectCopyRefreshErrors,
+      ThrowOnError
+    >({
+      url: "/experimental/project/{projectID}/copy/refresh",
+      ...options,
+      ...params,
+    })
+  }
+}
+
 export class V2 extends HeyApiClient {
   private _health?: Health
   get health(): Health {
@@ -6316,6 +6351,11 @@ export class V2 extends HeyApiClient {
   get reference(): Reference {
     return (this._reference ??= new Reference({ client: this.client }))
   }
+
+  private _projectCopy?: ProjectCopy2
+  get projectCopy(): ProjectCopy2 {
+    return (this._projectCopy ??= new ProjectCopy2({ client: this.client }))
+  }
 }
 
 export class OpencodeClient extends HeyApiClient {

+ 146 - 109
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -56,6 +56,7 @@ export type Event =
   | EventPermissionV2Asked
   | EventPermissionV2Replied
   | EventReferenceUpdated
+  | EventProjectDirectoriesUpdated
   | EventFileWatcherUpdated
   | EventPtyCreated
   | EventPtyUpdated
@@ -75,7 +76,6 @@ export type Event =
   | EventMcpToolsChanged
   | EventMcpBrowserOpenFailed
   | EventCommandExecuted
-  | EventProjectDirectoriesUpdated
   | EventProjectUpdated
   | EventSessionStatus
   | EventSessionIdle
@@ -1294,6 +1294,13 @@ export type GlobalEvent = {
           [key: string]: unknown
         }
       }
+    | {
+        id: string
+        type: "project.directories.updated"
+        properties: {
+          projectID: string
+        }
+      }
     | {
         id: string
         type: "file.watcher.updated"
@@ -1479,13 +1486,6 @@ export type GlobalEvent = {
           messageID: string
         }
       }
-    | {
-        id: string
-        type: "project.directories.updated"
-        properties: {
-          projectID: string
-        }
-      }
     | {
         id: string
         type: "project.updated"
@@ -2464,14 +2464,6 @@ export type ProjectNotFoundError = {
   message: string
 }
 
-export type ProjectCopyError = {
-  name: "ProjectCopyError"
-  data: {
-    message: string
-    forceRequired?: boolean
-  }
-}
-
 export type PtyNotFoundError = {
   _tag: "PtyNotFoundError"
   ptyID: string
@@ -2767,6 +2759,14 @@ export type ProviderNotFoundError = {
   message: string
 }
 
+export type ProjectCopyError = {
+  name: "ProjectCopyError"
+  data: {
+    message: string
+    forceRequired?: boolean
+  }
+}
+
 export type EffectHttpApiErrorForbidden = {
   _tag: "Forbidden"
 }
@@ -3699,13 +3699,9 @@ export type ConfigV2ExperimentalPolicy = {
 
 export type ProjectDirectories = Array<{
   directory: string
-  type: "main" | "root" | "git_worktree"
+  strategy?: string
 }>
 
-export type ProjectCopyCopy = {
-  directory: string
-}
-
 export type LocationInfo = {
   directory: string
   workspaceID?: string
@@ -4226,6 +4222,10 @@ export type ReferenceInfo = {
   source: ReferenceLocalSource | ReferenceGitSource
 }
 
+export type ProjectCopyCopy = {
+  directory: string
+}
+
 export type EventModelsDevRefreshed = {
   id: string
   type: "models-dev.refreshed"
@@ -4938,6 +4938,14 @@ export type EventReferenceUpdated = {
   }
 }
 
+export type EventProjectDirectoriesUpdated = {
+  id: string
+  type: "project.directories.updated"
+  properties: {
+    projectID: string
+  }
+}
+
 export type EventFileWatcherUpdated = {
   id: string
   type: "file.watcher.updated"
@@ -5087,14 +5095,6 @@ export type EventCommandExecuted = {
   }
 }
 
-export type EventProjectDirectoriesUpdated = {
-  id: string
-  type: "project.directories.updated"
-  properties: {
-    projectID: string
-  }
-}
-
 export type EventProjectUpdated = {
   id: string
   type: "project.updated"
@@ -6994,107 +6994,41 @@ export type ProjectDirectoriesResponses = {
 
 export type ProjectDirectoriesResponse = ProjectDirectoriesResponses[keyof ProjectDirectoriesResponses]
 
-export type ExperimentalProjectCopyRemoveData = {
+export type ExperimentalProjectCopyGenerateNameData = {
   body?: {
-    directory: string
-    force: boolean
-  }
-  path: {
-    projectID: string
-  }
-  query?: {
-    workspace?: string
-  }
-  url: "/experimental/project/{projectID}/copy"
-}
-
-export type ExperimentalProjectCopyRemoveErrors = {
-  /**
-   * ProjectCopyError | InvalidRequestError
-   */
-  400: ProjectCopyError | InvalidRequestError
-}
-
-export type ExperimentalProjectCopyRemoveError =
-  ExperimentalProjectCopyRemoveErrors[keyof ExperimentalProjectCopyRemoveErrors]
-
-export type ExperimentalProjectCopyRemoveResponses = {
-  /**
-   * Project copy removed
-   */
-  204: void
-}
-
-export type ExperimentalProjectCopyRemoveResponse =
-  ExperimentalProjectCopyRemoveResponses[keyof ExperimentalProjectCopyRemoveResponses]
-
-export type ExperimentalProjectCopyCreateData = {
-  body?: {
-    strategy: "git_worktree"
-    directory: string
-    name?: string
     context?: string
   }
   path: {
     projectID: string
   }
-  query?: {
-    workspace?: string
-  }
-  url: "/experimental/project/{projectID}/copy"
-}
-
-export type ExperimentalProjectCopyCreateErrors = {
-  /**
-   * ProjectCopyError | InvalidRequestError
-   */
-  400: ProjectCopyError | InvalidRequestError
-}
-
-export type ExperimentalProjectCopyCreateError =
-  ExperimentalProjectCopyCreateErrors[keyof ExperimentalProjectCopyCreateErrors]
-
-export type ExperimentalProjectCopyCreateResponses = {
-  /**
-   * Project copy created
-   */
-  200: ProjectCopyCopy
-}
-
-export type ExperimentalProjectCopyCreateResponse =
-  ExperimentalProjectCopyCreateResponses[keyof ExperimentalProjectCopyCreateResponses]
-
-export type ExperimentalProjectCopyRefreshData = {
-  body?: never
-  path: {
-    projectID: string
-  }
   query?: {
     directory?: string
     workspace?: string
   }
-  url: "/experimental/project/{projectID}/copy/refresh"
+  url: "/experimental/project/{projectID}/copy/generate-name"
 }
 
-export type ExperimentalProjectCopyRefreshErrors = {
+export type ExperimentalProjectCopyGenerateNameErrors = {
   /**
-   * ProjectCopyError | InvalidRequestError
+   * Bad request
    */
-  400: ProjectCopyError | InvalidRequestError
+  400: BadRequestError
 }
 
-export type ExperimentalProjectCopyRefreshError =
-  ExperimentalProjectCopyRefreshErrors[keyof ExperimentalProjectCopyRefreshErrors]
+export type ExperimentalProjectCopyGenerateNameError =
+  ExperimentalProjectCopyGenerateNameErrors[keyof ExperimentalProjectCopyGenerateNameErrors]
 
-export type ExperimentalProjectCopyRefreshResponses = {
+export type ExperimentalProjectCopyGenerateNameResponses = {
   /**
-   * Project copies refreshed
+   * Success
    */
-  204: void
+  200: {
+    name: string
+  }
 }
 
-export type ExperimentalProjectCopyRefreshResponse =
-  ExperimentalProjectCopyRefreshResponses[keyof ExperimentalProjectCopyRefreshResponses]
+export type ExperimentalProjectCopyGenerateNameResponse =
+  ExperimentalProjectCopyGenerateNameResponses[keyof ExperimentalProjectCopyGenerateNameResponses]
 
 export type PtyShellsData = {
   body?: never
@@ -10951,6 +10885,109 @@ export type V2ReferenceListResponses = {
 
 export type V2ReferenceListResponse = V2ReferenceListResponses[keyof V2ReferenceListResponses]
 
+export type V2ProjectCopyRemoveData = {
+  body?: {
+    directory: string
+    force: boolean
+  }
+  path: {
+    projectID: string
+  }
+  query?: {
+    location?: {
+      directory?: string
+      workspace?: string
+    }
+  }
+  url: "/experimental/project/{projectID}/copy"
+}
+
+export type V2ProjectCopyRemoveErrors = {
+  /**
+   * ProjectCopyError | InvalidRequestError
+   */
+  400: ProjectCopyError | InvalidRequestError
+}
+
+export type V2ProjectCopyRemoveError = V2ProjectCopyRemoveErrors[keyof V2ProjectCopyRemoveErrors]
+
+export type V2ProjectCopyRemoveResponses = {
+  /**
+   * <No Content>
+   */
+  204: void
+}
+
+export type V2ProjectCopyRemoveResponse = V2ProjectCopyRemoveResponses[keyof V2ProjectCopyRemoveResponses]
+
+export type V2ProjectCopyCreateData = {
+  body?: {
+    strategy: string
+    directory: string
+    name?: string
+  }
+  path: {
+    projectID: string
+  }
+  query?: {
+    location?: {
+      directory?: string
+      workspace?: string
+    }
+  }
+  url: "/experimental/project/{projectID}/copy"
+}
+
+export type V2ProjectCopyCreateErrors = {
+  /**
+   * ProjectCopyError | InvalidRequestError
+   */
+  400: ProjectCopyError | InvalidRequestError
+}
+
+export type V2ProjectCopyCreateError = V2ProjectCopyCreateErrors[keyof V2ProjectCopyCreateErrors]
+
+export type V2ProjectCopyCreateResponses = {
+  /**
+   * ProjectCopy.Copy
+   */
+  200: ProjectCopyCopy
+}
+
+export type V2ProjectCopyCreateResponse = V2ProjectCopyCreateResponses[keyof V2ProjectCopyCreateResponses]
+
+export type V2ProjectCopyRefreshData = {
+  body?: never
+  path: {
+    projectID: string
+  }
+  query?: {
+    location?: {
+      directory?: string
+      workspace?: string
+    }
+  }
+  url: "/experimental/project/{projectID}/copy/refresh"
+}
+
+export type V2ProjectCopyRefreshErrors = {
+  /**
+   * ProjectCopyError | InvalidRequestError
+   */
+  400: ProjectCopyError | InvalidRequestError
+}
+
+export type V2ProjectCopyRefreshError = V2ProjectCopyRefreshErrors[keyof V2ProjectCopyRefreshErrors]
+
+export type V2ProjectCopyRefreshResponses = {
+  /**
+   * <No Content>
+   */
+  204: void
+}
+
+export type V2ProjectCopyRefreshResponse = V2ProjectCopyRefreshResponses[keyof V2ProjectCopyRefreshResponses]
+
 export type PtyConnectData = {
   body?: never
   path: {

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

@@ -17,6 +17,7 @@ import { Authorization } from "./middleware/authorization"
 import { LocationGroup } from "./groups/location"
 import { IntegrationGroup } from "./groups/integration"
 import { CredentialGroup } from "./groups/credential"
+import { ProjectCopyGroup } from "./groups/project-copy"
 
 export const Api = HttpApi.make("server")
   .add(HealthGroup)
@@ -35,6 +36,7 @@ export const Api = HttpApi.make("server")
   .add(EventGroup)
   .add(QuestionGroup)
   .add(ReferenceGroup)
+  .add(ProjectCopyGroup)
   .annotateMerge(
     OpenApi.annotations({
       title: "opencode HttpApi",

+ 12 - 3
packages/server/src/groups/location.ts

@@ -74,14 +74,23 @@ export const LocationGroup = HttpApiGroup.make("server.location")
 function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
   const query = new URL(request.url, "http://localhost").searchParams
   const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
+  const directory =
+    query.get("location[directory]") ||
+    (request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd())
   return Location.Ref.make({
-    directory: AbsolutePath.make(
-      query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
-    ),
+    directory: AbsolutePath.make(directory),
     workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined,
   })
 }
 
+function decode(input: string) {
+  try {
+    return decodeURIComponent(input)
+  } catch {
+    return input
+  }
+}
+
 export const layer = Layer.effect(
   LocationMiddleware,
   Effect.gen(function* () {

+ 57 - 0
packages/server/src/groups/project-copy.ts

@@ -0,0 +1,57 @@
+import { ProjectCopy } from "@opencode-ai/core/project/copy"
+import { ProjectV2 } from "@opencode-ai/core/project"
+import { Schema, Struct } from "effect"
+import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
+
+const root = "/experimental/project/:projectID/copy"
+
+export class ProjectCopyError extends Schema.ErrorClass<ProjectCopyError>("ProjectCopyError")(
+  {
+    name: Schema.Literal("ProjectCopyError"),
+    data: Schema.Struct({
+      message: Schema.String,
+      forceRequired: Schema.optional(Schema.Boolean),
+    }),
+  },
+  { httpApiStatus: 400 },
+) {}
+
+const CreatePayload = Schema.Struct(Struct.omit(ProjectCopy.CreateInput.fields, ["projectID", "sourceDirectory"]))
+const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, ["projectID"]))
+
+export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
+  .add(
+    HttpApiEndpoint.post("projectCopy.create", root, {
+      params: { projectID: ProjectV2.ID },
+      query: LocationQuery,
+      payload: CreatePayload,
+      success: ProjectCopy.Copy,
+      error: ProjectCopyError,
+    })
+      .annotateMerge(locationQueryOpenApi)
+      .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.create" })),
+  )
+  .add(
+    HttpApiEndpoint.delete("projectCopy.remove", root, {
+      params: { projectID: ProjectV2.ID },
+      query: LocationQuery,
+      payload: RemovePayload,
+      success: HttpApiSchema.NoContent,
+      error: ProjectCopyError,
+    })
+      .annotateMerge(locationQueryOpenApi)
+      .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.remove" })),
+  )
+  .add(
+    HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, {
+      params: { projectID: ProjectV2.ID },
+      query: LocationQuery,
+      success: HttpApiSchema.NoContent,
+      error: ProjectCopyError,
+    })
+      .annotateMerge(locationQueryOpenApi)
+      .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })),
+  )
+  .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
+  .middleware(LocationMiddleware)

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

@@ -22,6 +22,7 @@ import { LocationHandler } from "./handlers/location"
 import { IntegrationHandler } from "./handlers/integration"
 import { CredentialHandler } from "./handlers/credential"
 import { Credential } from "@opencode-ai/core/credential"
+import { ProjectCopyHandler } from "./handlers/project-copy"
 
 export const handlers = Layer.mergeAll(
   HealthHandler,
@@ -40,6 +41,7 @@ export const handlers = Layer.mergeAll(
   EventHandler,
   QuestionHandler,
   ReferenceHandler,
+  ProjectCopyHandler,
 ).pipe(
   Layer.provide(sessionLocationLayer),
   Layer.provide(locationLayer),

+ 69 - 0
packages/server/src/handlers/project-copy.ts

@@ -0,0 +1,69 @@
+import { Location } from "@opencode-ai/core/location"
+import { ProjectCopy } from "@opencode-ai/core/project/copy"
+import { Git } from "@opencode-ai/core/git"
+import { Effect } from "effect"
+import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
+import { Api } from "../api"
+import { ProjectCopyError } from "../groups/project-copy"
+
+export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) =>
+  Effect.succeed(
+    handlers
+      .handle("projectCopy.create", (ctx) =>
+        Effect.gen(function* () {
+          const copies = yield* ProjectCopy.Service
+          const location = yield* Location.Service
+          return yield* badRequest(
+            copies.create({
+              ...ctx.payload,
+              projectID: ctx.params.projectID,
+              sourceDirectory: location.project.directory,
+            }),
+          )
+        }),
+      )
+      .handle("projectCopy.remove", (ctx) =>
+        ProjectCopy.Service.use((copies) =>
+          badRequest(copies.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe(
+            Effect.as(HttpApiSchema.NoContent.make()),
+          ),
+        ),
+      )
+      .handle("projectCopy.refresh", (ctx) =>
+        ProjectCopy.Service.use((copies) =>
+          badRequest(copies.refresh({ projectID: ctx.params.projectID })).pipe(
+            Effect.as(HttpApiSchema.NoContent.make()),
+          ),
+        ),
+      ),
+  ),
+)
+
+function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
+  return effect.pipe(
+    Effect.mapError(
+      (error) =>
+        new ProjectCopyError({
+          name: "ProjectCopyError",
+          data: {
+            message: message(error),
+            forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
+          },
+        }),
+    ),
+  )
+}
+
+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.InvalidDirectoryError)
+    return `Invalid project copy directory: ${error.directory}`
+  if (error instanceof ProjectCopy.StrategyUnavailableError)
+    return `Project copy strategy unavailable: ${error.strategy}`
+  return error.message
+}

+ 124 - 41
packages/tui/src/component/dialog-move-session.tsx

@@ -16,14 +16,18 @@ import { useCommandShortcut } from "../keymap"
 import { useProject } from "../context/project"
 import { Spinner } from "./spinner"
 import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
+import type { ProjectDirectories } from "@opencode-ai/sdk/v2"
+import { useRoute } from "../context/route"
 
 export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
+type ProjectDirectory = ProjectDirectories[number]
 
 export function DialogMoveSession(props: {
   projectID: string
   current?: MoveSessionSelection
   onSelect: (selection: MoveSessionSelection) => void
-  initialDirectories?: string[]
+  onCurrentChange?: (selection: MoveSessionSelection) => void
+  initialDirectories?: ProjectDirectory[]
   initialRemoving?: string
 }) {
   const dialog = useDialog()
@@ -32,11 +36,13 @@ export function DialogMoveSession(props: {
   const { theme } = useTheme()
   const sync = useSync()
   const projectContext = useProject()
+  const route = useRoute()
   const toast = useToast()
   const paths = useTuiPaths()
   const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
   const [toDelete, setToDelete] = createSignal<string>()
   const [removing, setRemoving] = createSignal(props.initialRemoving)
+  const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
   const deleteHint = useCommandShortcut("dialog.move_session.delete")
 
   function reopen(initialRemoving?: string) {
@@ -52,8 +58,8 @@ export function DialogMoveSession(props: {
       return result.data?.id === projectID ? result.data.worktree : undefined
     },
   )
-  const project = createMemo(() =>
-    projectContext.project() === props.projectID ? projectContext.data.project.worktree : loadedProject(),
+  const currentCheckout = createMemo(() =>
+    projectContext.project() === props.projectID ? projectContext.instance.path().worktree : loadedProject(),
   )
 
   const [directories, { refetch }] = createResource(
@@ -61,9 +67,12 @@ export function DialogMoveSession(props: {
     async (projectID) => {
       setWorking(true)
       try {
-        await sdk.client.experimental.projectCopy.refresh({ projectID }, { throwOnError: true })
+        await sdk.client.v2.projectCopy.refresh(
+          { projectID, location: { directory: sdk.directory } },
+          { throwOnError: true },
+        )
         const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
-        return directories.data?.map((item) => item.directory) ?? []
+        return directories.data ?? []
       } finally {
         setWorking(false)
       }
@@ -71,56 +80,88 @@ export function DialogMoveSession(props: {
     { initialValue: props.initialDirectories },
   )
 
+  const currentDirectory = createMemo(() =>
+    replacementCurrent() ?? (props.current?.type === "directory" ? props.current.directory : currentCheckout()),
+  )
+  const currentRoot = createMemo<ProjectDirectory | undefined>(() => {
+    const directory = currentDirectory()
+    if (!directory) return
+    return (
+      directories()
+        ?.filter((root) => contains(root.directory, directory))
+        .toSorted((a, b) => b.directory.length - a.directory.length)[0] ?? { directory }
+    )
+  })
+
   const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
     const data = directories()
-    const main = project()
-    if (directories.loading && !data && !main) return [{ title: "Loading project directories...", value: undefined }]
-    if (directories.error && !data && !main) return [{ title: "Failed to load project directories", value: undefined }]
-    const roots = [...new Set(main ? [main, ...(data ?? [])] : (data ?? []))]
+    const current = currentRoot()?.directory
+    if (directories.loading && !data && !current) return [{ title: "Loading project directories...", value: undefined }]
+    if (directories.error && !data && !current)
+      return [{ title: "Failed to load project directories", value: undefined }]
+    const roots = [...(data ?? [])]
+    if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current })
+    roots.sort((a, b) => {
+      if (a.directory === current) return -1
+      if (b.directory === current) return 1
+      if (Boolean(a.strategy) !== Boolean(b.strategy)) return a.strategy ? 1 : -1
+      if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length
+      return 0
+    })
     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) => !roots.some((root) => root.directory === directory))
       .filter((directory, index, directories) => directories.indexOf(directory) === index)
       .map((location) => ({
         location,
         root: roots
           .filter((root) => {
-            const relative = path.relative(root, location)
+            const relative = path.relative(root.directory, location)
             return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
           })
-          .toSorted((a, b) => b.length - a.length)[0],
+          .toSorted((a, b) => b.directory.length - a.directory.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) => {
+      .filter((item): item is { location: string; root: ProjectDirectory } => item.root !== undefined)
+
+    const list = [...roots.map((root) => ({ location: root.directory, root })), ...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
+      if (a.location === a.root.directory) return -1
+      if (b.location === b.root.directory) 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 = abbreviateHome(item.location, paths.home)
-      const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
+      const suffix =
+        item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, item.location)
       const visible = Locale.truncateLeft(title, titleWidth)
       const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
       const deleting = toDelete() === item.location
       const isRemoving = removing() === item.location
       return {
-        title: isRemoving ? `Deleting ${item.location}` : deleting ? `Press ${deleteHint()} again to confirm` : title,
+        title,
         titleView: isRemoving ? (
           <span style={{ fg: theme.error }}>Deleting {item.location}</span>
-        ) : !deleting && suffix ? (
+        ) : deleting ? (
+          <span style={{ fg: theme.text }}>Press {deleteHint()} again to confirm</span>
+        ) : suffix ? (
           <>
             {visible.slice(0, split)}
             <span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
           </>
         ) : undefined,
         bg: deleting ? theme.error : undefined,
-        value: { type: "directory", directory: item.location, subdirectory: item.location !== item.root } as const,
-        category: item.root === main ? "Project" : "Working copies",
+        value: {
+          type: "directory",
+          directory: item.location,
+          subdirectory: item.location !== item.root.directory,
+        } as const,
+        category: item.root.directory === current ? "Current" : "Other",
         titleWidth,
         truncateTitle: "left" as const,
       }
@@ -128,32 +169,56 @@ export function DialogMoveSession(props: {
   })
 
   const current = createMemo(() => {
-    if (directories.loading || loadedProject.loading || !props.current) return
-    if (props.current.type === "new") return props.current
-    const directory = props.current.directory
-    return options().find((option) => option.value?.type === "directory" && option.value.directory === directory)?.value
+    if (directories.loading || loadedProject.loading) return
+    const replacement = replacementCurrent()
+    if (replacement) return { type: "directory", directory: replacement, subdirectory: false } as const
+    return props.current
   })
 
+  async function removedCurrent(current: boolean) {
+    if (!current) return false
+    const fallback = projectContext.data.project.mainDir
+    if (fallback) setReplacementCurrent(fallback)
+    if (route.data.type === "session") {
+      route.navigate({ type: "home" })
+      dialog.clear()
+      return true
+    }
+    if (fallback) {
+      props.onCurrentChange?.({ type: "directory", directory: fallback, subdirectory: false })
+      return true
+    }
+    dialog.clear()
+    return true
+  }
+
   async function remove(option: DialogSelectOption<MoveSessionSelection | undefined>) {
     if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return
     const data = directories()
-    const main = project()
-    if (!data || !main || option.value.directory === main || !data.includes(option.value.directory)) return
-    if (toDelete() !== option.value.directory) {
-      setToDelete(option.value.directory)
+    const selected = option.value
+    const root = data?.find((item) => item.directory === selected.directory)
+    if (!root?.strategy) return
+    const deletingCurrent = selected.directory === currentRoot()?.directory
+    if (toDelete() !== selected.directory) {
+      setToDelete(selected.directory)
       return
     }
     setToDelete(undefined)
-    setRemoving(option.value.directory)
+    setRemoving(selected.directory)
     setWorking(true)
-    const result = await sdk.client.experimental.projectCopy
-      .remove({ projectID: props.projectID, directory: option.value.directory, force: false })
+    const result = await sdk.client.v2.projectCopy
+      .remove({
+        projectID: props.projectID,
+        location: { directory: sdk.directory },
+        directory: selected.directory,
+        force: false,
+      })
       .catch((error) => ({ error }))
     if (result.error) {
       setRemoving(undefined)
       setWorking(false)
       if ("data" in result.error && result.error.data.forceRequired) {
-        const status = await sdk.client.vcs.status({ directory: option.value.directory }).catch(() => undefined)
+        const status = await sdk.client.vcs.status({ directory: selected.directory }).catch(() => undefined)
         const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
           title: "Delete working copy?",
           message: "This working copy has file changes. Do you want to delete it anyway?",
@@ -162,9 +227,14 @@ export function DialogMoveSession(props: {
           reopen()
           return
         }
-        reopen(option.value.directory)
-        const forced = await sdk.client.experimental.projectCopy
-          .remove({ projectID: props.projectID, directory: option.value.directory, force: true })
+        reopen(selected.directory)
+        const forced = await sdk.client.v2.projectCopy
+          .remove({
+            projectID: props.projectID,
+            location: { directory: sdk.directory },
+            directory: selected.directory,
+            force: true,
+          })
           .catch((error) => ({ error }))
         if (forced.error) {
           toast.show({
@@ -172,7 +242,12 @@ export function DialogMoveSession(props: {
             title: "Failed to delete project copy",
             message: errorMessage(forced.error),
           })
+          reopen()
+          return
         }
+        setRemoving(undefined)
+        setWorking(false)
+        if (await removedCurrent(deletingCurrent)) return
         reopen()
         return
       }
@@ -185,6 +260,8 @@ export function DialogMoveSession(props: {
     }
     await refetch()
     setRemoving(undefined)
+    setWorking(false)
+    if (await removedCurrent(deletingCurrent)) return
   }
 
   onMount(() => dialog.setSize("xlarge"))
@@ -219,11 +296,11 @@ export function DialogMoveSession(props: {
           {
             command: "dialog.move_session.delete",
             title: "delete",
-            disabled: (option) =>
-              !option?.value ||
-              option.value.type !== "directory" ||
-              option.value.subdirectory ||
-              option.value.directory === project(),
+            disabled: (option) => {
+              const value = option?.value
+              if (!value || value.type !== "directory" || value.subdirectory) return true
+              return !directories()?.find((item) => item.directory === value.directory)?.strategy
+            },
             onTrigger: remove,
           },
           {
@@ -236,3 +313,9 @@ export function DialogMoveSession(props: {
     </box>
   )
 }
+
+function contains(root: string, directory: string) {
+  if (root === directory) return true
+  const relative = path.relative(root, directory)
+  return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
+}

+ 15 - 3
packages/tui/src/component/prompt/move.tsx

@@ -9,6 +9,7 @@ import { useToast } from "../../ui/toast"
 import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
 import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
 import { useHomeSessionDestination } from "../../routes/home/session-destination"
+import { useProject } from "../../context/project"
 
 function moveReminderText(directory: string) {
   return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
@@ -20,6 +21,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
   const sync = useSync()
   const toast = useToast()
   const homeDestination = useHomeSessionDestination()
+  const project = useProject()
   const paths = useTuiPaths()
   const [creating, setCreating] = createSignal(false)
   const [creatingDots, setCreatingDots] = createSignal(3)
@@ -31,12 +33,17 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
     setCreating(true)
     setProgress("Creating copy")
     try {
-      const result = await sdk.client.experimental.projectCopy.create(
+      const generated = await sdk.client.experimental.projectCopy.generateName(
+        { projectID, context },
+        { throwOnError: true },
+      )
+      const result = await sdk.client.v2.projectCopy.create(
         {
           projectID,
+          location: { directory: sdk.directory },
           strategy: "git_worktree",
           directory: path.join(paths.worktree, projectID.slice(0, 6)),
-          context,
+          name: generated.data.name,
         },
         { throwOnError: true },
       )
@@ -74,8 +81,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
                 directory: session.directory,
                 subdirectory: !!session.path,
               }
-            : undefined)
+            : {
+                type: "directory",
+                directory: project.instance.directory(),
+                subdirectory: project.instance.directory() !== project.instance.path().worktree,
+              })
         }
+        onCurrentChange={(selection) => homeDestination?.setDestination(selection)}
         onSelect={(selection) => {
           const sessionID = input.sessionID()
           if (!sessionID) {

+ 1 - 2
packages/tui/src/context/project.tsx

@@ -44,12 +44,11 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
       const directories = project.data?.id
         ? await sdk.client.project.directories({ projectID: project.data.id, workspace })
         : undefined
-
       batch(() => {
         setStore("instance", "path", reconcile(instancePath.data || defaultPath))
         setStore("project", "id", project.data?.id)
         setStore("project", "worktree", project.data?.worktree)
-        setStore("project", "mainDir", directories?.data?.find((item) => item.type === "main")?.directory)
+        setStore("project", "mainDir", directories?.data?.findLast((item) => item.strategy === undefined)?.directory)
       })
     }