Forráskód Böngészése

feat(core): project copying and tracking directories (#30139)

James Long 2 hónapja
szülő
commit
147c6c4d51
28 módosított fájl, 3638 hozzáadás és 10 törlés
  1. 8 0
      packages/core/migration/20260602182828_add_project_directories/migration.sql
  2. 1746 0
      packages/core/migration/20260602182828_add_project_directories/snapshot.json
  3. 1 0
      packages/core/src/database/migration.gen.ts
  4. 20 0
      packages/core/src/database/migration/20260602182828_add_project_directories.ts
  5. 51 1
      packages/core/src/git.ts
  6. 31 3
      packages/core/src/project.ts
  7. 38 0
      packages/core/src/project/copy-strategies.ts
  8. 241 0
      packages/core/src/project/copy.ts
  9. 17 1
      packages/core/src/project/sql.ts
  10. 1 1
      packages/core/test/database-migration.test.ts
  11. 40 0
      packages/core/test/git.test.ts
  12. 1 0
      packages/core/test/location.test.ts
  13. 191 0
      packages/core/test/project-copy.test.ts
  14. 60 2
      packages/core/test/project.test.ts
  15. 41 1
      packages/opencode/src/project/project.ts
  16. 2 0
      packages/opencode/src/server/routes/instance/httpapi/api.ts
  17. 67 0
      packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts
  18. 11 0
      packages/opencode/src/server/routes/instance/httpapi/groups/project.ts
  19. 53 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts
  20. 11 1
      packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts
  21. 6 0
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  22. 169 0
      packages/opencode/test/project/project-directory.test.ts
  23. 3 0
      packages/opencode/test/project/project.test.ts
  24. 35 0
      packages/opencode/test/server/httpapi-exercise/index.ts
  25. 94 0
      packages/opencode/test/server/project-copy.test.ts
  26. 187 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  27. 153 0
      packages/sdk/js/src/v2/gen/types.gen.ts
  28. 360 0
      packages/sdk/openapi.json

+ 8 - 0
packages/core/migration/20260602182828_add_project_directories/migration.sql

@@ -0,0 +1,8 @@
+CREATE TABLE `project_directory` (
+	`project_id` text NOT NULL,
+	`directory` text NOT NULL,
+	`type` text NOT NULL,
+	`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
+);

+ 1746 - 0
packages/core/migration/20260602182828_add_project_directories/snapshot.json

@@ -0,0 +1,1746 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "id": "80f2378a-ed35-45cb-9d3b-9f4837fac801",
+  "prevIds": [
+    "7f4866d3-a95b-4141-bb59-28e31c521605",
+    "80d6efb8-93fd-4ce5-b320-45a05aaebdd7"
+  ],
+  "ddl": [
+    {
+      "name": "workspace",
+      "entityType": "tables"
+    },
+    {
+      "name": "data_migration",
+      "entityType": "tables"
+    },
+    {
+      "name": "account_state",
+      "entityType": "tables"
+    },
+    {
+      "name": "account",
+      "entityType": "tables"
+    },
+    {
+      "name": "control_account",
+      "entityType": "tables"
+    },
+    {
+      "name": "event_sequence",
+      "entityType": "tables"
+    },
+    {
+      "name": "event",
+      "entityType": "tables"
+    },
+    {
+      "name": "permission",
+      "entityType": "tables"
+    },
+    {
+      "name": "project_directory",
+      "entityType": "tables"
+    },
+    {
+      "name": "project",
+      "entityType": "tables"
+    },
+    {
+      "name": "message",
+      "entityType": "tables"
+    },
+    {
+      "name": "part",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_message",
+      "entityType": "tables"
+    },
+    {
+      "name": "session",
+      "entityType": "tables"
+    },
+    {
+      "name": "todo",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_share",
+      "entityType": "tables"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "''",
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "branch",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "extra",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_used",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "data_migration"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_completed",
+      "entityType": "columns",
+      "table": "data_migration"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active_account_id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active_org_id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "access_token",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "refresh_token",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "token_expiry",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "access_token",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "refresh_token",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "token_expiry",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "aggregate_id",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "owner_id",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "aggregate_id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "action",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "resource",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "project_directory"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "project_directory"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "project_directory"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "project_directory"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "worktree",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "vcs",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url_override",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_color",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_initialized",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "sandboxes",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "commands",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "message_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "parent_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "slug",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "path",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "title",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "version",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "share_url",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_additions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_deletions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_files",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_diffs",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "metadata",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "real",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "cost",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_input",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_output",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_reasoning",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_cache_read",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_cache_write",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "revert",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "permission",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "agent",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "model",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_compacting",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_archived",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "content",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "status",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "priority",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "position",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "secret",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_workspace_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "workspace"
+    },
+    {
+      "columns": [
+        "active_account_id"
+      ],
+      "tableTo": "account",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "SET NULL",
+      "nameExplicit": false,
+      "name": "fk_account_state_active_account_id_account_id_fk",
+      "entityType": "fks",
+      "table": "account_state"
+    },
+    {
+      "columns": [
+        "aggregate_id"
+      ],
+      "tableTo": "event_sequence",
+      "columnsTo": [
+        "aggregate_id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk",
+      "entityType": "fks",
+      "table": "event"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_permission_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "permission"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_project_directory_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "project_directory"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_message_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "message"
+    },
+    {
+      "columns": [
+        "message_id"
+      ],
+      "tableTo": "message",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_part_message_id_message_id_fk",
+      "entityType": "fks",
+      "table": "part"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_message_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "session"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_todo_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "todo"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_share_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_share"
+    },
+    {
+      "columns": [
+        "email",
+        "url"
+      ],
+      "nameExplicit": false,
+      "name": "control_account_pk",
+      "entityType": "pks",
+      "table": "control_account"
+    },
+    {
+      "columns": [
+        "project_id",
+        "directory"
+      ],
+      "nameExplicit": false,
+      "name": "project_directory_pk",
+      "entityType": "pks",
+      "table": "project_directory"
+    },
+    {
+      "columns": [
+        "session_id",
+        "position"
+      ],
+      "nameExplicit": false,
+      "name": "todo_pk",
+      "entityType": "pks",
+      "table": "todo"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "workspace_pk",
+      "table": "workspace",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "name"
+      ],
+      "nameExplicit": false,
+      "name": "data_migration_pk",
+      "table": "data_migration",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "account_state_pk",
+      "table": "account_state",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "account_pk",
+      "table": "account",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "aggregate_id"
+      ],
+      "nameExplicit": false,
+      "name": "event_sequence_pk",
+      "table": "event_sequence",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "event_pk",
+      "table": "event",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "permission_pk",
+      "table": "permission",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "project_pk",
+      "table": "project",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "message_pk",
+      "table": "message",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "part_pk",
+      "table": "part",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "session_message_pk",
+      "table": "session_message",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "session_pk",
+      "table": "session",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "nameExplicit": false,
+      "name": "session_share_pk",
+      "table": "session_share",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        {
+          "value": "project_id",
+          "isExpression": false
+        },
+        {
+          "value": "action",
+          "isExpression": false
+        },
+        {
+          "value": "resource",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "where": null,
+      "origin": "manual",
+      "name": "permission_project_action_resource_idx",
+      "entityType": "indexes",
+      "table": "permission"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "time_created",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "message_session_time_created_id_idx",
+      "entityType": "indexes",
+      "table": "message"
+    },
+    {
+      "columns": [
+        {
+          "value": "message_id",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_message_id_id_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_session_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_session_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "type",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_session_type_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "time_created",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_time_created_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "project_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_project_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_workspace_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "parent_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_parent_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "todo_session_idx",
+      "entityType": "indexes",
+      "table": "todo"
+    }
+  ],
+  "renames": []
+}

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

@@ -26,5 +26,6 @@ export const migrations = (
     import("./migration/20260601010001_normalize_storage_paths"),
     import("./migration/20260601202201_amazing_prowler"),
     import("./migration/20260602002951_lowly_union_jack"),
+    import("./migration/20260602182828_add_project_directories"),
   ])
 ).map((module) => module.default) satisfies DatabaseMigration.Migration[]

+ 20 - 0
packages/core/src/database/migration/20260602182828_add_project_directories.ts

@@ -0,0 +1,20 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+export default {
+  id: "20260602182828_add_project_directories",
+  up(tx) {
+    return Effect.gen(function* () {
+      yield* tx.run(`
+        CREATE TABLE \`project_directory\` (
+          \`project_id\` text NOT NULL,
+          \`directory\` text NOT NULL,
+          \`type\` text NOT NULL,
+          \`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
+        );
+      `)
+    })
+  },
+} satisfies DatabaseMigration.Migration

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

@@ -1,7 +1,7 @@
 export * as Git from "./git"
 
 import path from "path"
-import { Context, Effect, Layer } from "effect"
+import { Context, Effect, Layer, Schema } from "effect"
 import { ChildProcess } from "effect/unstable/process"
 import { AbsolutePath } from "./schema"
 import { FSUtil } from "./fs-util"
@@ -26,6 +26,13 @@ export interface Repo {
   readonly store: AbsolutePath
 }
 
+export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git.WorktreeError", {
+  operation: Schema.Literals(["create", "remove", "list"]),
+  message: Schema.String,
+  directory: Schema.optional(AbsolutePath),
+  cause: Schema.optional(Schema.Defect),
+}) {}
+
 export interface Interface {
   readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
   readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
@@ -45,6 +52,9 @@ export interface Interface {
   readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
   readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
   readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
+  readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
+  readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
+  readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
@@ -149,6 +159,43 @@ export const layer = Layer.effect(
       execute(directory, proc)(["reset", "--hard", target]),
     )
 
+    const worktree = Effect.fnUntraced(function* (
+      operation: "create" | "remove" | "list",
+      repo: Repo,
+      args: string[],
+      worktreeDirectory?: AbsolutePath,
+      cwd = repo.directory,
+    ) {
+      const result = yield* proc
+        .run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
+        .pipe(
+          Effect.mapError(
+            (cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
+          ),
+        )
+      if (result.exitCode === 0) return result.stdout.toString("utf8")
+      return yield* new WorktreeError({
+        operation,
+        directory: worktreeDirectory,
+        message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed",
+      })
+    })
+
+    const worktreeCreate = Effect.fn("Git.worktreeCreate")(function* (input: { repo: Repo; directory: AbsolutePath }) {
+      yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory)
+    })
+
+    const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { repo: Repo; directory: AbsolutePath }) {
+      yield* worktree("remove", input.repo, ["worktree", "remove", "--force", input.directory], input.directory, input.repo.store)
+    })
+
+    const worktreeList = Effect.fn("Git.worktreeList")(function* (repo: Repo) {
+      return (yield* worktree("list", repo, ["worktree", "list", "--porcelain"]))
+        .split("\n")
+        .filter((line) => line.startsWith("worktree "))
+        .map((line) => AbsolutePath.make(resolvePath(repo.directory, line.slice("worktree ".length).trim())))
+    })
+
     return Service.of({
       find,
       remote,
@@ -163,6 +210,9 @@ export const layer = Layer.effect(
       fetchBranch,
       checkout,
       reset,
+      worktreeCreate,
+      worktreeRemove,
+      worktreeList,
     })
   }),
 )

+ 31 - 3
packages/core/src/project.ts

@@ -2,11 +2,14 @@ export * as ProjectV2 from "./project"
 export * as Project from "./project"
 
 import { Context, Effect, Layer, Schema } from "effect"
+import { eq } from "drizzle-orm"
 import path from "path"
 import { AbsolutePath, withStatics } from "./schema"
 import { FSUtil } from "./fs-util"
+import { Database } from "./database/database"
 import { Git } from "./git"
 import { Hash } from "./util/hash"
+import { ProjectDirectoryTable } from "./project/sql"
 
 export const ID = Schema.String.pipe(
   Schema.brand("Project.ID"),
@@ -28,7 +31,16 @@ export class Info extends Schema.Class<Info>("Project.Info")({
   id: ID,
 }) {}
 
+export const DirectoriesInput = Schema.Struct({
+  projectID: ID,
+}).annotate({ identifier: "Project.DirectoriesInput" })
+export type DirectoriesInput = typeof DirectoriesInput.Type
+
+export const Directories = Schema.Array(AbsolutePath).annotate({ identifier: "Project.Directories" })
+export type Directories = typeof Directories.Type
+
 export interface Interface {
+  readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
   readonly resolve: (input: AbsolutePath) => Effect.Effect<
     {
       previous?: ID
@@ -55,9 +67,22 @@ 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 directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
+      const rows = yield* db
+        .select({ directory: ProjectDirectoryTable.directory })
+        .from(ProjectDirectoryTable)
+        .where(eq(ProjectDirectoryTable.project_id, input.projectID))
+        .all()
+        .pipe(Effect.orDie)
+      return rows
+        .toSorted((a, b) => a.directory.localeCompare(b.directory))
+        .map((row) => AbsolutePath.make(row.directory))
+    })
+
     const cached = Effect.fnUntraced(function* (dir: string) {
       return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
         Effect.map((value) => value.trim()),
@@ -109,7 +134,6 @@ export const layer = Layer.effect(
 
       const previous = yield* cached(repo.store)
       const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
-
       return {
         previous,
         id: id ?? ID.global,
@@ -122,8 +146,12 @@ export const layer = Layer.effect(
       yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
     })
 
-    return Service.of({ resolve, commit })
+    return Service.of({ directories, resolve, commit })
   }),
 )
 
-export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer))
+export const defaultLayer = layer.pipe(
+  Layer.provide(Database.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
+  Layer.provide(Git.defaultLayer),
+)

+ 38 - 0
packages/core/src/project/copy-strategies.ts

@@ -0,0 +1,38 @@
+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"
+
+export function makeStrategies(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",
+    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) }
+    }),
+    remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (directory) {
+      const found = yield* input.git.find(directory)
+      if (!found) return yield* new DirectoryUnavailableError({ directory })
+      yield* input.git.worktreeRemove({ repo: found, directory })
+    }),
+    list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
+      const entries = yield* input.git.worktreeList(repo(directory))
+      return yield* Effect.forEach(entries, (entry) =>
+        entry === directory ? Effect.succeed(undefined) : input.canonical(entry).pipe(Effect.map((directory) => ({ directory }))),
+      ).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"))
+    }),
+  }
+
+  return new Map<StrategyID, Strategy>([[gitWorktree.id, gitWorktree]])
+}

+ 241 - 0
packages/core/src/project/copy.ts

@@ -0,0 +1,241 @@
+export * as ProjectCopy from "./copy"
+
+import { and, eq, inArray } from "drizzle-orm"
+import { Context, Effect, Layer, Schema } from "effect"
+import { AbsolutePath } from "../schema"
+import { FSUtil } from "../fs-util"
+import { Git } from "../git"
+import { Database } from "../database/database"
+import { EventV2 } from "../event"
+import { Project } from "../project"
+import { ProjectDirectoryTable } from "./sql"
+import { makeStrategies } from "./copy-strategies"
+
+export const StrategyID = Schema.Literal("git_worktree")
+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,
+}).annotate({ identifier: "ProjectCopy.CreateInput" })
+export type CreateInput = typeof CreateInput.Type
+
+export const RemoveInput = Schema.Struct({
+  projectID: Project.ID,
+  directory: AbsolutePath,
+}).annotate({ identifier: "ProjectCopy.RemoveInput" })
+export type RemoveInput = typeof RemoveInput.Type
+
+export const RefreshInput = Schema.Struct({
+  projectID: Project.ID,
+}).annotate({ identifier: "ProjectCopy.RefreshInput" })
+export type RefreshInput = typeof RefreshInput.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 class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
+  "ProjectCopy.SourceDirectoryNotFoundError",
+  { directory: AbsolutePath },
+) {}
+
+export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
+  "ProjectCopy.DestinationExistsError",
+  { directory: AbsolutePath },
+) {}
+
+export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
+  "ProjectCopy.DirectoryUnavailableError",
+  { directory: AbsolutePath },
+) {}
+
+export class StrategyNotFoundError extends Schema.TaggedErrorClass<StrategyNotFoundError>()(
+  "ProjectCopy.StrategyNotFoundError",
+  { directory: AbsolutePath },
+) {}
+
+export type Error =
+  | SourceDirectoryNotFoundError
+  | DestinationExistsError
+  | DirectoryUnavailableError
+  | StrategyNotFoundError
+  | Git.WorktreeError
+
+export interface Strategy {
+  readonly id: StrategyID
+  readonly create: (input: {
+    sourceDirectory: AbsolutePath
+    directory: AbsolutePath
+  }) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
+  readonly remove: (directory: AbsolutePath) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
+  readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
+  readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
+}
+
+export const Event = {
+  Updated: EventV2.define({
+    type: "project.directories.updated",
+    schema: { projectID: Project.ID },
+  }),
+}
+
+export interface Interface {
+  readonly detect: (input: DetectInput) => Effect.Effect<StrategyID | undefined>
+  readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
+  readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
+  readonly refresh: (input: RefreshInput) => Effect.Effect<void, Error>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
+
+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 db = (yield* Database.Service).db
+
+    const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
+      const resolved = AbsolutePath.make(FSUtil.resolve(input))
+      if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
+      return resolved
+    })
+
+    const registry = makeStrategies({ git, fs, canonical })
+
+    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 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)
+    })
+
+    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 changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
+      if (update) yield* events.publish(Event.Updated, { projectID })
+    })
+
+    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 create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
+      if (yield* fs.existsSafe(input.directory)) return yield* new DestinationExistsError({ directory: input.directory })
+      const result = yield* strategy(input.strategy).create({
+        directory: input.directory,
+        sourceDirectory: yield* source(input.sourceDirectory, input.projectID),
+      })
+      yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy))
+      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(copyDirectory)
+      yield* changed(input.projectID, yield* removeStored(input.projectID, 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 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 })))),
+          ),
+        { 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)),
+            ),
+          ),
+      ).pipe(Effect.map((items) => items.some(Boolean)))
+      yield* changed(input.projectID, inserted || removed)
+    })
+
+    return Service.of({ detect, create, remove, refresh })
+  }),
+)
+
+export const defaultLayer = layer.pipe(
+  Layer.provide(Database.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
+  Layer.provide(Git.defaultLayer),
+  Layer.provide(EventV2.defaultLayer),
+)

+ 17 - 1
packages/core/src/project/sql.ts

@@ -1,4 +1,4 @@
-import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
+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"
@@ -16,3 +16,19 @@ export const ProjectTable = sqliteTable("project", {
   sandboxes: DatabasePath.absoluteArrayColumn().notNull(),
   commands: text({ mode: "json" }).$type<{ start?: string }>(),
 })
+
+export const ProjectDirectoryTable = sqliteTable(
+  "project_directory",
+  {
+    project_id: text()
+      .$type<ProjectV2.ID>()
+      .notNull()
+      .references(() => ProjectTable.id, { onDelete: "cascade" }),
+    directory: text().notNull(),
+    type: text().$type<"main" | "root" | "git_worktree">().notNull(),
+    time_created: integer()
+      .notNull()
+      .$default(() => Date.now()),
+  },
+  (table) => [primaryKey({ columns: [table.project_id, table.directory] })],
+)

+ 1 - 1
packages/core/test/database-migration.test.ts

@@ -43,7 +43,7 @@ describe("DatabaseMigration", () => {
         expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
           name: "session",
         })
-        expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 24 })
+        expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 25 })
       }),
     )
   })

+ 40 - 0
packages/core/test/git.test.ts

@@ -1,8 +1,10 @@
 import { describe, expect } from "bun:test"
+import { $ } from "bun"
 import fs from "fs/promises"
 import path from "path"
 import { Effect } from "effect"
 import { Git } from "@opencode-ai/core/git"
+import { AbsolutePath } from "@opencode-ai/core/schema"
 import { branch, commit, gitRemote } from "./fixture/git"
 import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
@@ -64,3 +66,41 @@ function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote
 function read(file: string) {
   return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
 }
+
+async function initRepo(directory: string) {
+  await $`git init`.cwd(directory).quiet()
+  await $`git config core.fsmonitor false`.cwd(directory).quiet()
+  await $`git config commit.gpgsign false`.cwd(directory).quiet()
+  await $`git config user.email test@opencode.test`.cwd(directory).quiet()
+  await $`git config user.name Test`.cwd(directory).quiet()
+  await $`git commit --allow-empty -m root`.cwd(directory).quiet()
+}
+
+describe("Git worktrees", () => {
+  it.live("creates, lists, and removes linked worktrees", () =>
+    Effect.gen(function* () {
+      const root = yield* Effect.acquireRelease(
+        Effect.promise(() => tmpdir()),
+        (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
+      )
+      yield* Effect.promise(() => initRepo(root.path))
+      const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path)))
+      const worktree = AbsolutePath.make(`${root.path}-git-worktree`)
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
+      )
+      const git = yield* Git.Service
+      const repo = { directory, store: AbsolutePath.make(path.join(directory, ".git")) }
+
+      yield* git.worktreeCreate({ repo, directory: worktree })
+
+      expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(true)
+      const linked = yield* git.find(worktree)
+      expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
+      expect(linked?.store).toBe(repo.store)
+      if (!linked) throw new Error("Linked worktree not found")
+      yield* git.worktreeRemove({ repo: linked, directory: worktree })
+      expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
+    }),
+  )
+})

+ 1 - 0
packages/core/test/location.test.ts

@@ -9,6 +9,7 @@ const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID: "
 const projectLayer = Layer.succeed(
   Project.Service,
   Project.Service.of({
+    directories: () => Effect.succeed([]),
     resolve: () =>
       Effect.succeed({
         id: Project.ID.make("project"),

+ 191 - 0
packages/core/test/project-copy.test.ts

@@ -0,0 +1,191 @@
+import { describe, expect } from "bun:test"
+import { $ } from "bun"
+import fs from "fs/promises"
+import path from "path"
+import { eq } from "drizzle-orm"
+import { Effect, Fiber, Layer, Stream } from "effect"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import { Git } from "@opencode-ai/core/git"
+import { Database } from "@opencode-ai/core/database/database"
+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 { tmpdir } from "./fixture/tmpdir"
+import { testEffect } from "./lib/effect"
+
+const databaseLayer = Database.layerFromPath(":memory:")
+const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
+const copyLayer = ProjectCopy.layer.pipe(
+  Layer.provide(databaseLayer),
+  Layer.provide(eventLayer),
+  Layer.provide(FSUtil.defaultLayer),
+  Layer.provide(Git.defaultLayer),
+)
+const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer))
+
+function abs(input: string) {
+  return AbsolutePath.make(input)
+}
+
+async function initRepo(directory: string) {
+  await $`git init`.cwd(directory).quiet()
+  await $`git config core.fsmonitor false`.cwd(directory).quiet()
+  await $`git config commit.gpgsign false`.cwd(directory).quiet()
+  await $`git config user.email test@opencode.test`.cwd(directory).quiet()
+  await $`git config user.name Test`.cwd(directory).quiet()
+  await $`git commit --allow-empty -m root`.cwd(directory).quiet()
+}
+
+function setup() {
+  return Effect.gen(function* () {
+    const root = yield* Effect.acquireRelease(
+      Effect.promise(() => tmpdir()),
+      (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
+    )
+    yield* Effect.promise(() => initRepo(root.path))
+    const sourceDirectory = abs(yield* Effect.promise(() => fs.realpath(root.path)))
+    const projectID = Project.ID.make("copy-project")
+    const { db } = yield* Database.Service
+    yield* db
+      .insert(ProjectTable)
+      .values({ id: projectID, worktree: sourceDirectory, sandboxes: [], time_created: 1, time_updated: 1 })
+      .run()
+      .pipe(Effect.orDie)
+    yield* db
+      .insert(ProjectDirectoryTable)
+      .values({ project_id: projectID, directory: sourceDirectory, type: "main" })
+      .run()
+      .pipe(Effect.orDie)
+    return { root, sourceDirectory, projectID, db }
+  })
+}
+
+function stored(projectID: Project.ID) {
+  return Database.Service.use(({ db }) =>
+    db
+      .select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
+      .from(ProjectDirectoryTable)
+      .where(eq(ProjectDirectoryTable.project_id, projectID))
+      .all()
+      .pipe(
+        Effect.orDie,
+        Effect.map((rows) => rows.toSorted((a, b) => a.directory.localeCompare(b.directory))),
+      ),
+  )
+}
+
+describe("ProjectCopy", () => {
+  it.live("detects linked git worktrees but not root checkouts", () =>
+    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())
+
+      expect(yield* copy.detect({ directory: input.sourceDirectory })).toBeUndefined()
+      expect(yield* copy.detect({ directory: target })).toBe("git_worktree")
+    }),
+  )
+
+  it.live("creates and removes a git worktree directory", () =>
+    Effect.gen(function* () {
+      const input = yield* setup()
+      const copy = yield* ProjectCopy.Service
+      const events = yield* EventV2.Service
+      const target = abs(`${input.root.path}-copy-created`)
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
+      )
+      const fiber = yield* events
+        .subscribe(ProjectCopy.Event.Updated)
+        .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
+      yield* Effect.yieldNow
+
+      const created = yield* copy.create({
+        projectID: input.projectID,
+        strategy: "git_worktree",
+        sourceDirectory: input.sourceDirectory,
+        directory: target,
+      })
+      expect(yield* stored(input.projectID)).toEqual(
+        [
+          { directory: input.sourceDirectory, type: "main" as const },
+          { directory: created.directory, type: "git_worktree" as const },
+        ].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 })
+
+      expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
+      expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
+    }),
+  )
+
+  it.live("does not publish an event when refresh finds no directory changes", () =>
+    Effect.gen(function* () {
+      const input = yield* setup()
+      const copy = yield* ProjectCopy.Service
+      const events = yield* EventV2.Service
+      const event = yield* events.subscribe(ProjectCopy.Event.Updated).pipe(
+        Stream.take(1),
+        Stream.runCollect,
+        Effect.forkScoped,
+        Effect.flatMap((fiber) =>
+          Effect.gen(function* () {
+            yield* Effect.yieldNow
+            yield* copy.refresh({ projectID: input.projectID })
+            return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
+          }),
+        ),
+      )
+
+      expect(event._tag).toBe("None")
+    }),
+  )
+
+  it.live("refresh discovers and prunes an externally managed git worktree", () =>
+    Effect.gen(function* () {
+      const input = yield* setup()
+      const copy = yield* ProjectCopy.Service
+      const events = yield* EventV2.Service
+      const target = abs(`${input.root.path}-copy-external`)
+      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 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* stored(input.projectID)).toEqual(
+        [
+          { directory: input.sourceDirectory, type: "main" as const },
+          { directory: discovered, type: "git_worktree" as const },
+        ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
+      )
+      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 }])
+    }),
+  )
+
+  it.live("refresh with no roots is a no-op", () =>
+    Effect.gen(function* () {
+      const copy = yield* ProjectCopy.Service
+
+      yield* copy.refresh({ projectID: Project.ID.make("missing-project") })
+    }),
+  )
+})

+ 60 - 2
packages/core/test/project.test.ts

@@ -2,14 +2,28 @@ import { describe, expect } from "bun:test"
 import { $ } from "bun"
 import fs from "fs/promises"
 import path from "path"
-import { Effect } from "effect"
+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 { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
 
-const it = testEffect(ProjectV2.defaultLayer)
+const databaseLayer = Database.layerFromPath(":memory:")
+const it = testEffect(
+  Layer.mergeAll(
+    ProjectV2.layer.pipe(
+      Layer.provide(databaseLayer),
+      Layer.provide(FSUtil.defaultLayer),
+      Layer.provide(Git.defaultLayer),
+    ),
+    databaseLayer,
+  ),
+)
 
 function remoteID(remote: string) {
   return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
@@ -37,6 +51,50 @@ 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)([AbsolutePath.make("/tmp/project")])).toEqual([
+        AbsolutePath.make("/tmp/project"),
+      ])
+    }),
+  )
+
+  it.effect("lists stored project directories only 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" },
+          { project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main" },
+          { project_id: otherID, directory: AbsolutePath.make("/other"), type: "main" },
+        ])
+        .run()
+        .pipe(Effect.orDie)
+
+      expect(yield* project.directories({ projectID })).toEqual([
+        AbsolutePath.make("/repo/a"),
+        AbsolutePath.make("/repo/z"),
+      ])
+    }),
+  )
+})
+
 describe("ProjectV2.resolve", () => {
   it.live("returns global for non-git directory", () =>
     Effect.gen(function* () {

+ 41 - 1
packages/opencode/src/project/project.ts

@@ -1,6 +1,6 @@
 import { and, eq, sql } from "drizzle-orm"
 import { Database } from "@opencode-ai/core/database/database"
-import { ProjectTable } from "@opencode-ai/core/project/sql"
+import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
 import { SessionTable } from "@opencode-ai/core/session/sql"
 import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
 import * as Log from "@opencode-ai/core/util/log"
@@ -14,6 +14,7 @@ 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"
@@ -139,6 +140,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 events = yield* EventV2Bridge.Service
     const flags = yield* RuntimeFlags.Service
     const { db } = yield* Database.Service
@@ -215,6 +217,38 @@ export const layer = Layer.effect(
         .pipe(Effect.orDie)
     })
 
+    const saveProjectDirectory = Effect.fn("Project.saveProjectDirectory")(function* (input: {
+      projectID: ProjectV2.ID
+      directory: string
+    }) {
+      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" },
+        )
+        .pipe(
+          Effect.catchCause((cause) =>
+            Effect.sync(() => log.warn("project directory persistence failed", { projectID: input.projectID, cause })),
+          ),
+        )
+    })
+
     const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
       log.info("fromDirectory", { directory })
 
@@ -302,6 +336,11 @@ export const layer = Layer.effect(
           .pipe(Effect.orDie)
       }
 
+      yield* saveProjectDirectory({
+        projectID,
+        directory: data.directory,
+      })
+
       yield* emitUpdated(result)
       if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") {
         yield* projectV2.commit({ store: data.vcs.store, id: data.id })
@@ -466,6 +505,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(AppProcess.defaultLayer),
   Layer.provide(CrossSpawnSpawner.defaultLayer),
   Layer.provide(FSUtil.defaultLayer),

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

@@ -12,6 +12,7 @@ import { InstanceApi } from "./groups/instance"
 import { McpApi } from "./groups/mcp"
 import { PermissionApi } from "./groups/permission"
 import { ProjectApi } from "./groups/project"
+import { ProjectCopyApi } from "./groups/project-copy"
 import { ProviderApi } from "./groups/provider"
 import { PtyApi, PtyConnectApi } from "./groups/pty"
 import { QuestionApi } from "./groups/question"
@@ -52,6 +53,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
   .addHttpApi(InstanceApi)
   .addHttpApi(McpApi)
   .addHttpApi(ProjectApi)
+  .addHttpApi(ProjectCopyApi)
   .addHttpApi(PtyApi)
   .addHttpApi(QuestionApi)
   .addHttpApi(PermissionApi)

+ 67 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts

@@ -0,0 +1,67 @@
+import { ProjectCopy } from "@opencode-ai/core/project/copy"
+import { ProjectV2 } from "@opencode-ai/core/project"
+import { Schema } from "effect"
+import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import { Authorization } from "../middleware/authorization"
+import { InstanceContextMiddleware } from "../middleware/instance-context"
+import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
+import { described } from "./metadata"
+
+const root = "/experimental/project/:projectID/copy"
+
+export const CreatePayload = Schema.Struct({
+  strategy: ProjectCopy.StrategyID,
+  directory: ProjectCopy.CreateInput.fields.directory,
+})
+export const RemovePayload = Schema.Struct({
+  directory: ProjectCopy.RemoveInput.fields.directory,
+})
+
+export const ProjectCopyApi = HttpApi.make("projectCopy").add(
+  HttpApiGroup.make("projectCopy")
+    .add(
+      HttpApiEndpoint.post("create", root, {
+        params: { projectID: ProjectV2.ID },
+        query: WorkspaceRoutingQuery,
+        payload: CreatePayload,
+        success: described(ProjectCopy.Copy, "Project copy created"),
+        error: HttpApiError.BadRequest,
+      }).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: WorkspaceRoutingQuery,
+        payload: RemovePayload,
+        success: described(HttpApiSchema.NoContent, "Project copy removed"),
+        error: HttpApiError.BadRequest,
+      }).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`, {
+        params: { projectID: ProjectV2.ID },
+        query: WorkspaceRoutingQuery,
+        payload: HttpApiSchema.NoContent,
+        success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
+        error: HttpApiError.BadRequest,
+      }).annotateMerge(
+        OpenApi.annotations({
+          identifier: "experimental.projectCopy.refresh",
+          summary: "Refresh project copies",
+          description: "Discover local project copies using one or all configured strategies.",
+        }),
+      ),
+    )
+    .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
+    .middleware(InstanceContextMiddleware)
+    .middleware(WorkspaceRoutingMiddleware)
+    .middleware(Authorization),
+)

+ 11 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/project.ts

@@ -62,6 +62,17 @@ export const ProjectApi = HttpApi.make("project")
             description: "Update project properties such as name, icon, and commands.",
           }),
         ),
+        HttpApiEndpoint.get("directories", `${root}/:projectID/directories`, {
+          params: { projectID: ProjectV2.ID },
+          query: WorkspaceRoutingQuery,
+          success: described(ProjectV2.Directories, "Project directories"),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "project.directories",
+            summary: "List project directories",
+            description: "List known local absolute directories for a project.",
+          }),
+        ),
       )
       .annotateMerge(
         OpenApi.annotations({

+ 53 - 0
packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts

@@ -0,0 +1,53 @@
+import { ProjectCopy } from "@opencode-ai/core/project/copy"
+import { ProjectV2 } from "@opencode-ai/core/project"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { InstanceState } from "@/effect/instance-state"
+import { Effect } from "effect"
+import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
+import { InstanceHttpApi } from "../api"
+import { CreatePayload, RemovePayload } from "../groups/project-copy"
+
+function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
+  return effect.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
+}
+
+export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
+  Effect.gen(function* () {
+    const service = yield* ProjectCopy.Service
+
+    const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
+      params: { projectID: ProjectV2.ID }
+      payload: typeof CreatePayload.Type
+    }) {
+      return yield* badRequest(
+        service.create({
+          ...ctx.payload,
+          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 handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh)
+  }),
+)

+ 11 - 1
packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts

@@ -10,6 +10,7 @@ import { markInstanceForReload } from "../lifecycle"
 export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
   Effect.gen(function* () {
     const svc = yield* Project.Service
+    const project = yield* ProjectV2.Service
 
     const list = Effect.fn("ProjectHttpApi.list")(function* () {
       return yield* svc.list()
@@ -48,6 +49,15 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
       )
     })
 
-    return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
+    const directories = Effect.fn("ProjectHttpApi.directories")((ctx: { params: { projectID: ProjectV2.ID } }) =>
+      project.directories({ projectID: ctx.params.projectID }),
+    )
+
+    return handlers
+      .handle("list", list)
+      .handle("current", current)
+      .handle("initGit", initGit)
+      .handle("update", update)
+      .handle("directories", directories)
   }),
 )

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

@@ -26,6 +26,8 @@ import { Installation } from "@/installation"
 import { InstanceLayer } from "@/project/instance-layer"
 import { Plugin } from "@/plugin"
 import { Project } from "@/project/project"
+import { ProjectV2 } from "@opencode-ai/core/project"
+import { ProjectCopy } from "@opencode-ai/core/project/copy"
 import { ProviderAuth } from "@/provider/auth"
 import { ModelsDev } from "@opencode-ai/core/models-dev"
 import { Provider } from "@/provider/provider"
@@ -74,6 +76,7 @@ import { instanceHandlers } from "./handlers/instance"
 import { mcpHandlers } from "./handlers/mcp"
 import { permissionHandlers } from "./handlers/permission"
 import { projectHandlers } from "./handlers/project"
+import { projectCopyHandlers } from "./handlers/project-copy"
 import { providerHandlers } from "./handlers/provider"
 import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
 import { questionHandlers } from "./handlers/question"
@@ -135,6 +138,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
     instanceHandlers,
     mcpHandlers,
     projectHandlers,
+    projectCopyHandlers,
     ptyHandlers,
     questionHandlers,
     permissionHandlers,
@@ -204,6 +208,8 @@ export function createRoutes(
       Permission.defaultLayer,
       Plugin.defaultLayer,
       Project.defaultLayer,
+      ProjectV2.defaultLayer,
+      ProjectCopy.defaultLayer,
       ProviderAuth.defaultLayer,
       Provider.defaultLayer,
       Pty.defaultLayer,

+ 169 - 0
packages/opencode/test/project/project-directory.test.ts

@@ -0,0 +1,169 @@
+import { describe, expect } from "bun:test"
+import { $ } from "bun"
+import path from "path"
+import { eq } from "drizzle-orm"
+import { Effect, Layer } from "effect"
+import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import { Hash } from "@opencode-ai/core/util/hash"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { Database } from "@opencode-ai/core/database/database"
+import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
+import { ProjectV2 } from "@opencode-ai/core/project"
+import { Project } from "@/project/project"
+import { tmpdirScoped } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+const it = testEffect(Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer))
+
+function directories(projectID: ProjectV2.ID) {
+  return Database.Service.use(({ db }) =>
+    db
+      .select()
+      .from(ProjectDirectoryTable)
+      .where(eq(ProjectDirectoryTable.project_id, projectID))
+      .all()
+      .pipe(
+        Effect.orDie,
+        Effect.map((rows) =>
+          rows
+            .map((row) => ({ directory: row.directory, type: row.type }))
+            .toSorted((a, b) => a.directory.localeCompare(b.directory)),
+        ),
+      ),
+  )
+}
+
+describe("Project directory persistence", () => {
+  it.live("stores the first opened checkout directory", () =>
+    Effect.gen(function* () {
+      const tmp = yield* tmpdirScoped({ git: true })
+      const project = yield* Project.Service
+
+      const result = yield* project.fromDirectory(tmp)
+
+      expect(yield* directories(result.project.id)).toEqual([{ directory: tmp, type: "main" }])
+    }),
+  )
+
+  it.live("stores a repeatedly opened checkout directory only once", () =>
+    Effect.gen(function* () {
+      const tmp = yield* tmpdirScoped({ git: true })
+      const project = yield* Project.Service
+
+      const result = yield* project.fromDirectory(tmp)
+      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" }])
+    }),
+  )
+
+  it.live("stores an opened linked worktree directory", () =>
+    Effect.gen(function* () {
+      const tmp = yield* tmpdirScoped({ git: true })
+      const project = yield* Project.Service
+      const main = yield* project.fromDirectory(tmp)
+      const worktree = path.join(tmp, "..", path.basename(tmp) + "-project-directory-worktree")
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(() => $`git worktree remove ${worktree}`.cwd(tmp).quiet().nothrow()).pipe(Effect.ignore),
+      )
+      yield* Effect.promise(() => $`git worktree add ${worktree} -b project-directory-${Date.now()}`.cwd(tmp).quiet())
+
+      yield* project.fromDirectory(worktree)
+
+      expect(yield* directories(main.project.id)).toEqual(
+        [
+          { directory: tmp, type: "main" as const },
+          { directory: worktree, type: "git_worktree" as const },
+        ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
+      )
+    }),
+  )
+
+  it.live("stores only the linked copy when first opened from an external linked worktree", () =>
+    Effect.gen(function* () {
+      const tmp = yield* tmpdirScoped({ git: true })
+      const worktree = path.join(tmp, "..", path.basename(tmp) + "-project-directory-first-worktree")
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(() => $`git worktree remove ${worktree}`.cwd(tmp).quiet().nothrow()).pipe(Effect.ignore),
+      )
+      yield* Effect.promise(() => $`git worktree add --detach ${worktree} HEAD`.cwd(tmp).quiet())
+      const project = yield* Project.Service
+
+      const result = yield* project.fromDirectory(worktree)
+
+      expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
+    }),
+  )
+
+  it.live("stores a separately opened clone as a secondary directory", () =>
+    Effect.gen(function* () {
+      const tmp = yield* tmpdirScoped({ git: true })
+      const bare = tmp + "-project-directory-bare"
+      const clone = tmp + "-project-directory-clone"
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(() => $`rm -rf ${bare} ${clone}`.quiet().nothrow()).pipe(Effect.ignore),
+      )
+      yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
+      yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
+      const project = yield* Project.Service
+      const main = yield* project.fromDirectory(tmp)
+
+      yield* project.fromDirectory(clone)
+
+      expect(yield* directories(main.project.id)).toEqual(
+        [
+          { directory: tmp, type: "main" as const },
+          { directory: clone, type: "root" as const },
+        ].toSorted((a, b) => a.directory.localeCompare(b.directory)),
+      )
+    }),
+  )
+
+  it.live("stores only the materialized worktree for a bare repository", () =>
+    Effect.gen(function* () {
+      const tmp = yield* tmpdirScoped({ git: true })
+      const bare = tmp + "-project-directory-bare-store.git"
+      const worktree = tmp + "-project-directory-bare-worktree"
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(() => $`rm -rf ${bare} ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
+      )
+      yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
+      yield* Effect.promise(() => $`git worktree add ${worktree} HEAD`.cwd(bare).quiet())
+      const project = yield* Project.Service
+
+      const result = yield* project.fromDirectory(worktree)
+
+      expect(yield* directories(result.project.id)).toEqual([{ directory: worktree, type: "git_worktree" }])
+    }),
+  )
+
+  it.live("records the active directory under its newly resolved project id", () =>
+    Effect.gen(function* () {
+      const tmp = yield* tmpdirScoped({ git: true })
+      const project = yield* Project.Service
+      yield* project.fromDirectory(tmp)
+      const remoteID = ProjectV2.ID.make(Hash.fast("git-remote:github.com/project-directory-test/collision"))
+      const { db } = yield* Database.Service
+      yield* db
+        .insert(ProjectTable)
+        .values({
+          id: remoteID,
+          worktree: AbsolutePath.make("/tmp/existing"),
+          vcs: "git",
+          time_created: Date.now(),
+          time_updated: Date.now(),
+          sandboxes: [],
+        })
+        .run()
+        .pipe(Effect.orDie)
+      yield* Effect.promise(() =>
+        $`git remote add origin git@github.com:project-directory-test/collision.git`.cwd(tmp).quiet(),
+      )
+
+      yield* project.fromDirectory(tmp)
+
+      expect(yield* directories(remoteID)).toEqual([{ directory: tmp, type: "main" }])
+    }),
+  )
+})

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

@@ -20,6 +20,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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { testEffect } from "../lib/effect"
 import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -75,6 +76,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(EventV2Bridge.defaultLayer),
     Layer.provide(FSUtil.defaultLayer),
     Layer.provide(NodePath.layer),
@@ -87,6 +89,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(AppProcess.defaultLayer),
     Layer.provide(FSUtil.defaultLayer),
     Layer.provide(NodePath.layer),

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

@@ -199,6 +199,41 @@ const scenarios: Scenario[] = [
       },
       "status",
     ),
+  http.protected
+    .get("/project/{projectID}/directories", "project.directories")
+    .seeded((ctx) => ctx.project())
+    .at((ctx) => ({
+      path: route("/project/{projectID}/directories", { projectID: ctx.state.id }),
+      headers: ctx.headers(),
+    }))
+    .json(200, array, "status"),
+  http.protected
+    .post("/experimental/project/{projectID}/copy", "experimental.projectCopy.create")
+    .seeded((ctx) => ctx.project())
+    .at((ctx) => ({
+      path: route("/experimental/project/{projectID}/copy", { projectID: ctx.state.id }),
+      headers: ctx.headers(),
+      body: {},
+    }))
+    .status(400),
+  http.protected
+    .delete("/experimental/project/{projectID}/copy", "experimental.projectCopy.remove")
+    .seeded((ctx) => ctx.project())
+    .at((ctx) => ({
+      path: route("/experimental/project/{projectID}/copy", { projectID: ctx.state.id }),
+      headers: ctx.headers(),
+      body: {},
+    }))
+    .status(400),
+  http.protected
+    .post("/experimental/project/{projectID}/copy/refresh", "experimental.projectCopy.refresh")
+    .mutating()
+    .seeded((ctx) => ctx.project())
+    .at((ctx) => ({
+      path: route("/experimental/project/{projectID}/copy/refresh", { projectID: ctx.state.id }),
+      headers: ctx.headers(),
+    }))
+    .status(204, undefined, "status"),
   http.protected.get("/provider", "provider.list").json(),
   http.protected.get("/provider/auth", "provider.auth").json(),
   http.protected

+ 94 - 0
packages/opencode/test/server/project-copy.test.ts

@@ -0,0 +1,94 @@
+import { afterEach, describe, expect } from "bun:test"
+import { $ } from "bun"
+import fs from "fs/promises"
+import path from "path"
+import { Effect, Layer } from "effect"
+import { HttpClientResponse } from "effect/unstable/http"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import { Database } from "@opencode-ai/core/database/database"
+import { Snapshot } from "@/snapshot"
+import { InstanceBootstrap } from "@/project/bootstrap-service"
+import { InstanceStore } from "@/project/instance-store"
+import { resetDatabase } from "../fixture/db"
+import { disposeAllInstances, TestInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
+
+afterEach(async () => {
+  await disposeAllInstances()
+  await resetDatabase()
+})
+
+const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
+const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
+const it = testEffect(
+  Layer.mergeAll(
+    FSUtil.defaultLayer,
+    Database.defaultLayer,
+    Snapshot.defaultLayer,
+    testInstanceStore,
+    httpApiLayer,
+  ),
+)
+
+function request(directory: string, url: string, init: RequestInit = {}) {
+  return requestInDirectory(url, directory, init)
+}
+
+function json<T>(response: HttpClientResponse.HttpClientResponse) {
+  return response.json.pipe(Effect.map((value) => value as T))
+}
+
+describe("project directories and copies endpoints", () => {
+  it.instance(
+    "lists directories and manages git worktree copies",
+    () =>
+      Effect.gen(function* () {
+        const test = yield* TestInstance
+        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 createdDirectory = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
+        yield* Effect.addFinalizer(() =>
+          Effect.promise(() => fs.rm(createdDirectory, { recursive: true, force: true })).pipe(Effect.ignore),
+        )
+
+        const initial = yield* request(test.directory, `${base}/directories`)
+        expect(initial.status).toBe(200)
+        expect(yield* json<string[]>(initial)).toEqual([test.directory])
+
+        const create = yield* request(test.directory, copies, {
+          method: "POST",
+          headers: { "content-type": "application/json" },
+          body: JSON.stringify({ strategy: "git_worktree", directory: createdDirectory }),
+        })
+        expect(create.status).toBe(200)
+        const created = yield* json<{ directory: string }>(create)
+        expect(created.directory).toContain("-http-copy")
+
+        const listed = yield* request(test.directory, `${base}/directories`)
+        expect(yield* json<string[]>(listed)).toContain(created.directory)
+
+        const remove = yield* request(test.directory, copies, {
+          method: "DELETE",
+          headers: { "content-type": "application/json" },
+          body: JSON.stringify({ directory: created.directory }),
+        })
+        expect(remove.status).toBe(204)
+
+        const externalDirectory = path.join(test.directory, "..", path.basename(test.directory) + "-http-refresh")
+        yield* Effect.addFinalizer(() =>
+          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`, {
+          method: "POST",
+        })
+        expect(refresh.status).toBe(204)
+        const refreshed = yield* request(test.directory, `${base}/directories`)
+        expect((yield* json<string[]>(refreshed)).length).toBe(2)
+      }),
+    { git: true },
+  )
+})

+ 187 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -34,6 +34,12 @@ import type {
   ExperimentalConsoleListOrgsErrors,
   ExperimentalConsoleListOrgsResponses,
   ExperimentalConsoleSwitchOrgResponses,
+  ExperimentalProjectCopyCreateErrors,
+  ExperimentalProjectCopyCreateResponses,
+  ExperimentalProjectCopyRefreshErrors,
+  ExperimentalProjectCopyRefreshResponses,
+  ExperimentalProjectCopyRemoveErrors,
+  ExperimentalProjectCopyRemoveResponses,
   ExperimentalResourceListErrors,
   ExperimentalResourceListResponses,
   ExperimentalSessionListErrors,
@@ -120,6 +126,8 @@ import type {
   PermissionV2Reply,
   ProjectCurrentErrors,
   ProjectCurrentResponses,
+  ProjectDirectoriesErrors,
+  ProjectDirectoriesResponses,
   ProjectInitGitErrors,
   ProjectInitGitResponses,
   ProjectListErrors,
@@ -937,6 +945,148 @@ export class Resource extends HeyApiClient {
   }
 }
 
+export class ProjectCopy extends HeyApiClient {
+  /**
+   * Remove project copy
+   *
+   * Remove a local physical copy of a project using the selected strategy.
+   */
+  public remove<ThrowOnError extends boolean = false>(
+    parameters: {
+      projectID: string
+      query_directory?: string
+      workspace?: string
+      body_directory?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "projectID" },
+            {
+              in: "query",
+              key: "query_directory",
+              map: "directory",
+            },
+            { in: "query", key: "workspace" },
+            {
+              in: "body",
+              key: "body_directory",
+              map: "directory",
+            },
+          ],
+        },
+      ],
+    )
+    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
+      query_directory?: string
+      workspace?: string
+      strategy?: "git_worktree"
+      body_directory?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "projectID" },
+            {
+              in: "query",
+              key: "query_directory",
+              map: "directory",
+            },
+            { in: "query", key: "workspace" },
+            { in: "body", key: "strategy" },
+            {
+              in: "body",
+              key: "body_directory",
+              map: "directory",
+            },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<
+      ExperimentalProjectCopyCreateResponses,
+      ExperimentalProjectCopyCreateErrors,
+      ThrowOnError
+    >({
+      url: "/experimental/project/{projectID}/copy",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
+  /**
+   * 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 {
   /**
    * List workspace adapters
@@ -1226,6 +1376,11 @@ export class Experimental extends HeyApiClient {
     return (this._resource ??= new Resource({ client: this.client }))
   }
 
+  private _projectCopy?: ProjectCopy
+  get projectCopy(): ProjectCopy {
+    return (this._projectCopy ??= new ProjectCopy({ client: this.client }))
+  }
+
   private _workspace?: Workspace
   get workspace(): Workspace {
     return (this._workspace ??= new Workspace({ client: this.client }))
@@ -2388,6 +2543,38 @@ export class Project extends HeyApiClient {
       },
     })
   }
+
+  /**
+   * List project directories
+   *
+   * List known local absolute directories for a project.
+   */
+  public directories<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).get<ProjectDirectoriesResponses, ProjectDirectoriesErrors, ThrowOnError>({
+      url: "/project/{projectID}/directories",
+      ...options,
+      ...params,
+    })
+  }
 }
 
 export class Pty extends HeyApiClient {

+ 153 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -63,6 +63,7 @@ export type Event =
   | EventMcpToolsChanged
   | EventMcpBrowserOpenFailed
   | EventCommandExecuted
+  | EventProjectDirectoriesUpdated
   | EventProjectUpdated
   | EventPtyCreated
   | EventPtyUpdated
@@ -1300,6 +1301,13 @@ export type GlobalEvent = {
           messageID: string
         }
       }
+    | {
+        id: string
+        type: "project.directories.updated"
+        properties: {
+          projectID: string
+        }
+      }
     | {
         id: string
         type: "project.updated"
@@ -3366,6 +3374,12 @@ export type ConfigV2ExperimentalPolicy = {
   resource: string
 }
 
+export type ProjectDirectories = Array<string>
+
+export type ProjectCopyCopy = {
+  directory: string
+}
+
 export type LocationRef = {
   directory: string
   workspaceID?: string
@@ -4406,6 +4420,14 @@ export type EventCommandExecuted = {
   }
 }
 
+export type EventProjectDirectoriesUpdated = {
+  id: string
+  type: "project.directories.updated"
+  properties: {
+    projectID: string
+  }
+}
+
 export type EventProjectUpdated = {
   id: string
   type: "project.updated"
@@ -6254,6 +6276,137 @@ export type ProjectUpdateResponses = {
 
 export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses]
 
+export type ProjectDirectoriesData = {
+  body?: never
+  path: {
+    projectID: string
+  }
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/project/{projectID}/directories"
+}
+
+export type ProjectDirectoriesErrors = {
+  /**
+   * Bad request
+   */
+  400: BadRequestError
+}
+
+export type ProjectDirectoriesError = ProjectDirectoriesErrors[keyof ProjectDirectoriesErrors]
+
+export type ProjectDirectoriesResponses = {
+  /**
+   * Project directories
+   */
+  200: ProjectDirectories
+}
+
+export type ProjectDirectoriesResponse = ProjectDirectoriesResponses[keyof ProjectDirectoriesResponses]
+
+export type ExperimentalProjectCopyRemoveData = {
+  body?: {
+    directory: string
+  }
+  path: {
+    projectID: string
+  }
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/experimental/project/{projectID}/copy"
+}
+
+export type ExperimentalProjectCopyRemoveErrors = {
+  /**
+   * BadRequest | InvalidRequestError
+   */
+  400: EffectHttpApiErrorBadRequest | 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
+  }
+  path: {
+    projectID: string
+  }
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/experimental/project/{projectID}/copy"
+}
+
+export type ExperimentalProjectCopyCreateErrors = {
+  /**
+   * BadRequest | InvalidRequestError
+   */
+  400: EffectHttpApiErrorBadRequest | 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"
+}
+
+export type ExperimentalProjectCopyRefreshErrors = {
+  /**
+   * BadRequest | InvalidRequestError
+   */
+  400: EffectHttpApiErrorBadRequest | InvalidRequestError
+}
+
+export type ExperimentalProjectCopyRefreshError =
+  ExperimentalProjectCopyRefreshErrors[keyof ExperimentalProjectCopyRefreshErrors]
+
+export type ExperimentalProjectCopyRefreshResponses = {
+  /**
+   * Project copies refreshed
+   */
+  204: void
+}
+
+export type ExperimentalProjectCopyRefreshResponse =
+  ExperimentalProjectCopyRefreshResponses[keyof ExperimentalProjectCopyRefreshResponses]
+
 export type PtyShellsData = {
   body?: never
   path?: never

+ 360 - 0
packages/sdk/openapi.json

@@ -3699,6 +3699,295 @@
         ]
       }
     },
+    "/project/{projectID}/directories": {
+      "get": {
+        "tags": ["project"],
+        "operationId": "project.directories",
+        "parameters": [
+          {
+            "name": "projectID",
+            "in": "path",
+            "schema": {
+              "type": "string"
+            },
+            "required": true
+          },
+          {
+            "name": "directory",
+            "in": "query",
+            "schema": {
+              "type": "string"
+            },
+            "required": false
+          },
+          {
+            "name": "workspace",
+            "in": "query",
+            "schema": {
+              "type": "string"
+            },
+            "required": false
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Project directories",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/ProjectDirectories"
+                }
+              }
+            }
+          },
+          "400": {
+            "description": "Bad request",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/BadRequestError"
+                }
+              }
+            }
+          }
+        },
+        "description": "List known local absolute directories for a project.",
+        "summary": "List project directories",
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.directories({\n  ...\n})"
+          }
+        ]
+      }
+    },
+    "/experimental/project/{projectID}/copy": {
+      "post": {
+        "tags": ["projectCopy"],
+        "operationId": "experimental.projectCopy.create",
+        "parameters": [
+          {
+            "name": "projectID",
+            "in": "path",
+            "schema": {
+              "type": "string"
+            },
+            "required": true
+          },
+          {
+            "name": "directory",
+            "in": "query",
+            "schema": {
+              "type": "string"
+            },
+            "required": false
+          },
+          {
+            "name": "workspace",
+            "in": "query",
+            "schema": {
+              "type": "string"
+            },
+            "required": false
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Project copy created",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/ProjectCopyCopy"
+                }
+              }
+            }
+          },
+          "400": {
+            "description": "BadRequest | InvalidRequestError",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "anyOf": [
+                    {
+                      "$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
+                    },
+                    {
+                      "$ref": "#/components/schemas/InvalidRequestError"
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        },
+        "description": "Create a local physical copy of a project using the selected strategy.",
+        "summary": "Create project copy",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "strategy": {
+                    "type": "string",
+                    "enum": ["git_worktree"]
+                  },
+                  "directory": {
+                    "type": "string"
+                  }
+                },
+                "required": ["strategy", "directory"],
+                "additionalProperties": false
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.projectCopy.create({\n  ...\n})"
+          }
+        ]
+      },
+      "delete": {
+        "tags": ["projectCopy"],
+        "operationId": "experimental.projectCopy.remove",
+        "parameters": [
+          {
+            "name": "projectID",
+            "in": "path",
+            "schema": {
+              "type": "string"
+            },
+            "required": true
+          },
+          {
+            "name": "directory",
+            "in": "query",
+            "schema": {
+              "type": "string"
+            },
+            "required": false
+          },
+          {
+            "name": "workspace",
+            "in": "query",
+            "schema": {
+              "type": "string"
+            },
+            "required": false
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "Project copy removed"
+          },
+          "400": {
+            "description": "BadRequest | InvalidRequestError",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "anyOf": [
+                    {
+                      "$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
+                    },
+                    {
+                      "$ref": "#/components/schemas/InvalidRequestError"
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        },
+        "description": "Remove a local physical copy of a project using the selected strategy.",
+        "summary": "Remove project copy",
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "directory": {
+                    "type": "string"
+                  }
+                },
+                "required": ["directory"],
+                "additionalProperties": false
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.projectCopy.remove({\n  ...\n})"
+          }
+        ]
+      }
+    },
+    "/experimental/project/{projectID}/copy/refresh": {
+      "post": {
+        "tags": ["projectCopy"],
+        "operationId": "experimental.projectCopy.refresh",
+        "parameters": [
+          {
+            "name": "projectID",
+            "in": "path",
+            "schema": {
+              "type": "string"
+            },
+            "required": true
+          },
+          {
+            "name": "directory",
+            "in": "query",
+            "schema": {
+              "type": "string"
+            },
+            "required": false
+          },
+          {
+            "name": "workspace",
+            "in": "query",
+            "schema": {
+              "type": "string"
+            },
+            "required": false
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "Project copies refreshed"
+          },
+          "400": {
+            "description": "BadRequest | InvalidRequestError",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "anyOf": [
+                    {
+                      "$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
+                    },
+                    {
+                      "$ref": "#/components/schemas/InvalidRequestError"
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        },
+        "description": "Discover local project copies using one or all configured strategies.",
+        "summary": "Refresh project copies",
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.projectCopy.refresh({\n  ...\n})"
+          }
+        ]
+      }
+    },
     "/pty/shells": {
       "get": {
         "tags": ["pty"],
@@ -11173,6 +11462,9 @@
           {
             "$ref": "#/components/schemas/EventCommandExecuted"
           },
+          {
+            "$ref": "#/components/schemas/EventProjectDirectoriesUpdated"
+          },
           {
             "$ref": "#/components/schemas/EventProjectUpdated"
           },
@@ -15039,6 +15331,30 @@
                 "required": ["id", "type", "properties"],
                 "additionalProperties": false
               },
+              {
+                "type": "object",
+                "properties": {
+                  "id": {
+                    "type": "string"
+                  },
+                  "type": {
+                    "type": "string",
+                    "enum": ["project.directories.updated"]
+                  },
+                  "properties": {
+                    "type": "object",
+                    "properties": {
+                      "projectID": {
+                        "type": "string"
+                      }
+                    },
+                    "required": ["projectID"],
+                    "additionalProperties": false
+                  }
+                },
+                "required": ["id", "type", "properties"],
+                "additionalProperties": false
+              },
               {
                 "type": "object",
                 "properties": {
@@ -21149,6 +21465,22 @@
         "required": ["action", "effect", "resource"],
         "additionalProperties": false
       },
+      "ProjectDirectories": {
+        "type": "array",
+        "items": {
+          "type": "string"
+        }
+      },
+      "ProjectCopyCopy": {
+        "type": "object",
+        "properties": {
+          "directory": {
+            "type": "string"
+          }
+        },
+        "required": ["directory"],
+        "additionalProperties": false
+      },
       "LocationRef": {
         "type": "object",
         "properties": {
@@ -24299,6 +24631,30 @@
         "required": ["id", "type", "properties"],
         "additionalProperties": false
       },
+      "EventProjectDirectoriesUpdated": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string"
+          },
+          "type": {
+            "type": "string",
+            "enum": ["project.directories.updated"]
+          },
+          "properties": {
+            "type": "object",
+            "properties": {
+              "projectID": {
+                "type": "string"
+              }
+            },
+            "required": ["projectID"],
+            "additionalProperties": false
+          }
+        },
+        "required": ["id", "type", "properties"],
+        "additionalProperties": false
+      },
       "EventProjectUpdated": {
         "type": "object",
         "properties": {
@@ -24947,6 +25303,10 @@
       "name": "project",
       "description": "Experimental HttpApi project routes."
     },
+    {
+      "name": "projectCopy",
+      "description": "Project copy management routes."
+    },
     {
       "name": "pty",
       "description": "Experimental HttpApi PTY routes."