瀏覽代碼

Merge branch 'dev' into fix/lsp-dead-root-prune

Kit Langton 5 天之前
父節點
當前提交
d5d9260383
共有 50 個文件被更改,包括 2606 次插入725 次删除
  1. 1 0
      .github/VOUCHED.td
  2. 16 0
      packages/opencode/migration/20260410174513_workspace-name/migration.sql
  3. 1271 0
      packages/opencode/migration/20260410174513_workspace-name/snapshot.json
  4. 1 1
      packages/opencode/script/seed-e2e.ts
  5. 2 1
      packages/opencode/src/cli/cmd/debug/config.ts
  6. 22 4
      packages/opencode/src/cli/cmd/debug/file.ts
  7. 82 46
      packages/opencode/src/cli/cmd/mcp.ts
  8. 1 1
      packages/opencode/src/cli/cmd/providers.ts
  9. 40 8
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
  10. 1 1
      packages/opencode/src/cli/cmd/tui/worker.ts
  11. 2 1
      packages/opencode/src/cli/network.ts
  12. 1 1
      packages/opencode/src/cli/upgrade.ts
  13. 0 39
      packages/opencode/src/config/config.ts
  14. 2 1
      packages/opencode/src/config/tui.ts
  15. 43 11
      packages/opencode/src/control-plane/adaptors/index.ts
  16. 10 10
      packages/opencode/src/control-plane/adaptors/worktree.ts
  17. 8 6
      packages/opencode/src/control-plane/types.ts
  18. 1 1
      packages/opencode/src/control-plane/workspace.sql.ts
  19. 5 4
      packages/opencode/src/control-plane/workspace.ts
  20. 0 23
      packages/opencode/src/file/index.ts
  21. 0 34
      packages/opencode/src/mcp/index.ts
  22. 14 1
      packages/opencode/src/plugin/index.ts
  23. 2 2
      packages/opencode/src/server/instance/config.ts
  24. 8 1
      packages/opencode/src/server/instance/experimental.ts
  25. 28 9
      packages/opencode/src/server/instance/file.ts
  26. 2 2
      packages/opencode/src/server/instance/global.ts
  27. 34 14
      packages/opencode/src/server/instance/mcp.ts
  28. 1 1
      packages/opencode/src/server/instance/middleware.ts
  29. 2 1
      packages/opencode/src/server/instance/provider.ts
  30. 28 0
      packages/opencode/src/server/instance/workspace.ts
  31. 4 1
      packages/opencode/test/config/agent-color.test.ts
  32. 77 67
      packages/opencode/test/config/config.test.ts
  33. 6 3
      packages/opencode/test/config/tui.test.ts
  34. 71 0
      packages/opencode/test/control-plane/adaptors.test.ts
  35. 9 3
      packages/opencode/test/file/fsmonitor.test.ts
  36. 88 78
      packages/opencode/test/file/index.test.ts
  37. 14 8
      packages/opencode/test/file/path-traversal.test.ts
  38. 45 20
      packages/opencode/test/mcp/headers.test.ts
  39. 330 296
      packages/opencode/test/mcp/lifecycle.test.ts
  40. 10 4
      packages/opencode/test/mcp/oauth-auto-connect.test.ts
  41. 22 3
      packages/opencode/test/mcp/oauth-browser.test.ts
  42. 9 6
      packages/opencode/test/permission-task.test.ts
  43. 3 0
      packages/opencode/test/plugin/github-copilot-models.test.ts
  44. 99 0
      packages/opencode/test/plugin/workspace-adaptor.test.ts
  45. 34 0
      packages/plugin/src/example-workspace.ts
  46. 33 0
      packages/plugin/src/index.ts
  47. 1 0
      packages/plugin/tsconfig.json
  48. 38 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  49. 25 1
      packages/sdk/js/src/v2/gen/types.gen.ts
  50. 60 11
      packages/sdk/openapi.json

+ 1 - 0
.github/VOUCHED.td

@@ -25,6 +25,7 @@ kommander
 -opencodeengineer bot that spams issues
 r44vc0rp
 rekram1-node
+-ricardo-m-l
 -robinmordasiewicz
 simonklee
 -spider-yamet clawdbot/llm psychosis, spam pinging the team

+ 16 - 0
packages/opencode/migration/20260410174513_workspace-name/migration.sql

@@ -0,0 +1,16 @@
+PRAGMA foreign_keys=OFF;--> statement-breakpoint
+CREATE TABLE `__new_workspace` (
+	`id` text PRIMARY KEY,
+	`type` text NOT NULL,
+	`name` text DEFAULT '' NOT NULL,
+	`branch` text,
+	`directory` text,
+	`extra` text,
+	`project_id` text NOT NULL,
+	CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+INSERT INTO `__new_workspace`(`id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id`) SELECT `id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id` FROM `workspace`;--> statement-breakpoint
+DROP TABLE `workspace`;--> statement-breakpoint
+ALTER TABLE `__new_workspace` RENAME TO `workspace`;--> statement-breakpoint
+PRAGMA foreign_keys=ON;

+ 1271 - 0
packages/opencode/migration/20260410174513_workspace-name/snapshot.json

@@ -0,0 +1,1271 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "id": "b61476b8-3b92-49ae-9fa5-6eef586ed64b",
+  "prevIds": ["f13dfa58-7fb4-47a2-8f6b-dc70258e14ed"],
+  "ddl": [
+    {
+      "name": "account_state",
+      "entityType": "tables"
+    },
+    {
+      "name": "account",
+      "entityType": "tables"
+    },
+    {
+      "name": "control_account",
+      "entityType": "tables"
+    },
+    {
+      "name": "workspace",
+      "entityType": "tables"
+    },
+    {
+      "name": "project",
+      "entityType": "tables"
+    },
+    {
+      "name": "message",
+      "entityType": "tables"
+    },
+    {
+      "name": "part",
+      "entityType": "tables"
+    },
+    {
+      "name": "permission",
+      "entityType": "tables"
+    },
+    {
+      "name": "session",
+      "entityType": "tables"
+    },
+    {
+      "name": "todo",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_share",
+      "entityType": "tables"
+    },
+    {
+      "name": "event_sequence",
+      "entityType": "tables"
+    },
+    {
+      "name": "event",
+      "entityType": "tables"
+    },
+    {
+      "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": "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": "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_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": "project_id",
+      "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": "data",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "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": 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": "revert",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "permission",
+      "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"
+    },
+    {
+      "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": "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"
+    },
+    {
+      "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": ["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": ["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": ["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_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": ["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": ["email", "url"],
+      "nameExplicit": false,
+      "name": "control_account_pk",
+      "entityType": "pks",
+      "table": "control_account"
+    },
+    {
+      "columns": ["session_id", "position"],
+      "nameExplicit": false,
+      "name": "todo_pk",
+      "entityType": "pks",
+      "table": "todo"
+    },
+    {
+      "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": ["id"],
+      "nameExplicit": false,
+      "name": "workspace_pk",
+      "table": "workspace",
+      "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": ["project_id"],
+      "nameExplicit": false,
+      "name": "permission_pk",
+      "table": "permission",
+      "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": ["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": [
+        {
+          "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": "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 - 1
packages/opencode/script/seed-e2e.ts

@@ -25,7 +25,7 @@ const seed = async () => {
       directory: dir,
       init: () => AppRuntime.runPromise(InstanceBootstrap),
       fn: async () => {
-        await Config.waitForDependencies()
+        await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.waitForDependencies()))
         await AppRuntime.runPromise(
           Effect.gen(function* () {
             const registry = yield* ToolRegistry.Service

+ 2 - 1
packages/opencode/src/cli/cmd/debug/config.ts

@@ -1,5 +1,6 @@
 import { EOL } from "os"
 import { Config } from "../../../config/config"
+import { AppRuntime } from "@/effect/app-runtime"
 import { bootstrap } from "../../bootstrap"
 import { cmd } from "../cmd"
 
@@ -9,7 +10,7 @@ export const ConfigCommand = cmd({
   builder: (yargs) => yargs,
   async handler() {
     await bootstrap(process.cwd(), async () => {
-      const config = await Config.get()
+      const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
       process.stdout.write(JSON.stringify(config, null, 2) + EOL)
     })
   },

+ 22 - 4
packages/opencode/src/cli/cmd/debug/file.ts

@@ -1,4 +1,6 @@
 import { EOL } from "os"
+import { Effect } from "effect"
+import { AppRuntime } from "@/effect/app-runtime"
 import { File } from "../../../file"
 import { bootstrap } from "../../bootstrap"
 import { cmd } from "../cmd"
@@ -15,7 +17,11 @@ const FileSearchCommand = cmd({
     }),
   async handler(args) {
     await bootstrap(process.cwd(), async () => {
-      const results = await File.search({ query: args.query })
+      const results = await AppRuntime.runPromise(
+        Effect.gen(function* () {
+          return yield* File.Service.use((svc) => svc.search({ query: args.query }))
+        }),
+      )
       process.stdout.write(results.join(EOL) + EOL)
     })
   },
@@ -32,7 +38,11 @@ const FileReadCommand = cmd({
     }),
   async handler(args) {
     await bootstrap(process.cwd(), async () => {
-      const content = await File.read(args.path)
+      const content = await AppRuntime.runPromise(
+        Effect.gen(function* () {
+          return yield* File.Service.use((svc) => svc.read(args.path))
+        }),
+      )
       process.stdout.write(JSON.stringify(content, null, 2) + EOL)
     })
   },
@@ -44,7 +54,11 @@ const FileStatusCommand = cmd({
   builder: (yargs) => yargs,
   async handler() {
     await bootstrap(process.cwd(), async () => {
-      const status = await File.status()
+      const status = await AppRuntime.runPromise(
+        Effect.gen(function* () {
+          return yield* File.Service.use((svc) => svc.status())
+        }),
+      )
       process.stdout.write(JSON.stringify(status, null, 2) + EOL)
     })
   },
@@ -61,7 +75,11 @@ const FileListCommand = cmd({
     }),
   async handler(args) {
     await bootstrap(process.cwd(), async () => {
-      const files = await File.list(args.path)
+      const files = await AppRuntime.runPromise(
+        Effect.gen(function* () {
+          return yield* File.Service.use((svc) => svc.list(args.path))
+        }),
+      )
       process.stdout.write(JSON.stringify(files, null, 2) + EOL)
     })
   },

+ 82 - 46
packages/opencode/src/cli/cmd/mcp.ts

@@ -15,6 +15,8 @@ import { Global } from "../../global"
 import { modify, applyEdits } from "jsonc-parser"
 import { Filesystem } from "../../util/filesystem"
 import { Bus } from "../../bus"
+import { AppRuntime } from "../../effect/app-runtime"
+import { Effect } from "effect"
 
 function getAuthStatusIcon(status: MCP.AuthStatus): string {
   switch (status) {
@@ -50,6 +52,47 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
   return isMcpConfigured(config) && config.type === "remote"
 }
 
+function configuredServers(config: Config.Info) {
+  return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
+}
+
+function oauthServers(config: Config.Info) {
+  return configuredServers(config).filter(
+    (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
+  )
+}
+
+async function listState() {
+  return AppRuntime.runPromise(
+    Effect.gen(function* () {
+      const cfg = yield* Config.Service
+      const mcp = yield* MCP.Service
+      const config = yield* cfg.get()
+      const statuses = yield* mcp.status()
+      const stored = yield* Effect.all(
+        Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
+        { concurrency: "unbounded" },
+      )
+      return { config, statuses, stored }
+    }),
+  )
+}
+
+async function authState() {
+  return AppRuntime.runPromise(
+    Effect.gen(function* () {
+      const cfg = yield* Config.Service
+      const mcp = yield* MCP.Service
+      const config = yield* cfg.get()
+      const auth = yield* Effect.all(
+        Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
+        { concurrency: "unbounded" },
+      )
+      return { config, auth }
+    }),
+  )
+}
+
 export const McpCommand = cmd({
   command: "mcp",
   describe: "manage MCP (Model Context Protocol) servers",
@@ -75,13 +118,8 @@ export const McpListCommand = cmd({
         UI.empty()
         prompts.intro("MCP Servers")
 
-        const config = await Config.get()
-        const mcpServers = config.mcp ?? {}
-        const statuses = await MCP.status()
-
-        const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] =>
-          isMcpConfigured(entry[1]),
-        )
+        const { config, statuses, stored } = await listState()
+        const servers = configuredServers(config)
 
         if (servers.length === 0) {
           prompts.log.warn("No MCP servers configured")
@@ -92,7 +130,7 @@ export const McpListCommand = cmd({
         for (const [name, serverConfig] of servers) {
           const status = statuses[name]
           const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
-          const hasStoredTokens = await MCP.hasStoredTokens(name)
+          const hasStoredTokens = stored[name]
 
           let statusIcon: string
           let statusText: string
@@ -152,15 +190,11 @@ export const McpAuthCommand = cmd({
         UI.empty()
         prompts.intro("MCP OAuth Authentication")
 
-        const config = await Config.get()
+        const { config, auth } = await authState()
         const mcpServers = config.mcp ?? {}
+        const servers = oauthServers(config)
 
-        // Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
-        const oauthServers = Object.entries(mcpServers).filter(
-          (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
-        )
-
-        if (oauthServers.length === 0) {
+        if (servers.length === 0) {
           prompts.log.warn("No OAuth-capable MCP servers configured")
           prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
           prompts.log.info(`
@@ -177,19 +211,17 @@ export const McpAuthCommand = cmd({
         let serverName = args.name
         if (!serverName) {
           // Build options with auth status
-          const options = await Promise.all(
-            oauthServers.map(async ([name, cfg]) => {
-              const authStatus = await MCP.getAuthStatus(name)
-              const icon = getAuthStatusIcon(authStatus)
-              const statusText = getAuthStatusText(authStatus)
-              const url = cfg.url
-              return {
-                label: `${icon} ${name} (${statusText})`,
-                value: name,
-                hint: url,
-              }
-            }),
-          )
+          const options = servers.map(([name, cfg]) => {
+            const authStatus = auth[name]
+            const icon = getAuthStatusIcon(authStatus)
+            const statusText = getAuthStatusText(authStatus)
+            const url = cfg.url
+            return {
+              label: `${icon} ${name} (${statusText})`,
+              value: name,
+              hint: url,
+            }
+          })
 
           const selected = await prompts.select({
             message: "Select MCP server to authenticate",
@@ -213,7 +245,8 @@ export const McpAuthCommand = cmd({
         }
 
         // Check if already authenticated
-        const authStatus = await MCP.getAuthStatus(serverName)
+        const authStatus =
+          auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))))
         if (authStatus === "authenticated") {
           const confirm = await prompts.confirm({
             message: `${serverName} already has valid credentials. Re-authenticate?`,
@@ -240,7 +273,7 @@ export const McpAuthCommand = cmd({
         })
 
         try {
-          const status = await MCP.authenticate(serverName)
+          const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName)))
 
           if (status.status === "connected") {
             spinner.stop("Authentication successful!")
@@ -289,22 +322,17 @@ export const McpAuthListCommand = cmd({
         UI.empty()
         prompts.intro("MCP OAuth Status")
 
-        const config = await Config.get()
-        const mcpServers = config.mcp ?? {}
-
-        // Get OAuth-capable servers
-        const oauthServers = Object.entries(mcpServers).filter(
-          (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
-        )
+        const { config, auth } = await authState()
+        const servers = oauthServers(config)
 
-        if (oauthServers.length === 0) {
+        if (servers.length === 0) {
           prompts.log.warn("No OAuth-capable MCP servers configured")
           prompts.outro("Done")
           return
         }
 
-        for (const [name, serverConfig] of oauthServers) {
-          const authStatus = await MCP.getAuthStatus(name)
+        for (const [name, serverConfig] of servers) {
+          const authStatus = auth[name]
           const icon = getAuthStatusIcon(authStatus)
           const statusText = getAuthStatusText(authStatus)
           const url = serverConfig.url
@@ -312,7 +340,7 @@ export const McpAuthListCommand = cmd({
           prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n    ${UI.Style.TEXT_DIM}${url}`)
         }
 
-        prompts.outro(`${oauthServers.length} OAuth-capable server(s)`)
+        prompts.outro(`${servers.length} OAuth-capable server(s)`)
       },
     })
   },
@@ -334,7 +362,7 @@ export const McpLogoutCommand = cmd({
         prompts.intro("MCP OAuth Logout")
 
         const authPath = path.join(Global.Path.data, "mcp-auth.json")
-        const credentials = await McpAuth.all()
+        const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all()))
         const serverNames = Object.keys(credentials)
 
         if (serverNames.length === 0) {
@@ -372,7 +400,7 @@ export const McpLogoutCommand = cmd({
           return
         }
 
-        await MCP.removeAuth(serverName)
+        await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName)))
         prompts.log.success(`Removed OAuth credentials for ${serverName}`)
         prompts.outro("Done")
       },
@@ -595,7 +623,7 @@ export const McpDebugCommand = cmd({
         UI.empty()
         prompts.intro("MCP OAuth Debug")
 
-        const config = await Config.get()
+        const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
         const mcpServers = config.mcp ?? {}
         const serverName = args.name
 
@@ -622,10 +650,18 @@ export const McpDebugCommand = cmd({
         prompts.log.info(`URL: ${serverConfig.url}`)
 
         // Check stored auth status
-        const authStatus = await MCP.getAuthStatus(serverName)
+        const { authStatus, entry } = await AppRuntime.runPromise(
+          Effect.gen(function* () {
+            const mcp = yield* MCP.Service
+            const auth = yield* McpAuth.Service
+            return {
+              authStatus: yield* mcp.getAuthStatus(serverName),
+              entry: yield* auth.get(serverName),
+            }
+          }),
+        )
         prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
 
-        const entry = await McpAuth.get(serverName)
         if (entry?.tokens) {
           prompts.log.info(`  Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
           if (entry.tokens.expiresAt) {

+ 1 - 1
packages/opencode/src/cli/cmd/providers.ts

@@ -326,7 +326,7 @@ export const ProvidersLoginCommand = cmd({
         }
         await ModelsDev.refresh(true).catch(() => {})
 
-        const config = await Config.get()
+        const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
 
         const disabled = new Set(config.disabled_providers ?? [])
         const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined

+ 40 - 8
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx

@@ -9,6 +9,12 @@ import { setTimeout as sleep } from "node:timers/promises"
 import { useSDK } from "../context/sdk"
 import { useToast } from "../ui/toast"
 
+type Adaptor = {
+  type: string
+  name: string
+  description: string
+}
+
 function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
   return createOpencodeClient({
     baseUrl: sdk.url,
@@ -63,9 +69,27 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
   const sdk = useSDK()
   const toast = useToast()
   const [creating, setCreating] = createSignal<string>()
+  const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
 
   onMount(() => {
     dialog.setSize("medium")
+    void (async () => {
+      const dir = sync.path.directory || sdk.directory
+      const url = new URL("/experimental/workspace/adaptor", sdk.url)
+      if (dir) url.searchParams.set("directory", dir)
+      const res = await sdk
+        .fetch(url)
+        .then((x) => x.json() as Promise<Adaptor[]>)
+        .catch(() => undefined)
+      if (!res) {
+        toast.show({
+          message: "Failed to load workspace adaptors",
+          variant: "error",
+        })
+        return
+      }
+      setAdaptors(res)
+    })()
   })
 
   const options = createMemo(() => {
@@ -79,13 +103,21 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
         },
       ]
     }
-    return [
-      {
-        title: "Worktree",
-        value: "worktree" as const,
-        description: "Create a local git worktree",
-      },
-    ]
+    const list = adaptors()
+    if (!list) {
+      return [
+        {
+          title: "Loading workspaces...",
+          value: "loading" as const,
+          description: "Fetching available workspace adaptors",
+        },
+      ]
+    }
+    return list.map((item) => ({
+      title: item.name,
+      value: item.type,
+      description: item.description,
+    }))
   })
 
   const create = async (type: string) => {
@@ -113,7 +145,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
       skipFilter={true}
       options={options()}
       onSelect={(option) => {
-        if (option.value === "creating") return
+        if (option.value === "creating" || option.value === "loading") return
         void create(option.value)
       }}
     />

+ 1 - 1
packages/opencode/src/cli/cmd/tui/worker.ts

@@ -81,7 +81,7 @@ export const rpc = {
     })
   },
   async reload() {
-    await Config.invalidate(true)
+    await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
   },
   async shutdown() {
     Log.Default.info("worker shutting down")

+ 2 - 1
packages/opencode/src/cli/network.ts

@@ -1,5 +1,6 @@
 import type { Argv, InferredOptionTypes } from "yargs"
 import { Config } from "../config/config"
+import { AppRuntime } from "@/effect/app-runtime"
 
 const options = {
   port: {
@@ -37,7 +38,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
 }
 
 export async function resolveNetworkOptions(args: NetworkOptions) {
-  const config = await Config.getGlobal()
+  const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
   const portExplicitlySet = process.argv.includes("--port")
   const hostnameExplicitlySet = process.argv.includes("--hostname")
   const mdnsExplicitlySet = process.argv.includes("--mdns")

+ 1 - 1
packages/opencode/src/cli/upgrade.ts

@@ -5,7 +5,7 @@ import { Flag } from "@/flag/flag"
 import { Installation } from "@/installation"
 
 export async function upgrade() {
-  const config = await Config.getGlobal()
+  const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
   const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
   const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
   if (!latest) return

+ 0 - 39
packages/opencode/src/config/config.ts

@@ -33,7 +33,6 @@ import { ConfigPaths } from "./paths"
 import type { ConsoleState } from "./console-state"
 import { AppFileSystem } from "@/filesystem"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
 import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
 import { Flock } from "@/util/flock"
 import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
@@ -1661,42 +1660,4 @@ export namespace Config {
     Layer.provide(Auth.defaultLayer),
     Layer.provide(Account.defaultLayer),
   )
-
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export async function get() {
-    return runPromise((svc) => svc.get())
-  }
-
-  export async function getGlobal() {
-    return runPromise((svc) => svc.getGlobal())
-  }
-
-  export async function getConsoleState() {
-    return runPromise((svc) => svc.getConsoleState())
-  }
-
-  export async function installDependencies(dir: string, input?: InstallInput) {
-    return runPromise((svc) => svc.installDependencies(dir, input))
-  }
-
-  export async function update(config: Info) {
-    return runPromise((svc) => svc.update(config))
-  }
-
-  export async function updateGlobal(config: Info) {
-    return runPromise((svc) => svc.updateGlobal(config))
-  }
-
-  export async function invalidate(wait = false) {
-    return runPromise((svc) => svc.invalidate(wait))
-  }
-
-  export async function directories() {
-    return runPromise((svc) => svc.directories())
-  }
-
-  export async function waitForDependencies() {
-    return runPromise((svc) => svc.waitForDependencies())
-  }
 }

+ 2 - 1
packages/opencode/src/config/tui.ts

@@ -10,6 +10,7 @@ import { Flag } from "@/flag/flag"
 import { Log } from "@/util/log"
 import { isRecord } from "@/util/record"
 import { Global } from "@/global"
+import { AppRuntime } from "@/effect/app-runtime"
 
 export namespace TuiConfig {
   const log = Log.create({ service: "tui.config" })
@@ -51,7 +52,7 @@ export namespace TuiConfig {
   }
 
   function installDeps(dir: string): Promise<void> {
-    return Config.installDependencies(dir)
+    return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir)))
   }
 
   async function mergeFile(acc: Acc, file: string) {

+ 43 - 11
packages/opencode/src/control-plane/adaptors/index.ts

@@ -1,20 +1,52 @@
 import { lazy } from "@/util/lazy"
-import type { Adaptor } from "../types"
+import type { ProjectID } from "@/project/schema"
+import type { WorkspaceAdaptor } from "../types"
 
-const ADAPTORS: Record<string, () => Promise<Adaptor>> = {
+export type WorkspaceAdaptorEntry = {
+  type: string
+  name: string
+  description: string
+}
+
+const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
   worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
 }
 
-export function getAdaptor(type: string): Promise<Adaptor> {
-  return ADAPTORS[type]()
+const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
+
+export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
+  const custom = state.get(projectID)?.get(type)
+  if (custom) return custom
+
+  const builtin = BUILTIN[type]
+  if (builtin) return builtin()
+
+  throw new Error(`Unknown workspace adaptor: ${type}`)
 }
 
-export function installAdaptor(type: string, adaptor: Adaptor) {
-  // This is experimental: mostly used for testing right now, but we
-  // will likely allow this in the future. Need to figure out the
-  // TypeScript story
+export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
+  const builtin = await Promise.all(
+    Object.entries(BUILTIN).map(async ([type, init]) => {
+      const adaptor = await init()
+      return {
+        type,
+        name: adaptor.name,
+        description: adaptor.description,
+      }
+    }),
+  )
+  const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
+    type,
+    name: adaptor.name,
+    description: adaptor.description,
+  }))
+  return [...builtin, ...custom]
+}
 
-  // @ts-expect-error we force the builtin types right now, but we
-  // will implement a way to extend the types for custom adaptors
-  ADAPTORS[type] = () => adaptor
+// Plugins can be loaded per-project so we need to scope them. If you
+// want to install a global one pass `ProjectID.global`
+export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) {
+  const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
+  adaptors.set(type, adaptor)
+  state.set(projectID, adaptors)
 }

+ 10 - 10
packages/opencode/src/control-plane/adaptors/worktree.ts

@@ -1,18 +1,18 @@
 import z from "zod"
 import { Worktree } from "@/worktree"
-import { type Adaptor, WorkspaceInfo } from "../types"
+import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
 
-const Config = WorkspaceInfo.extend({
-  name: WorkspaceInfo.shape.name.unwrap(),
+const WorktreeConfig = z.object({
+  name: WorkspaceInfo.shape.name,
   branch: WorkspaceInfo.shape.branch.unwrap(),
   directory: WorkspaceInfo.shape.directory.unwrap(),
 })
 
-type Config = z.infer<typeof Config>
-
-export const WorktreeAdaptor: Adaptor = {
+export const WorktreeAdaptor: WorkspaceAdaptor = {
+  name: "Worktree",
+  description: "Create a git worktree",
   async configure(info) {
-    const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined)
+    const worktree = await Worktree.makeWorktreeInfo(undefined)
     return {
       ...info,
       name: worktree.name,
@@ -21,7 +21,7 @@ export const WorktreeAdaptor: Adaptor = {
     }
   },
   async create(info) {
-    const config = Config.parse(info)
+    const config = WorktreeConfig.parse(info)
     await Worktree.createFromInfo({
       name: config.name,
       directory: config.directory,
@@ -29,11 +29,11 @@ export const WorktreeAdaptor: Adaptor = {
     })
   },
   async remove(info) {
-    const config = Config.parse(info)
+    const config = WorktreeConfig.parse(info)
     await Worktree.remove({ directory: config.directory })
   },
   target(info) {
-    const config = Config.parse(info)
+    const config = WorktreeConfig.parse(info)
     return {
       type: "local",
       directory: config.directory,

+ 8 - 6
packages/opencode/src/control-plane/types.ts

@@ -5,8 +5,8 @@ import { WorkspaceID } from "./schema"
 export const WorkspaceInfo = z.object({
   id: WorkspaceID.zod,
   type: z.string(),
+  name: z.string(),
   branch: z.string().nullable(),
-  name: z.string().nullable(),
   directory: z.string().nullable(),
   extra: z.unknown().nullable(),
   projectID: ProjectID.zod,
@@ -24,9 +24,11 @@ export type Target =
       headers?: HeadersInit
     }
 
-export type Adaptor = {
-  configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
-  create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
-  remove(config: WorkspaceInfo): Promise<void>
-  target(config: WorkspaceInfo): Target | Promise<Target>
+export type WorkspaceAdaptor = {
+  name: string
+  description: string
+  configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
+  create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
+  remove(info: WorkspaceInfo): Promise<void>
+  target(info: WorkspaceInfo): Target | Promise<Target>
 }

+ 1 - 1
packages/opencode/src/control-plane/workspace.sql.ts

@@ -6,8 +6,8 @@ import type { WorkspaceID } from "./schema"
 export const WorkspaceTable = sqliteTable("workspace", {
   id: text().$type<WorkspaceID>().primaryKey(),
   type: text().notNull(),
+  name: text().notNull().default(""),
   branch: text(),
-  name: text(),
   directory: text(),
   extra: text({ mode: "json" }),
   project_id: text()

+ 5 - 4
packages/opencode/src/control-plane/workspace.ts

@@ -9,6 +9,7 @@ import { SyncEvent } from "@/sync"
 import { Log } from "@/util/log"
 import { Filesystem } from "@/util/filesystem"
 import { ProjectID } from "@/project/schema"
+import { Slug } from "@opencode-ai/util/slug"
 import { WorkspaceTable } from "./workspace.sql"
 import { getAdaptor } from "./adaptors"
 import { WorkspaceInfo } from "./types"
@@ -66,9 +67,9 @@ export namespace Workspace {
 
   export const create = fn(CreateInput, async (input) => {
     const id = WorkspaceID.ascending(input.id)
-    const adaptor = await getAdaptor(input.type)
+    const adaptor = await getAdaptor(input.projectID, input.type)
 
-    const config = await adaptor.configure({ ...input, id, name: null, directory: null })
+    const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
 
     const info: Info = {
       id,
@@ -124,7 +125,7 @@ export namespace Workspace {
       stopSync(id)
 
       const info = fromRow(row)
-      const adaptor = await getAdaptor(row.type)
+      const adaptor = await getAdaptor(info.projectID, row.type)
       adaptor.remove(info)
       Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
       return info
@@ -162,7 +163,7 @@ export namespace Workspace {
       log.info("connecting to sync: " + space.id)
 
       setStatus(space.id, "connecting")
-      const adaptor = await getAdaptor(space.type)
+      const adaptor = await getAdaptor(space.projectID, space.type)
       const target = await adaptor.target(space)
 
       if (target.type === "local") return

+ 0 - 23
packages/opencode/src/file/index.ts

@@ -1,6 +1,5 @@
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
 import { AppFileSystem } from "@/filesystem"
 import { Git } from "@/git"
 import { Effect, Layer, Context } from "effect"
@@ -644,26 +643,4 @@ export namespace File {
   )
 
   export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
-
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export function init() {
-    return runPromise((svc) => svc.init())
-  }
-
-  export async function status() {
-    return runPromise((svc) => svc.status())
-  }
-
-  export async function read(file: string): Promise<Content> {
-    return runPromise((svc) => svc.read(file))
-  }
-
-  export async function list(dir?: string) {
-    return runPromise((svc) => svc.list(dir))
-  }
-
-  export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
-    return runPromise((svc) => svc.search(input))
-  }
 }

+ 0 - 34
packages/opencode/src/mcp/index.ts

@@ -27,7 +27,6 @@ import open from "open"
 import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
 import { EffectLogger } from "@/effect/logger"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 
@@ -890,37 +889,4 @@ export namespace MCP {
     Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provide(AppFileSystem.defaultLayer),
   )
-
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  // --- Async facade functions ---
-
-  export const status = async () => runPromise((svc) => svc.status())
-
-  export const tools = async () => runPromise((svc) => svc.tools())
-
-  export const prompts = async () => runPromise((svc) => svc.prompts())
-
-  export const resources = async () => runPromise((svc) => svc.resources())
-
-  export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))
-
-  export const connect = async (name: string) => runPromise((svc) => svc.connect(name))
-
-  export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
-
-  export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
-
-  export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
-
-  export const finishAuth = async (mcpName: string, authorizationCode: string) =>
-    runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))
-
-  export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))
-
-  export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))
-
-  export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))
-
-  export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
 }

+ 14 - 1
packages/opencode/src/plugin/index.ts

@@ -1,4 +1,10 @@
-import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
+import type {
+  Hooks,
+  PluginInput,
+  Plugin as PluginInstance,
+  PluginModule,
+  WorkspaceAdaptor as PluginWorkspaceAdaptor,
+} from "@opencode-ai/plugin"
 import { Config } from "../config/config"
 import { Bus } from "../bus"
 import { Log } from "../util/log"
@@ -18,6 +24,8 @@ import { makeRuntime } from "@/effect/run-service"
 import { errorMessage } from "@/util/error"
 import { PluginLoader } from "./loader"
 import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
+import { registerAdaptor } from "@/control-plane/adaptors"
+import type { WorkspaceAdaptor } from "@/control-plane/types"
 
 export namespace Plugin {
   const log = Log.create({ service: "plugin" })
@@ -132,6 +140,11 @@ export namespace Plugin {
             project: ctx.project,
             worktree: ctx.worktree,
             directory: ctx.directory,
+            experimental_workspace: {
+              register(type: string, adaptor: PluginWorkspaceAdaptor) {
+                registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
+              },
+            },
             get serverUrl(): URL {
               return Server.url ?? new URL("http://localhost:4096")
             },

+ 2 - 2
packages/opencode/src/server/instance/config.ts

@@ -32,7 +32,7 @@ export const ConfigRoutes = lazy(() =>
         },
       }),
       async (c) => {
-        return c.json(await Config.get())
+        return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())))
       },
     )
     .patch(
@@ -56,7 +56,7 @@ export const ConfigRoutes = lazy(() =>
       validator("json", Config.Info),
       async (c) => {
         const config = c.req.valid("json")
-        await Config.update(config)
+        await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
         return c.json(config)
       },
     )

+ 8 - 1
packages/opencode/src/server/instance/experimental.ts

@@ -408,7 +408,14 @@ export const ExperimentalRoutes = lazy(() =>
         },
       }),
       async (c) => {
-        return c.json(await MCP.resources())
+        return c.json(
+          await AppRuntime.runPromise(
+            Effect.gen(function* () {
+              const mcp = yield* MCP.Service
+              return yield* mcp.resources()
+            }),
+          ),
+        )
       },
     ),
 )

+ 28 - 9
packages/opencode/src/server/instance/file.ts

@@ -1,5 +1,6 @@
 import { Hono } from "hono"
 import { describeRoute, validator, resolver } from "hono-openapi"
+import { Effect } from "effect"
 import z from "zod"
 import { AppRuntime } from "../../effect/app-runtime"
 import { File } from "../../file"
@@ -72,12 +73,18 @@ export const FileRoutes = lazy(() =>
         const dirs = c.req.valid("query").dirs
         const type = c.req.valid("query").type
         const limit = c.req.valid("query").limit
-        const results = await File.search({
-          query,
-          limit: limit ?? 10,
-          dirs: dirs !== "false",
-          type,
-        })
+        const results = await AppRuntime.runPromise(
+          Effect.gen(function* () {
+            return yield* File.Service.use((svc) =>
+              svc.search({
+                query,
+                limit: limit ?? 10,
+                dirs: dirs !== "false",
+                type,
+              }),
+            )
+          }),
+        )
         return c.json(results)
       },
     )
@@ -133,7 +140,11 @@ export const FileRoutes = lazy(() =>
       ),
       async (c) => {
         const path = c.req.valid("query").path
-        const content = await File.list(path)
+        const content = await AppRuntime.runPromise(
+          Effect.gen(function* () {
+            return yield* File.Service.use((svc) => svc.list(path))
+          }),
+        )
         return c.json(content)
       },
     )
@@ -162,7 +173,11 @@ export const FileRoutes = lazy(() =>
       ),
       async (c) => {
         const path = c.req.valid("query").path
-        const content = await File.read(path)
+        const content = await AppRuntime.runPromise(
+          Effect.gen(function* () {
+            return yield* File.Service.use((svc) => svc.read(path))
+          }),
+        )
         return c.json(content)
       },
     )
@@ -184,7 +199,11 @@ export const FileRoutes = lazy(() =>
         },
       }),
       async (c) => {
-        const content = await File.status()
+        const content = await AppRuntime.runPromise(
+          Effect.gen(function* () {
+            return yield* File.Service.use((svc) => svc.status())
+          }),
+        )
         return c.json(content)
       },
     ),

+ 2 - 2
packages/opencode/src/server/instance/global.ts

@@ -199,7 +199,7 @@ export const GlobalRoutes = lazy(() =>
         },
       }),
       async (c) => {
-        return c.json(await Config.getGlobal())
+        return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())))
       },
     )
     .patch(
@@ -223,7 +223,7 @@ export const GlobalRoutes = lazy(() =>
       validator("json", Config.Info),
       async (c) => {
         const config = c.req.valid("json")
-        const next = await Config.updateGlobal(config)
+        const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
         return c.json(next)
       },
     )

+ 34 - 14
packages/opencode/src/server/instance/mcp.ts

@@ -3,8 +3,10 @@ import { describeRoute, validator, resolver } from "hono-openapi"
 import z from "zod"
 import { MCP } from "../../mcp"
 import { Config } from "../../config/config"
+import { AppRuntime } from "../../effect/app-runtime"
 import { errors } from "../error"
 import { lazy } from "../../util/lazy"
+import { Effect } from "effect"
 
 export const McpRoutes = lazy(() =>
   new Hono()
@@ -26,7 +28,7 @@ export const McpRoutes = lazy(() =>
         },
       }),
       async (c) => {
-        return c.json(await MCP.status())
+        return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
       },
     )
     .post(
@@ -56,7 +58,7 @@ export const McpRoutes = lazy(() =>
       ),
       async (c) => {
         const { name, config } = c.req.valid("json")
-        const result = await MCP.add(name, config)
+        const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
         return c.json(result.status)
       },
     )
@@ -84,12 +86,21 @@ export const McpRoutes = lazy(() =>
       }),
       async (c) => {
         const name = c.req.param("name")
-        const supportsOAuth = await MCP.supportsOAuth(name)
-        if (!supportsOAuth) {
+        const result = await AppRuntime.runPromise(
+          Effect.gen(function* () {
+            const mcp = yield* MCP.Service
+            const supports = yield* mcp.supportsOAuth(name)
+            if (!supports) return { supports }
+            return {
+              supports,
+              auth: yield* mcp.startAuth(name),
+            }
+          }),
+        )
+        if (!result.supports) {
           return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
         }
-        const result = await MCP.startAuth(name)
-        return c.json(result)
+        return c.json(result.auth)
       },
     )
     .post(
@@ -120,7 +131,7 @@ export const McpRoutes = lazy(() =>
       async (c) => {
         const name = c.req.param("name")
         const { code } = c.req.valid("json")
-        const status = await MCP.finishAuth(name, code)
+        const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
         return c.json(status)
       },
     )
@@ -144,12 +155,21 @@ export const McpRoutes = lazy(() =>
       }),
       async (c) => {
         const name = c.req.param("name")
-        const supportsOAuth = await MCP.supportsOAuth(name)
-        if (!supportsOAuth) {
+        const result = await AppRuntime.runPromise(
+          Effect.gen(function* () {
+            const mcp = yield* MCP.Service
+            const supports = yield* mcp.supportsOAuth(name)
+            if (!supports) return { supports }
+            return {
+              supports,
+              status: yield* mcp.authenticate(name),
+            }
+          }),
+        )
+        if (!result.supports) {
           return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
         }
-        const status = await MCP.authenticate(name)
-        return c.json(status)
+        return c.json(result.status)
       },
     )
     .delete(
@@ -172,7 +192,7 @@ export const McpRoutes = lazy(() =>
       }),
       async (c) => {
         const name = c.req.param("name")
-        await MCP.removeAuth(name)
+        await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
         return c.json({ success: true as const })
       },
     )
@@ -195,7 +215,7 @@ export const McpRoutes = lazy(() =>
       validator("param", z.object({ name: z.string() })),
       async (c) => {
         const { name } = c.req.valid("param")
-        await MCP.connect(name)
+        await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
         return c.json(true)
       },
     )
@@ -218,7 +238,7 @@ export const McpRoutes = lazy(() =>
       validator("param", z.object({ name: z.string() })),
       async (c) => {
         const { name } = c.req.valid("param")
-        await MCP.disconnect(name)
+        await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
         return c.json(true)
       },
     ),

+ 1 - 1
packages/opencode/src/server/instance/middleware.ts

@@ -95,7 +95,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
       })
     }
 
-    const adaptor = await getAdaptor(workspace.type)
+    const adaptor = await getAdaptor(workspace.projectID, workspace.type)
     const target = await adaptor.target(workspace)
 
     if (target.type === "local") {

+ 2 - 1
packages/opencode/src/server/instance/provider.ts

@@ -44,7 +44,8 @@ export const ProviderRoutes = lazy(() =>
         const result = await AppRuntime.runPromise(
           Effect.gen(function* () {
             const svc = yield* Provider.Service
-            const config = yield* Effect.promise(() => Config.get())
+            const cfg = yield* Config.Service
+            const config = yield* cfg.get()
             const all = yield* Effect.promise(() => ModelsDev.get())
             const disabled = new Set(config.disabled_providers ?? [])
             const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined

+ 28 - 0
packages/opencode/src/server/instance/workspace.ts

@@ -1,13 +1,41 @@
 import { Hono } from "hono"
 import { describeRoute, resolver, validator } from "hono-openapi"
 import z from "zod"
+import { listAdaptors } from "../../control-plane/adaptors"
 import { Workspace } from "../../control-plane/workspace"
 import { Instance } from "../../project/instance"
 import { errors } from "../error"
 import { lazy } from "../../util/lazy"
 
+const WorkspaceAdaptor = z.object({
+  type: z.string(),
+  name: z.string(),
+  description: z.string(),
+})
+
 export const WorkspaceRoutes = lazy(() =>
   new Hono()
+    .get(
+      "/adaptor",
+      describeRoute({
+        summary: "List workspace adaptors",
+        description: "List all available workspace adaptors for the current project.",
+        operationId: "experimental.workspace.adaptor.list",
+        responses: {
+          200: {
+            description: "Workspace adaptors",
+            content: {
+              "application/json": {
+                schema: resolver(z.array(WorkspaceAdaptor)),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        return c.json(await listAdaptors(Instance.project.id))
+      },
+    )
     .post(
       "/",
       describeRoute({

+ 4 - 1
packages/opencode/test/config/agent-color.test.ts

@@ -5,6 +5,9 @@ import { Instance } from "../../src/project/instance"
 import { Config } from "../../src/config/config"
 import { Agent as AgentSvc } from "../../src/agent/agent"
 import { Color } from "../../src/util/color"
+import { AppRuntime } from "../../src/effect/app-runtime"
+
+const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
 
 test("agent color parsed from project config", async () => {
   await using tmp = await tmpdir({
@@ -24,7 +27,7 @@ test("agent color parsed from project config", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const cfg = await Config.get()
+      const cfg = await load()
       expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
       expect(cfg.agent?.["plan"]?.color).toBe("primary")
     },

+ 77 - 67
packages/opencode/test/config/config.test.ts

@@ -33,15 +33,25 @@ const emptyAuth = Layer.mock(Auth.Service)({
   all: () => Effect.succeed({}),
 })
 
-const it = testEffect(
-  Config.layer.pipe(
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(emptyAuth),
-    Layer.provide(emptyAccount),
-    Layer.provideMerge(infra),
-  ),
+const layer = Config.layer.pipe(
+  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(emptyAuth),
+  Layer.provide(emptyAccount),
+  Layer.provideMerge(infra),
 )
 
+const it = testEffect(layer)
+
+const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
+const save = (config: Config.Info) =>
+  Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
+const clear = (wait = false) =>
+  Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
+const listDirs = () =>
+  Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer)))
+const ready = () =>
+  Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer)))
+
 const installDeps = (dir: string, input?: Config.InstallInput) =>
   Config.Service.use((svc) => svc.installDependencies(dir, input))
 
@@ -49,12 +59,12 @@ const installDeps = (dir: string, input?: Config.InstallInput) =>
 const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
 
 beforeEach(async () => {
-  await Config.invalidate(true)
+  await clear(true)
 })
 
 afterEach(async () => {
   await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
-  await Config.invalidate(true)
+  await clear(true)
 })
 
 async function writeManagedSettings(settings: object, filename = "opencode.json") {
@@ -72,7 +82,7 @@ async function check(map: (dir: string) => string) {
   await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
   const prev = Global.Path.config
   ;(Global.Path as { config: string }).config = globalTmp.path
-  await Config.invalidate()
+  await clear()
   try {
     await writeConfig(globalTmp.path, {
       $schema: "https://opencode.ai/config.json",
@@ -81,7 +91,7 @@ async function check(map: (dir: string) => string) {
     await Instance.provide({
       directory: map(tmp.path),
       fn: async () => {
-        const cfg = await Config.get()
+        const cfg = await load()
         expect(cfg.snapshot).toBe(true)
         expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
         expect(Instance.project.id).not.toBe(ProjectID.global)
@@ -90,7 +100,7 @@ async function check(map: (dir: string) => string) {
   } finally {
     await Instance.disposeAll()
     ;(Global.Path as { config: string }).config = prev
-    await Config.invalidate()
+    await clear()
   }
 }
 
@@ -99,7 +109,7 @@ test("loads config with defaults when no files exist", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.username).toBeDefined()
     },
   })
@@ -118,7 +128,7 @@ test("loads JSON config file", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.model).toBe("test/model")
       expect(config.username).toBe("testuser")
     },
@@ -156,7 +166,7 @@ test("ignores legacy tui keys in opencode config", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.model).toBe("test/model")
       expect((config as Record<string, unknown>).theme).toBeUndefined()
       expect((config as Record<string, unknown>).tui).toBeUndefined()
@@ -181,7 +191,7 @@ test("loads JSONC config file", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.model).toBe("test/model")
       expect(config.username).toBe("testuser")
     },
@@ -209,7 +219,7 @@ test("jsonc overrides json in the same directory", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.model).toBe("base")
       expect(config.username).toBe("base")
     },
@@ -232,7 +242,7 @@ test("handles environment variable substitution", async () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const config = await Config.get()
+        const config = await load()
         expect(config.username).toBe("test-user")
       },
     })
@@ -264,7 +274,7 @@ test("preserves env variables when adding $schema to config", async () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const config = await Config.get()
+        const config = await load()
         expect(config.username).toBe("secret_value")
 
         // Read the file to verify the env variable was preserved
@@ -358,7 +368,7 @@ test("handles file inclusion substitution", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.username).toBe("test-user")
     },
   })
@@ -377,7 +387,7 @@ test("handles file inclusion with replacement tokens", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.username).toBe("const out = await Bun.$`echo hi`")
     },
   })
@@ -396,7 +406,7 @@ test("validates config schema and throws on invalid fields", async () => {
     directory: tmp.path,
     fn: async () => {
       // Strict schema should throw an error for invalid fields
-      await expect(Config.get()).rejects.toThrow()
+      await expect(load()).rejects.toThrow()
     },
   })
 })
@@ -410,7 +420,7 @@ test("throws error for invalid JSON", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      await expect(Config.get()).rejects.toThrow()
+      await expect(load()).rejects.toThrow()
     },
   })
 })
@@ -433,7 +443,7 @@ test("handles agent configuration", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.agent?.["test_agent"]).toEqual(
         expect.objectContaining({
           model: "test/model",
@@ -464,7 +474,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       const agent = config.agent?.["test_agent"]
 
       expect(agent?.variant).toBe("xhigh")
@@ -494,7 +504,7 @@ test("handles command configuration", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.command?.["test_command"]).toEqual({
         template: "test template",
         description: "test command",
@@ -519,7 +529,7 @@ test("migrates autoshare to share field", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.share).toBe("auto")
       expect(config.autoshare).toBe(true)
     },
@@ -546,7 +556,7 @@ test("migrates mode field to agent field", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.agent?.["test_mode"]).toEqual({
         model: "test/model",
         temperature: 0.5,
@@ -578,7 +588,7 @@ Test agent prompt`,
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.agent?.["test"]).toEqual(
         expect.objectContaining({
           name: "test",
@@ -622,7 +632,7 @@ Nested agent prompt`,
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
 
       expect(config.agent?.["helper"]).toMatchObject({
         name: "helper",
@@ -671,7 +681,7 @@ Nested command template`,
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
 
       expect(config.command?.["hello"]).toEqual({
         description: "Test command",
@@ -716,7 +726,7 @@ Nested command template`,
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
 
       expect(config.command?.["hello"]).toEqual({
         description: "Test command",
@@ -737,7 +747,7 @@ test("updates config and writes to file", async () => {
     directory: tmp.path,
     fn: async () => {
       const newConfig = { model: "updated/model" }
-      await Config.update(newConfig as any)
+      await save(newConfig as any)
 
       const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json"))
       expect(writtenConfig.model).toBe("updated/model")
@@ -750,7 +760,7 @@ test("gets config directories", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const dirs = await Config.directories()
+      const dirs = await listDirs()
       expect(dirs.length).toBeGreaterThanOrEqual(1)
     },
   })
@@ -780,7 +790,7 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        await Config.get()
+        await load()
       },
     })
   } finally {
@@ -814,8 +824,8 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        await Config.get()
-        await Config.waitForDependencies()
+        await load()
+        await ready()
       },
     })
 
@@ -996,7 +1006,7 @@ test("resolves scoped npm plugins in config", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       const pluginEntries = config.plugin ?? []
       expect(pluginEntries).toContain("@scope/plugin")
     },
@@ -1034,7 +1044,7 @@ test("merges plugin arrays from global and local configs", async () => {
   await Instance.provide({
     directory: path.join(tmp.path, "project"),
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       const plugins = config.plugin ?? []
 
       // Should contain both global and local plugins
@@ -1070,7 +1080,7 @@ Helper subagent prompt`,
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.agent?.["helper"]).toMatchObject({
         name: "helper",
         model: "test/model",
@@ -1109,7 +1119,7 @@ test("merges instructions arrays from global and local configs", async () => {
   await Instance.provide({
     directory: path.join(tmp.path, "project"),
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       const instructions = config.instructions ?? []
 
       expect(instructions).toContain("global-instructions.md")
@@ -1148,7 +1158,7 @@ test("deduplicates duplicate instructions from global and local configs", async
   await Instance.provide({
     directory: path.join(tmp.path, "project"),
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       const instructions = config.instructions ?? []
 
       expect(instructions).toContain("global-only.md")
@@ -1193,7 +1203,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
   await Instance.provide({
     directory: path.join(tmp.path, "project"),
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       const plugins = config.plugin ?? []
 
       // Should contain all unique plugins
@@ -1242,7 +1252,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
   await Instance.provide({
     directory: path.join(tmp.path, "project"),
     fn: async () => {
-      const cfg = await Config.get()
+      const cfg = await load()
       const plugins = cfg.plugin ?? []
       const origins = cfg.plugin_origins ?? []
       const names = plugins.map((item) => Config.pluginSpecifier(item))
@@ -1283,7 +1293,7 @@ test("migrates legacy tools config to permissions - allow", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.agent?.["test"]?.permission).toEqual({
         bash: "allow",
         read: "allow",
@@ -1314,7 +1324,7 @@ test("migrates legacy tools config to permissions - deny", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.agent?.["test"]?.permission).toEqual({
         bash: "deny",
         webfetch: "deny",
@@ -1344,7 +1354,7 @@ test("migrates legacy write tool to edit permission", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.agent?.["test"]?.permission).toEqual({
         edit: "allow",
       })
@@ -1376,7 +1386,7 @@ test("managed settings override user settings", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.model).toBe("managed/model")
       expect(config.share).toBe("disabled")
       expect(config.username).toBe("testuser")
@@ -1404,7 +1414,7 @@ test("managed settings override project settings", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.autoupdate).toBe(false)
       expect(config.disabled_providers).toEqual(["openai"])
     },
@@ -1424,7 +1434,7 @@ test("missing managed settings file is not an error", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.model).toBe("user/model")
     },
   })
@@ -1451,7 +1461,7 @@ test("migrates legacy edit tool to edit permission", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.agent?.["test"]?.permission).toEqual({
         edit: "deny",
       })
@@ -1480,7 +1490,7 @@ test("migrates legacy patch tool to edit permission", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.agent?.["test"]?.permission).toEqual({
         edit: "allow",
       })
@@ -1509,7 +1519,7 @@ test("migrates legacy multiedit tool to edit permission", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.agent?.["test"]?.permission).toEqual({
         edit: "deny",
       })
@@ -1541,7 +1551,7 @@ test("migrates mixed legacy tools config", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.agent?.["test"]?.permission).toEqual({
         bash: "allow",
         edit: "allow",
@@ -1576,7 +1586,7 @@ test("merges legacy tools with existing permission config", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.agent?.["test"]?.permission).toEqual({
         glob: "allow",
         bash: "allow",
@@ -1611,7 +1621,7 @@ test("permission config preserves key order", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(Object.keys(config.permission!)).toEqual([
         "*",
         "edit",
@@ -1671,7 +1681,7 @@ test("project config can override MCP server enabled status", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       // jira should be enabled (overridden by project config)
       expect(config.mcp?.jira).toEqual({
         type: "remote",
@@ -1727,7 +1737,7 @@ test("MCP config deep merges preserving base config properties", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.mcp?.myserver).toEqual({
         type: "remote",
         url: "https://myserver.example.com/mcp",
@@ -1778,7 +1788,7 @@ test("local .opencode config can override MCP from project config", async () =>
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const config = await Config.get()
+      const config = await load()
       expect(config.mcp?.docs?.enabled).toBe(true)
     },
   })
@@ -2029,7 +2039,7 @@ describe("deduplicatePluginOrigins", () => {
     await Instance.provide({
       directory: path.join(tmp.path, "project"),
       fn: async () => {
-        const config = await Config.get()
+        const config = await load()
         const plugins = config.plugin ?? []
 
         expect(plugins.some((p) => Config.pluginSpecifier(p) === "[email protected]")).toBe(true)
@@ -2061,7 +2071,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const config = await Config.get()
+          const config = await load()
           // Project config should NOT be loaded - model should be default, not "project/model"
           expect(config.model).not.toBe("project/model")
           expect(config.username).not.toBe("project-user")
@@ -2092,7 +2102,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const directories = await Config.directories()
+          const directories = await listDirs()
           // Project .opencode should NOT be in directories list
           const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path))
           expect(hasProjectOpencode).toBe(false)
@@ -2117,7 +2127,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
         directory: tmp.path,
         fn: async () => {
           // Should still get default config (from global or defaults)
-          const config = await Config.get()
+          const config = await load()
           expect(config).toBeDefined()
           expect(config.username).toBeDefined()
         },
@@ -2160,7 +2170,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
         fn: async () => {
           // The relative instruction should be skipped without error
           // We're mainly verifying this doesn't throw and the config loads
-          const config = await Config.get()
+          const config = await load()
           expect(config).toBeDefined()
           // The instruction should have been skipped (warning logged)
           // We can't easily test the warning was logged, but we verify
@@ -2218,7 +2228,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
       await Instance.provide({
         directory: projectTmp.path,
         fn: async () => {
-          const config = await Config.get()
+          const config = await load()
           // Should load from OPENCODE_CONFIG_DIR, not project
           expect(config.model).toBe("configdir/model")
         },
@@ -2253,7 +2263,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const config = await Config.get()
+          const config = await load()
           expect(config.username).toBe("test_api_key_12345")
         },
       })
@@ -2287,7 +2297,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const config = await Config.get()
+          const config = await load()
           expect(config.username).toBe("secret_key_from_file")
         },
       })

+ 6 - 3
packages/opencode/test/config/tui.test.ts

@@ -7,12 +7,15 @@ import { Config } from "../../src/config/config"
 import { TuiConfig } from "../../src/config/tui"
 import { Global } from "../../src/global"
 import { Filesystem } from "../../src/util/filesystem"
+import { AppRuntime } from "../../src/effect/app-runtime"
 
 const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
 const wintest = process.platform === "win32" ? test : test.skip
+const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
+const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
 
 beforeEach(async () => {
-  await Config.invalidate(true)
+  await clear(true)
 })
 
 afterEach(async () => {
@@ -23,7 +26,7 @@ afterEach(async () => {
   await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
   await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
   await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
-  await Config.invalidate(true)
+  await clear(true)
 })
 
 test("keeps server and tui plugin merge semantics aligned", async () => {
@@ -79,7 +82,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const server = await Config.get()
+      const server = await load()
       const tui = await TuiConfig.get()
       const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
       const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))

+ 71 - 0
packages/opencode/test/control-plane/adaptors.test.ts

@@ -0,0 +1,71 @@
+import { describe, expect, test } from "bun:test"
+import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors"
+import { ProjectID } from "../../src/project/schema"
+import type { WorkspaceInfo } from "../../src/control-plane/types"
+
+function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInfo {
+  return {
+    id: "workspace-test" as WorkspaceInfo["id"],
+    type,
+    name: "workspace-test",
+    branch: null,
+    directory: null,
+    extra: null,
+    projectID,
+  }
+}
+
+function adaptor(dir: string) {
+  return {
+    name: dir,
+    description: dir,
+    configure(input: WorkspaceInfo) {
+      return input
+    },
+    async create() {},
+    async remove() {},
+    target() {
+      return {
+        type: "local" as const,
+        directory: dir,
+      }
+    },
+  }
+}
+
+describe("control-plane/adaptors", () => {
+  test("isolates custom adaptors by project", async () => {
+    const type = `demo-${Math.random().toString(36).slice(2)}`
+    const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
+    const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
+    registerAdaptor(one, type, adaptor("/one"))
+    registerAdaptor(two, type, adaptor("/two"))
+
+    expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({
+      type: "local",
+      directory: "/one",
+    })
+    expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({
+      type: "local",
+      directory: "/two",
+    })
+  })
+
+  test("latest install wins within a project", async () => {
+    const type = `demo-${Math.random().toString(36).slice(2)}`
+    const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
+    registerAdaptor(id, type, adaptor("/one"))
+
+    expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
+      type: "local",
+      directory: "/one",
+    })
+
+    registerAdaptor(id, type, adaptor("/two"))
+
+    expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
+      type: "local",
+      directory: "/two",
+    })
+  })
+})

+ 9 - 3
packages/opencode/test/file/fsmonitor.test.ts

@@ -1,10 +1,16 @@
 import { $ } from "bun"
 import { describe, expect, test } from "bun:test"
+import { Effect } from "effect"
 import fs from "fs/promises"
 import path from "path"
 import { File } from "../../src/file"
 import { Instance } from "../../src/project/instance"
-import { tmpdir } from "../fixture/fixture"
+import { provideInstance, tmpdir } from "../fixture/fixture"
+
+const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
+  Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
+const status = () => run(File.Service.use((svc) => svc.status()))
+const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
 
 const wintest = process.platform === "win32" ? test : test.skip
 
@@ -27,7 +33,7 @@ describe("file fsmonitor", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        await File.status()
+        await status()
       },
     })
 
@@ -52,7 +58,7 @@ describe("file fsmonitor", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        await File.read("tracked.txt")
+        await read("tracked.txt")
       },
     })
 

+ 88 - 78
packages/opencode/test/file/index.test.ts

@@ -1,18 +1,28 @@
 import { afterEach, describe, test, expect } from "bun:test"
 import { $ } from "bun"
+import { Effect } from "effect"
 import path from "path"
 import fs from "fs/promises"
 import { File } from "../../src/file"
 import { Instance } from "../../src/project/instance"
 import { Filesystem } from "../../src/util/filesystem"
-import { tmpdir } from "../fixture/fixture"
+import { provideInstance, tmpdir } from "../fixture/fixture"
 
 afterEach(async () => {
   await Instance.disposeAll()
 })
 
+const init = () => run(File.Service.use((svc) => svc.init()))
+const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
+  Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
+const status = () => run(File.Service.use((svc) => svc.status()))
+const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
+const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
+const search = (input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) =>
+  run(File.Service.use((svc) => svc.search(input)))
+
 describe("file/index Filesystem patterns", () => {
-  describe("File.read() - text content", () => {
+  describe("read() - text content", () => {
     test("reads text file via Filesystem.readText()", async () => {
       await using tmp = await tmpdir()
       const filepath = path.join(tmp.path, "test.txt")
@@ -21,7 +31,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("test.txt")
+          const result = await read("test.txt")
           expect(result.type).toBe("text")
           expect(result.content).toBe("Hello World")
         },
@@ -35,7 +45,7 @@ describe("file/index Filesystem patterns", () => {
         directory: tmp.path,
         fn: async () => {
           // Non-existent file should return empty content
-          const result = await File.read("nonexistent.txt")
+          const result = await read("nonexistent.txt")
           expect(result.type).toBe("text")
           expect(result.content).toBe("")
         },
@@ -50,7 +60,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("test.txt")
+          const result = await read("test.txt")
           expect(result.content).toBe("content with spaces")
         },
       })
@@ -64,7 +74,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("empty.txt")
+          const result = await read("empty.txt")
           expect(result.type).toBe("text")
           expect(result.content).toBe("")
         },
@@ -79,14 +89,14 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("multiline.txt")
+          const result = await read("multiline.txt")
           expect(result.content).toBe("line1\nline2\nline3")
         },
       })
     })
   })
 
-  describe("File.read() - binary content", () => {
+  describe("read() - binary content", () => {
     test("reads binary file via Filesystem.readArrayBuffer()", async () => {
       await using tmp = await tmpdir()
       const filepath = path.join(tmp.path, "image.png")
@@ -96,7 +106,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("image.png")
+          const result = await read("image.png")
           expect(result.type).toBe("text") // Images return as text with base64 encoding
           expect(result.encoding).toBe("base64")
           expect(result.mimeType).toBe("image/png")
@@ -113,7 +123,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("binary.so")
+          const result = await read("binary.so")
           expect(result.type).toBe("binary")
           expect(result.content).toBe("")
         },
@@ -121,7 +131,7 @@ describe("file/index Filesystem patterns", () => {
     })
   })
 
-  describe("File.read() - Filesystem.mimeType()", () => {
+  describe("read() - Filesystem.mimeType()", () => {
     test("detects MIME type via Filesystem.mimeType()", async () => {
       await using tmp = await tmpdir()
       const filepath = path.join(tmp.path, "test.json")
@@ -132,7 +142,7 @@ describe("file/index Filesystem patterns", () => {
         fn: async () => {
           expect(Filesystem.mimeType(filepath)).toContain("application/json")
 
-          const result = await File.read("test.json")
+          const result = await read("test.json")
           expect(result.type).toBe("text")
         },
       })
@@ -161,7 +171,7 @@ describe("file/index Filesystem patterns", () => {
     })
   })
 
-  describe("File.list() - Filesystem.exists() and readText()", () => {
+  describe("list() - Filesystem.exists() and readText()", () => {
     test("reads .gitignore via Filesystem.exists() and readText()", async () => {
       await using tmp = await tmpdir({ git: true })
 
@@ -171,7 +181,7 @@ describe("file/index Filesystem patterns", () => {
           const gitignorePath = path.join(tmp.path, ".gitignore")
           await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
 
-          // This is used internally in File.list()
+          // This is used internally in list()
           expect(await Filesystem.exists(gitignorePath)).toBe(true)
 
           const content = await Filesystem.readText(gitignorePath)
@@ -204,8 +214,8 @@ describe("file/index Filesystem patterns", () => {
           const gitignorePath = path.join(tmp.path, ".gitignore")
           expect(await Filesystem.exists(gitignorePath)).toBe(false)
 
-          // File.list() should still work
-          const nodes = await File.list()
+          // list() should still work
+          const nodes = await list()
           expect(Array.isArray(nodes)).toBe(true)
         },
       })
@@ -244,8 +254,8 @@ describe("file/index Filesystem patterns", () => {
           // Filesystem.readText() on non-existent file throws
           await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow()
 
-          // But File.read() handles this gracefully
-          const result = await File.read("does-not-exist.txt")
+          // But read() handles this gracefully
+          const result = await read("does-not-exist.txt")
           expect(result.content).toBe("")
         },
       })
@@ -272,8 +282,8 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          // File.read() handles missing images gracefully
-          const result = await File.read("broken.png")
+          // read() handles missing images gracefully
+          const result = await read("broken.png")
           expect(result.type).toBe("text")
           expect(result.content).toBe("")
         },
@@ -290,7 +300,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("test.ts")
+          const result = await read("test.ts")
           expect(result.type).toBe("text")
           expect(result.content).toBe("export const value = 1")
         },
@@ -305,7 +315,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("test.mts")
+          const result = await read("test.mts")
           expect(result.type).toBe("text")
           expect(result.content).toBe("export const value = 1")
         },
@@ -320,7 +330,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("test.sh")
+          const result = await read("test.sh")
           expect(result.type).toBe("text")
           expect(result.content).toBe("#!/usr/bin/env bash\necho hello")
         },
@@ -335,7 +345,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("Dockerfile")
+          const result = await read("Dockerfile")
           expect(result.type).toBe("text")
           expect(result.content).toBe("FROM alpine:3.20")
         },
@@ -350,7 +360,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("test.txt")
+          const result = await read("test.txt")
           expect(result.encoding).toBeUndefined()
           expect(result.type).toBe("text")
         },
@@ -365,7 +375,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("test.jpg")
+          const result = await read("test.jpg")
           expect(result.encoding).toBe("base64")
           expect(result.mimeType).toBe("image/jpeg")
         },
@@ -380,7 +390,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
+          await expect(read("../outside.txt")).rejects.toThrow("Access denied")
         },
       })
     })
@@ -391,13 +401,13 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
+          await expect(read("../outside.txt")).rejects.toThrow("Access denied")
         },
       })
     })
   })
 
-  describe("File.status()", () => {
+  describe("status()", () => {
     test("detects modified file", async () => {
       await using tmp = await tmpdir({ git: true })
       const filepath = path.join(tmp.path, "file.txt")
@@ -409,7 +419,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.status()
+          const result = await status()
           const entry = result.find((f) => f.path === "file.txt")
           expect(entry).toBeDefined()
           expect(entry!.status).toBe("modified")
@@ -426,7 +436,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.status()
+          const result = await status()
           const entry = result.find((f) => f.path === "new.txt")
           expect(entry).toBeDefined()
           expect(entry!.status).toBe("added")
@@ -447,7 +457,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.status()
+          const result = await status()
           // Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted")
           const entries = result.filter((f) => f.path === "gone.txt")
           expect(entries.some((e) => e.status === "deleted")).toBe(true)
@@ -470,7 +480,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.status()
+          const result = await status()
           expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true)
           expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true)
           expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true)
@@ -484,7 +494,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.status()
+          const result = await status()
           expect(result).toEqual([])
         },
       })
@@ -496,7 +506,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.status()
+          const result = await status()
           expect(result).toEqual([])
         },
       })
@@ -519,7 +529,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.status()
+          const result = await status()
           const entry = result.find((f) => f.path === "data.bin")
           expect(entry).toBeDefined()
           expect(entry!.status).toBe("modified")
@@ -530,7 +540,7 @@ describe("file/index Filesystem patterns", () => {
     })
   })
 
-  describe("File.list()", () => {
+  describe("list()", () => {
     test("returns files and directories with correct shape", async () => {
       await using tmp = await tmpdir({ git: true })
       await fs.mkdir(path.join(tmp.path, "subdir"))
@@ -540,7 +550,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const nodes = await File.list()
+          const nodes = await list()
           expect(nodes.length).toBeGreaterThanOrEqual(2)
           for (const node of nodes) {
             expect(node).toHaveProperty("name")
@@ -564,7 +574,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const nodes = await File.list()
+          const nodes = await list()
           const dirs = nodes.filter((n) => n.type === "directory")
           const files = nodes.filter((n) => n.type === "file")
           // Dirs come first
@@ -589,7 +599,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const nodes = await File.list()
+          const nodes = await list()
           const names = nodes.map((n) => n.name)
           expect(names).not.toContain(".git")
           expect(names).not.toContain(".DS_Store")
@@ -608,7 +618,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const nodes = await File.list()
+          const nodes = await list()
           const logNode = nodes.find((n) => n.name === "app.log")
           const tsNode = nodes.find((n) => n.name === "main.ts")
           const buildNode = nodes.find((n) => n.name === "build")
@@ -628,7 +638,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const nodes = await File.list("sub")
+          const nodes = await list("sub")
           expect(nodes.length).toBe(2)
           expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"])
           // Paths should be relative to project root (normalize for Windows)
@@ -643,7 +653,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await expect(File.list("../outside")).rejects.toThrow("Access denied")
+          await expect(list("../outside")).rejects.toThrow("Access denied")
         },
       })
     })
@@ -655,7 +665,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const nodes = await File.list()
+          const nodes = await list()
           expect(nodes.length).toBeGreaterThanOrEqual(1)
           // Without git, ignored should be false for all
           for (const node of nodes) {
@@ -666,7 +676,7 @@ describe("file/index Filesystem patterns", () => {
     })
   })
 
-  describe("File.search()", () => {
+  describe("search()", () => {
     async function setupSearchableRepo() {
       const tmp = await tmpdir({ git: true })
       await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8")
@@ -685,9 +695,9 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await File.init()
+          await init()
 
-          const result = await File.search({ query: "", type: "file" })
+          const result = await search({ query: "", type: "file" })
           expect(result.length).toBeGreaterThan(0)
         },
       })
@@ -699,7 +709,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.search({ query: "main", type: "file" })
+          const result = await search({ query: "main", type: "file" })
           expect(result.some((f) => f.includes("main"))).toBe(true)
         },
       })
@@ -711,9 +721,9 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await File.init()
+          await init()
 
-          const result = await File.search({ query: "", type: "directory" })
+          const result = await search({ query: "", type: "directory" })
           expect(result.length).toBeGreaterThan(0)
           // Find first hidden dir index
           const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1))
@@ -731,9 +741,9 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await File.init()
+          await init()
 
-          const result = await File.search({ query: "main", type: "file" })
+          const result = await search({ query: "main", type: "file" })
           expect(result.some((f) => f.includes("main"))).toBe(true)
         },
       })
@@ -745,9 +755,9 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await File.init()
+          await init()
 
-          const result = await File.search({ query: "", type: "file" })
+          const result = await search({ query: "", type: "file" })
           // Files don't end with /
           for (const f of result) {
             expect(f.endsWith("/")).toBe(false)
@@ -762,9 +772,9 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await File.init()
+          await init()
 
-          const result = await File.search({ query: "", type: "directory" })
+          const result = await search({ query: "", type: "directory" })
           // Directories end with /
           for (const d of result) {
             expect(d.endsWith("/")).toBe(true)
@@ -779,9 +789,9 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await File.init()
+          await init()
 
-          const result = await File.search({ query: "", type: "file", limit: 2 })
+          const result = await search({ query: "", type: "file", limit: 2 })
           expect(result.length).toBeLessThanOrEqual(2)
         },
       })
@@ -793,9 +803,9 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await File.init()
+          await init()
 
-          const result = await File.search({ query: ".hidden", type: "directory" })
+          const result = await search({ query: ".hidden", type: "directory" })
           expect(result.length).toBeGreaterThan(0)
           expect(result[0]).toContain(".hidden")
         },
@@ -808,19 +818,19 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await File.init()
-          expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
+          await init()
+          expect(await search({ query: "fresh", type: "file" })).toEqual([])
 
           await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
 
-          const result = await File.search({ query: "fresh", type: "file" })
+          const result = await search({ query: "fresh", type: "file" })
           expect(result).toContain("fresh.ts")
         },
       })
     })
   })
 
-  describe("File.read() - diff/patch", () => {
+  describe("read() - diff/patch", () => {
     test("returns diff and patch for modified tracked file", async () => {
       await using tmp = await tmpdir({ git: true })
       const filepath = path.join(tmp.path, "file.txt")
@@ -832,7 +842,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("file.txt")
+          const result = await read("file.txt")
           expect(result.type).toBe("text")
           expect(result.content).toBe("modified content")
           expect(result.diff).toBeDefined()
@@ -856,7 +866,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("staged.txt")
+          const result = await read("staged.txt")
           expect(result.diff).toBeDefined()
           expect(result.patch).toBeDefined()
         },
@@ -873,7 +883,7 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const result = await File.read("clean.txt")
+          const result = await read("clean.txt")
           expect(result.type).toBe("text")
           expect(result.content).toBe("unchanged")
           expect(result.diff).toBeUndefined()
@@ -893,10 +903,10 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: one.path,
         fn: async () => {
-          await File.init()
-          const results = await File.search({ query: "a.ts", type: "file" })
+          await init()
+          const results = await search({ query: "a.ts", type: "file" })
           expect(results).toContain("a.ts")
-          const results2 = await File.search({ query: "b.ts", type: "file" })
+          const results2 = await search({ query: "b.ts", type: "file" })
           expect(results2).not.toContain("b.ts")
         },
       })
@@ -904,10 +914,10 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: two.path,
         fn: async () => {
-          await File.init()
-          const results = await File.search({ query: "b.ts", type: "file" })
+          await init()
+          const results = await search({ query: "b.ts", type: "file" })
           expect(results).toContain("b.ts")
-          const results2 = await File.search({ query: "a.ts", type: "file" })
+          const results2 = await search({ query: "a.ts", type: "file" })
           expect(results2).not.toContain("a.ts")
         },
       })
@@ -920,8 +930,8 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await File.init()
-          const results = await File.search({ query: "before", type: "file" })
+          await init()
+          const results = await search({ query: "before", type: "file" })
           expect(results).toContain("before.ts")
         },
       })
@@ -934,10 +944,10 @@ describe("file/index Filesystem patterns", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await File.init()
-          const results = await File.search({ query: "after", type: "file" })
+          await init()
+          const results = await search({ query: "after", type: "file" })
           expect(results).toContain("after.ts")
-          const stale = await File.search({ query: "before", type: "file" })
+          const stale = await search({ query: "before", type: "file" })
           expect(stale).not.toContain("before.ts")
         },
       })

+ 14 - 8
packages/opencode/test/file/path-traversal.test.ts

@@ -1,10 +1,16 @@
 import { test, expect, describe } from "bun:test"
+import { Effect } from "effect"
 import path from "path"
 import fs from "fs/promises"
 import { Filesystem } from "../../src/util/filesystem"
 import { File } from "../../src/file"
 import { Instance } from "../../src/project/instance"
-import { tmpdir } from "../fixture/fixture"
+import { provideInstance, tmpdir } from "../fixture/fixture"
+
+const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
+  Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
+const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
+const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
 
 describe("Filesystem.contains", () => {
   test("allows paths within project", () => {
@@ -32,10 +38,10 @@ describe("Filesystem.contains", () => {
 })
 
 /*
- * Integration tests for File.read() and File.list() path traversal protection.
+ * Integration tests for read() and list() path traversal protection.
  *
  * These tests verify the HTTP API code path is protected. The HTTP endpoints
- * in server.ts (GET /file/content, GET /file) call File.read()/File.list()
+ * in server.ts (GET /file/content, GET /file) call read()/list()
  * directly - they do NOT go through ReadTool or the agent permission layer.
  *
  * This is a SEPARATE code path from ReadTool, which has its own checks.
@@ -51,7 +57,7 @@ describe("File.read path traversal protection", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
+        await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
       },
     })
   })
@@ -62,7 +68,7 @@ describe("File.read path traversal protection", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
+        await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
           "Access denied: path escapes project directory",
         )
       },
@@ -79,7 +85,7 @@ describe("File.read path traversal protection", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const result = await File.read("valid.txt")
+        const result = await read("valid.txt")
         expect(result.content).toBe("valid content")
       },
     })
@@ -93,7 +99,7 @@ describe("File.list path traversal protection", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        await expect(File.list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
+        await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
       },
     })
   })
@@ -108,7 +114,7 @@ describe("File.list path traversal protection", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const result = await File.list("subdir")
+        const result = await list("subdir")
         expect(Array.isArray(result)).toBe(true)
       },
     })

+ 45 - 20
packages/opencode/test/mcp/headers.test.ts

@@ -1,4 +1,6 @@
 import { test, expect, mock, beforeEach } from "bun:test"
+import { Effect } from "effect"
+import type { MCP as MCPNS } from "../../src/mcp/index"
 
 // Track what options were passed to each transport constructor
 const transportCalls: Array<{
@@ -44,8 +46,10 @@ beforeEach(() => {
 
 // Import MCP after mocking
 const { MCP } = await import("../../src/mcp/index")
+const { AppRuntime } = await import("../../src/effect/app-runtime")
 const { Instance } = await import("../../src/project/instance")
 const { tmpdir } = await import("../fixture/fixture")
+const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
 
 test("headers are passed to transports when oauth is enabled (default)", async () => {
   await using tmp = await tmpdir({
@@ -73,14 +77,21 @@ test("headers are passed to transports when oauth is enabled (default)", async (
     directory: tmp.path,
     fn: async () => {
       // Trigger MCP initialization - it will fail to connect but we can check the transport options
-      await MCP.add("test-server", {
-        type: "remote",
-        url: "https://example.com/mcp",
-        headers: {
-          Authorization: "Bearer test-token",
-          "X-Custom-Header": "custom-value",
-        },
-      }).catch(() => {})
+      await AppRuntime.runPromise(
+        Effect.gen(function* () {
+          const mcp = yield* service
+          yield* mcp
+            .add("test-server", {
+              type: "remote",
+              url: "https://example.com/mcp",
+              headers: {
+                Authorization: "Bearer test-token",
+                "X-Custom-Header": "custom-value",
+              },
+            })
+            .pipe(Effect.catch(() => Effect.void))
+        }),
+      )
 
       // Both transports should have been created with headers
       expect(transportCalls.length).toBeGreaterThanOrEqual(1)
@@ -106,14 +117,21 @@ test("headers are passed to transports when oauth is explicitly disabled", async
     fn: async () => {
       transportCalls.length = 0
 
-      await MCP.add("test-server-no-oauth", {
-        type: "remote",
-        url: "https://example.com/mcp",
-        oauth: false,
-        headers: {
-          Authorization: "Bearer test-token",
-        },
-      }).catch(() => {})
+      await AppRuntime.runPromise(
+        Effect.gen(function* () {
+          const mcp = yield* service
+          yield* mcp
+            .add("test-server-no-oauth", {
+              type: "remote",
+              url: "https://example.com/mcp",
+              oauth: false,
+              headers: {
+                Authorization: "Bearer test-token",
+              },
+            })
+            .pipe(Effect.catch(() => Effect.void))
+        }),
+      )
 
       expect(transportCalls.length).toBeGreaterThanOrEqual(1)
 
@@ -137,10 +155,17 @@ test("no requestInit when headers are not provided", async () => {
     fn: async () => {
       transportCalls.length = 0
 
-      await MCP.add("test-server-no-headers", {
-        type: "remote",
-        url: "https://example.com/mcp",
-      }).catch(() => {})
+      await AppRuntime.runPromise(
+        Effect.gen(function* () {
+          const mcp = yield* service
+          yield* mcp
+            .add("test-server-no-headers", {
+              type: "remote",
+              url: "https://example.com/mcp",
+            })
+            .pipe(Effect.catch(() => Effect.void))
+        }),
+      )
 
       expect(transportCalls.length).toBeGreaterThanOrEqual(1)
 

+ 330 - 296
packages/opencode/test/mcp/lifecycle.test.ts

@@ -1,4 +1,6 @@
 import { test, expect, mock, beforeEach } from "bun:test"
+import { Effect } from "effect"
+import type { MCP as MCPNS } from "../../src/mcp/index"
 
 // --- Mock infrastructure ---
 
@@ -170,7 +172,10 @@ const { tmpdir } = await import("../fixture/fixture")
 
 // --- Helper ---
 
-function withInstance(config: Record<string, any>, fn: () => Promise<void>) {
+function withInstance(
+  config: Record<string, unknown>,
+  fn: (mcp: MCPNS.Interface) => Effect.Effect<void, unknown, never>,
+) {
   return async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {
@@ -187,7 +192,7 @@ function withInstance(config: Record<string, any>, fn: () => Promise<void>) {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        await fn()
+        await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer)))
         // dispose instance to clean up state between tests
         await Instance.dispose()
       },
@@ -201,28 +206,30 @@ function withInstance(config: Record<string, any>, fn: () => Promise<void>) {
 
 test(
   "tools() reuses cached tool definitions after connect",
-  withInstance({}, async () => {
-    lastCreatedClientName = "my-server"
-    const serverState = getOrCreateClientState("my-server")
-    serverState.tools = [
-      { name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } },
-    ]
-
-    // First: add the server successfully
-    const addResult = await MCP.add("my-server", {
-      type: "local",
-      command: ["echo", "test"],
-    })
-    expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected")
+  withInstance({}, (mcp) =>
+    Effect.gen(function* () {
+      lastCreatedClientName = "my-server"
+      const serverState = getOrCreateClientState("my-server")
+      serverState.tools = [
+        { name: "do_thing", description: "does a thing", inputSchema: { type: "object", properties: {} } },
+      ]
+
+      // First: add the server successfully
+      const addResult = yield* mcp.add("my-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
+      expect((addResult.status as any)["my-server"]?.status ?? (addResult.status as any).status).toBe("connected")
 
-    expect(serverState.listToolsCalls).toBe(1)
+      expect(serverState.listToolsCalls).toBe(1)
 
-    const toolsA = await MCP.tools()
-    const toolsB = await MCP.tools()
-    expect(Object.keys(toolsA).length).toBeGreaterThan(0)
-    expect(Object.keys(toolsB).length).toBeGreaterThan(0)
-    expect(serverState.listToolsCalls).toBe(1)
-  }),
+      const toolsA = yield* mcp.tools()
+      const toolsB = yield* mcp.tools()
+      expect(Object.keys(toolsA).length).toBeGreaterThan(0)
+      expect(Object.keys(toolsB).length).toBeGreaterThan(0)
+      expect(serverState.listToolsCalls).toBe(1)
+    }),
+  ),
 )
 
 // ========================================================================
@@ -231,30 +238,32 @@ test(
 
 test(
   "tool change notifications refresh cached tool definitions",
-  withInstance({}, async () => {
-    lastCreatedClientName = "status-server"
-    const serverState = getOrCreateClientState("status-server")
+  withInstance({}, (mcp) =>
+    Effect.gen(function* () {
+      lastCreatedClientName = "status-server"
+      const serverState = getOrCreateClientState("status-server")
 
-    await MCP.add("status-server", {
-      type: "local",
-      command: ["echo", "test"],
-    })
+      yield* mcp.add("status-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
 
-    const before = await MCP.tools()
-    expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true)
-    expect(serverState.listToolsCalls).toBe(1)
+      const before = yield* mcp.tools()
+      expect(Object.keys(before).some((key) => key.includes("test_tool"))).toBe(true)
+      expect(serverState.listToolsCalls).toBe(1)
 
-    serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }]
+      serverState.tools = [{ name: "next_tool", description: "next", inputSchema: { type: "object", properties: {} } }]
 
-    const handler = Array.from(serverState.notificationHandlers.values())[0]
-    expect(handler).toBeDefined()
-    await handler?.()
+      const handler = Array.from(serverState.notificationHandlers.values())[0]
+      expect(handler).toBeDefined()
+      yield* Effect.promise(() => handler?.())
 
-    const after = await MCP.tools()
-    expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true)
-    expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false)
-    expect(serverState.listToolsCalls).toBe(2)
-  }),
+      const after = yield* mcp.tools()
+      expect(Object.keys(after).some((key) => key.includes("next_tool"))).toBe(true)
+      expect(Object.keys(after).some((key) => key.includes("test_tool"))).toBe(false)
+      expect(serverState.listToolsCalls).toBe(2)
+    }),
+  ),
 )
 
 // ========================================================================
@@ -270,28 +279,29 @@ test(
         command: ["echo", "test"],
       },
     },
-    async () => {
-      lastCreatedClientName = "disc-server"
-      getOrCreateClientState("disc-server")
+    (mcp) =>
+      Effect.gen(function* () {
+        lastCreatedClientName = "disc-server"
+        getOrCreateClientState("disc-server")
 
-      await MCP.add("disc-server", {
-        type: "local",
-        command: ["echo", "test"],
-      })
+        yield* mcp.add("disc-server", {
+          type: "local",
+          command: ["echo", "test"],
+        })
 
-      const statusBefore = await MCP.status()
-      expect(statusBefore["disc-server"]?.status).toBe("connected")
+        const statusBefore = yield* mcp.status()
+        expect(statusBefore["disc-server"]?.status).toBe("connected")
 
-      await MCP.disconnect("disc-server")
+        yield* mcp.disconnect("disc-server")
 
-      const statusAfter = await MCP.status()
-      expect(statusAfter["disc-server"]?.status).toBe("disabled")
+        const statusAfter = yield* mcp.status()
+        expect(statusAfter["disc-server"]?.status).toBe("disabled")
 
-      // Tools should be empty after disconnect
-      const tools = await MCP.tools()
-      const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server"))
-      expect(serverTools.length).toBe(0)
-    },
+        // Tools should be empty after disconnect
+        const tools = yield* mcp.tools()
+        const serverTools = Object.keys(tools).filter((k) => k.startsWith("disc-server"))
+        expect(serverTools.length).toBe(0)
+      }),
   ),
 )
 
@@ -304,26 +314,29 @@ test(
         command: ["echo", "test"],
       },
     },
-    async () => {
-      lastCreatedClientName = "reconn-server"
-      const serverState = getOrCreateClientState("reconn-server")
-      serverState.tools = [{ name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } }]
-
-      await MCP.add("reconn-server", {
-        type: "local",
-        command: ["echo", "test"],
-      })
-
-      await MCP.disconnect("reconn-server")
-      expect((await MCP.status())["reconn-server"]?.status).toBe("disabled")
-
-      // Reconnect
-      await MCP.connect("reconn-server")
-      expect((await MCP.status())["reconn-server"]?.status).toBe("connected")
-
-      const tools = await MCP.tools()
-      expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true)
-    },
+    (mcp) =>
+      Effect.gen(function* () {
+        lastCreatedClientName = "reconn-server"
+        const serverState = getOrCreateClientState("reconn-server")
+        serverState.tools = [
+          { name: "my_tool", description: "a tool", inputSchema: { type: "object", properties: {} } },
+        ]
+
+        yield* mcp.add("reconn-server", {
+          type: "local",
+          command: ["echo", "test"],
+        })
+
+        yield* mcp.disconnect("reconn-server")
+        expect((yield* mcp.status())["reconn-server"]?.status).toBe("disabled")
+
+        // Reconnect
+        yield* mcp.connect("reconn-server")
+        expect((yield* mcp.status())["reconn-server"]?.status).toBe("connected")
+
+        const tools = yield* mcp.tools()
+        expect(Object.keys(tools).some((k) => k.includes("my_tool"))).toBe(true)
+      }),
   ),
 )
 
@@ -335,30 +348,32 @@ test(
   "add() closes the old client when replacing a server",
   // Don't put the server in config — add it dynamically so we control
   // exactly which client instance is "first" vs "second".
-  withInstance({}, async () => {
-    lastCreatedClientName = "replace-server"
-    const firstState = getOrCreateClientState("replace-server")
+  withInstance({}, (mcp) =>
+    Effect.gen(function* () {
+      lastCreatedClientName = "replace-server"
+      const firstState = getOrCreateClientState("replace-server")
 
-    await MCP.add("replace-server", {
-      type: "local",
-      command: ["echo", "test"],
-    })
+      yield* mcp.add("replace-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
 
-    expect(firstState.closed).toBe(false)
+      expect(firstState.closed).toBe(false)
 
-    // Create new state for second client
-    clientStates.delete("replace-server")
-    const secondState = getOrCreateClientState("replace-server")
+      // Create new state for second client
+      clientStates.delete("replace-server")
+      const secondState = getOrCreateClientState("replace-server")
 
-    // Re-add should close the first client
-    await MCP.add("replace-server", {
-      type: "local",
-      command: ["echo", "test"],
-    })
+      // Re-add should close the first client
+      yield* mcp.add("replace-server", {
+        type: "local",
+        command: ["echo", "test"],
+      })
 
-    expect(firstState.closed).toBe(true)
-    expect(secondState.closed).toBe(false)
-  }),
+      expect(firstState.closed).toBe(true)
+      expect(secondState.closed).toBe(false)
+    }),
+  ),
 )
 
 // ========================================================================
@@ -378,37 +393,38 @@ test(
         command: ["echo", "bad"],
       },
     },
-    async () => {
-      // Set up good server
-      const goodState = getOrCreateClientState("good-server")
-      goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }]
-
-      // Set up bad server - will fail on listTools during create()
-      const badState = getOrCreateClientState("bad-server")
-      badState.listToolsShouldFail = true
-
-      // Add good server first
-      lastCreatedClientName = "good-server"
-      await MCP.add("good-server", {
-        type: "local",
-        command: ["echo", "good"],
-      })
-
-      // Add bad server - should fail but not affect good server
-      lastCreatedClientName = "bad-server"
-      await MCP.add("bad-server", {
-        type: "local",
-        command: ["echo", "bad"],
-      })
-
-      const status = await MCP.status()
-      expect(status["good-server"]?.status).toBe("connected")
-      expect(status["bad-server"]?.status).toBe("failed")
-
-      // Good server's tools should still be available
-      const tools = await MCP.tools()
-      expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
-    },
+    (mcp) =>
+      Effect.gen(function* () {
+        // Set up good server
+        const goodState = getOrCreateClientState("good-server")
+        goodState.tools = [{ name: "good_tool", description: "works", inputSchema: { type: "object", properties: {} } }]
+
+        // Set up bad server - will fail on listTools during create()
+        const badState = getOrCreateClientState("bad-server")
+        badState.listToolsShouldFail = true
+
+        // Add good server first
+        lastCreatedClientName = "good-server"
+        yield* mcp.add("good-server", {
+          type: "local",
+          command: ["echo", "good"],
+        })
+
+        // Add bad server - should fail but not affect good server
+        lastCreatedClientName = "bad-server"
+        yield* mcp.add("bad-server", {
+          type: "local",
+          command: ["echo", "bad"],
+        })
+
+        const status = yield* mcp.status()
+        expect(status["good-server"]?.status).toBe("connected")
+        expect(status["bad-server"]?.status).toBe("failed")
+
+        // Good server's tools should still be available
+        const tools = yield* mcp.tools()
+        expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
+      }),
   ),
 )
 
@@ -426,21 +442,22 @@ test(
         enabled: false,
       },
     },
-    async () => {
-      const countBefore = clientCreateCount
-
-      await MCP.add("disabled-server", {
-        type: "local",
-        command: ["echo", "test"],
-        enabled: false,
-      } as any)
-
-      // No client should have been created
-      expect(clientCreateCount).toBe(countBefore)
-
-      const status = await MCP.status()
-      expect(status["disabled-server"]?.status).toBe("disabled")
-    },
+    (mcp) =>
+      Effect.gen(function* () {
+        const countBefore = clientCreateCount
+
+        yield* mcp.add("disabled-server", {
+          type: "local",
+          command: ["echo", "test"],
+          enabled: false,
+        } as any)
+
+        // No client should have been created
+        expect(clientCreateCount).toBe(countBefore)
+
+        const status = yield* mcp.status()
+        expect(status["disabled-server"]?.status).toBe("disabled")
+      }),
   ),
 )
 
@@ -457,22 +474,23 @@ test(
         command: ["echo", "test"],
       },
     },
-    async () => {
-      lastCreatedClientName = "prompt-server"
-      const serverState = getOrCreateClientState("prompt-server")
-      serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }]
-
-      await MCP.add("prompt-server", {
-        type: "local",
-        command: ["echo", "test"],
-      })
-
-      const prompts = await MCP.prompts()
-      expect(Object.keys(prompts).length).toBe(1)
-      const key = Object.keys(prompts)[0]
-      expect(key).toContain("prompt-server")
-      expect(key).toContain("my-prompt")
-    },
+    (mcp) =>
+      Effect.gen(function* () {
+        lastCreatedClientName = "prompt-server"
+        const serverState = getOrCreateClientState("prompt-server")
+        serverState.prompts = [{ name: "my-prompt", description: "A test prompt" }]
+
+        yield* mcp.add("prompt-server", {
+          type: "local",
+          command: ["echo", "test"],
+        })
+
+        const prompts = yield* mcp.prompts()
+        expect(Object.keys(prompts).length).toBe(1)
+        const key = Object.keys(prompts)[0]
+        expect(key).toContain("prompt-server")
+        expect(key).toContain("my-prompt")
+      }),
   ),
 )
 
@@ -485,22 +503,23 @@ test(
         command: ["echo", "test"],
       },
     },
-    async () => {
-      lastCreatedClientName = "resource-server"
-      const serverState = getOrCreateClientState("resource-server")
-      serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
-
-      await MCP.add("resource-server", {
-        type: "local",
-        command: ["echo", "test"],
-      })
-
-      const resources = await MCP.resources()
-      expect(Object.keys(resources).length).toBe(1)
-      const key = Object.keys(resources)[0]
-      expect(key).toContain("resource-server")
-      expect(key).toContain("my-resource")
-    },
+    (mcp) =>
+      Effect.gen(function* () {
+        lastCreatedClientName = "resource-server"
+        const serverState = getOrCreateClientState("resource-server")
+        serverState.resources = [{ name: "my-resource", uri: "file:///test.txt", description: "A test resource" }]
+
+        yield* mcp.add("resource-server", {
+          type: "local",
+          command: ["echo", "test"],
+        })
+
+        const resources = yield* mcp.resources()
+        expect(Object.keys(resources).length).toBe(1)
+        const key = Object.keys(resources)[0]
+        expect(key).toContain("resource-server")
+        expect(key).toContain("my-resource")
+      }),
   ),
 )
 
@@ -513,21 +532,22 @@ test(
         command: ["echo", "test"],
       },
     },
-    async () => {
-      lastCreatedClientName = "prompt-disc-server"
-      const serverState = getOrCreateClientState("prompt-disc-server")
-      serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }]
-
-      await MCP.add("prompt-disc-server", {
-        type: "local",
-        command: ["echo", "test"],
-      })
-
-      await MCP.disconnect("prompt-disc-server")
-
-      const prompts = await MCP.prompts()
-      expect(Object.keys(prompts).length).toBe(0)
-    },
+    (mcp) =>
+      Effect.gen(function* () {
+        lastCreatedClientName = "prompt-disc-server"
+        const serverState = getOrCreateClientState("prompt-disc-server")
+        serverState.prompts = [{ name: "hidden-prompt", description: "Should not appear" }]
+
+        yield* mcp.add("prompt-disc-server", {
+          type: "local",
+          command: ["echo", "test"],
+        })
+
+        yield* mcp.disconnect("prompt-disc-server")
+
+        const prompts = yield* mcp.prompts()
+        expect(Object.keys(prompts).length).toBe(0)
+      }),
   ),
 )
 
@@ -537,12 +557,14 @@ test(
 
 test(
   "connect() on nonexistent server does not throw",
-  withInstance({}, async () => {
-    // Should not throw
-    await MCP.connect("nonexistent")
-    const status = await MCP.status()
-    expect(status["nonexistent"]).toBeUndefined()
-  }),
+  withInstance({}, (mcp) =>
+    Effect.gen(function* () {
+      // Should not throw
+      yield* mcp.connect("nonexistent")
+      const status = yield* mcp.status()
+      expect(status["nonexistent"]).toBeUndefined()
+    }),
+  ),
 )
 
 // ========================================================================
@@ -551,10 +573,12 @@ test(
 
 test(
   "disconnect() on nonexistent server does not throw",
-  withInstance({}, async () => {
-    await MCP.disconnect("nonexistent")
-    // Should complete without error
-  }),
+  withInstance({}, (mcp) =>
+    Effect.gen(function* () {
+      yield* mcp.disconnect("nonexistent")
+      // Should complete without error
+    }),
+  ),
 )
 
 // ========================================================================
@@ -563,10 +587,12 @@ test(
 
 test(
   "tools() returns empty when no MCP servers are configured",
-  withInstance({}, async () => {
-    const tools = await MCP.tools()
-    expect(Object.keys(tools).length).toBe(0)
-  }),
+  withInstance({}, (mcp) =>
+    Effect.gen(function* () {
+      const tools = yield* mcp.tools()
+      expect(Object.keys(tools).length).toBe(0)
+    }),
+  ),
 )
 
 // ========================================================================
@@ -582,27 +608,28 @@ test(
         command: ["echo", "test"],
       },
     },
-    async () => {
-      lastCreatedClientName = "fail-connect"
-      getOrCreateClientState("fail-connect")
-      connectShouldFail = true
-      connectError = "Connection refused"
-
-      await MCP.add("fail-connect", {
-        type: "local",
-        command: ["echo", "test"],
-      })
-
-      const status = await MCP.status()
-      expect(status["fail-connect"]?.status).toBe("failed")
-      if (status["fail-connect"]?.status === "failed") {
-        expect(status["fail-connect"].error).toContain("Connection refused")
-      }
-
-      // No tools should be available
-      const tools = await MCP.tools()
-      expect(Object.keys(tools).length).toBe(0)
-    },
+    (mcp) =>
+      Effect.gen(function* () {
+        lastCreatedClientName = "fail-connect"
+        getOrCreateClientState("fail-connect")
+        connectShouldFail = true
+        connectError = "Connection refused"
+
+        yield* mcp.add("fail-connect", {
+          type: "local",
+          command: ["echo", "test"],
+        })
+
+        const status = yield* mcp.status()
+        expect(status["fail-connect"]?.status).toBe("failed")
+        if (status["fail-connect"]?.status === "failed") {
+          expect(status["fail-connect"].error).toContain("Connection refused")
+        }
+
+        // No tools should be available
+        const tools = yield* mcp.tools()
+        expect(Object.keys(tools).length).toBe(0)
+      }),
   ),
 )
 
@@ -648,28 +675,29 @@ test(
         command: ["echo", "test"],
       },
     },
-    async () => {
-      lastCreatedClientName = "my.special-server"
-      const serverState = getOrCreateClientState("my.special-server")
-      serverState.tools = [
-        { name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } },
-        { name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } },
-      ]
-
-      await MCP.add("my.special-server", {
-        type: "local",
-        command: ["echo", "test"],
-      })
-
-      const tools = await MCP.tools()
-      const keys = Object.keys(tools)
-
-      // Server name dots should be replaced with underscores
-      expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
-      // Tool name dots should be replaced with underscores
-      expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
-      expect(keys.length).toBe(2)
-    },
+    (mcp) =>
+      Effect.gen(function* () {
+        lastCreatedClientName = "my.special-server"
+        const serverState = getOrCreateClientState("my.special-server")
+        serverState.tools = [
+          { name: "tool-a", description: "Tool A", inputSchema: { type: "object", properties: {} } },
+          { name: "tool.b", description: "Tool B", inputSchema: { type: "object", properties: {} } },
+        ]
+
+        yield* mcp.add("my.special-server", {
+          type: "local",
+          command: ["echo", "test"],
+        })
+
+        const tools = yield* mcp.tools()
+        const keys = Object.keys(tools)
+
+        // Server name dots should be replaced with underscores
+        expect(keys.some((k) => k.startsWith("my_special-server_"))).toBe(true)
+        // Tool name dots should be replaced with underscores
+        expect(keys.some((k) => k.endsWith("tool_b"))).toBe(true)
+        expect(keys.length).toBe(2)
+      }),
   ),
 )
 
@@ -679,23 +707,25 @@ test(
 
 test(
   "local stdio transport is closed when connect times out (no process leak)",
-  withInstance({}, async () => {
-    lastCreatedClientName = "hanging-server"
-    getOrCreateClientState("hanging-server")
-    connectShouldHang = true
-
-    const addResult = await MCP.add("hanging-server", {
-      type: "local",
-      command: ["node", "fake.js"],
-      timeout: 100,
-    })
+  withInstance({}, (mcp) =>
+    Effect.gen(function* () {
+      lastCreatedClientName = "hanging-server"
+      getOrCreateClientState("hanging-server")
+      connectShouldHang = true
 
-    const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status
-    expect(serverStatus.status).toBe("failed")
-    expect(serverStatus.error).toContain("timed out")
-    // Transport must be closed to avoid orphaned child process
-    expect(transportCloseCount).toBeGreaterThanOrEqual(1)
-  }),
+      const addResult = yield* mcp.add("hanging-server", {
+        type: "local",
+        command: ["node", "fake.js"],
+        timeout: 100,
+      })
+
+      const serverStatus = (addResult.status as any)["hanging-server"] ?? addResult.status
+      expect(serverStatus.status).toBe("failed")
+      expect(serverStatus.error).toContain("timed out")
+      // Transport must be closed to avoid orphaned child process
+      expect(transportCloseCount).toBeGreaterThanOrEqual(1)
+    }),
+  ),
 )
 
 // ========================================================================
@@ -704,23 +734,25 @@ test(
 
 test(
   "remote transport is closed when connect times out",
-  withInstance({}, async () => {
-    lastCreatedClientName = "hanging-remote"
-    getOrCreateClientState("hanging-remote")
-    connectShouldHang = true
-
-    const addResult = await MCP.add("hanging-remote", {
-      type: "remote",
-      url: "http://localhost:9999/mcp",
-      timeout: 100,
-      oauth: false,
-    })
+  withInstance({}, (mcp) =>
+    Effect.gen(function* () {
+      lastCreatedClientName = "hanging-remote"
+      getOrCreateClientState("hanging-remote")
+      connectShouldHang = true
+
+      const addResult = yield* mcp.add("hanging-remote", {
+        type: "remote",
+        url: "http://localhost:9999/mcp",
+        timeout: 100,
+        oauth: false,
+      })
 
-    const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status
-    expect(serverStatus.status).toBe("failed")
-    // Transport must be closed to avoid leaked HTTP connections
-    expect(transportCloseCount).toBeGreaterThanOrEqual(1)
-  }),
+      const serverStatus = (addResult.status as any)["hanging-remote"] ?? addResult.status
+      expect(serverStatus.status).toBe("failed")
+      // Transport must be closed to avoid leaked HTTP connections
+      expect(transportCloseCount).toBeGreaterThanOrEqual(1)
+    }),
+  ),
 )
 
 // ========================================================================
@@ -729,22 +761,24 @@ test(
 
 test(
   "failed remote transport is closed before trying next transport",
-  withInstance({}, async () => {
-    lastCreatedClientName = "fail-remote"
-    getOrCreateClientState("fail-remote")
-    connectShouldFail = true
-    connectError = "Connection refused"
-
-    const addResult = await MCP.add("fail-remote", {
-      type: "remote",
-      url: "http://localhost:9999/mcp",
-      timeout: 5000,
-      oauth: false,
-    })
+  withInstance({}, (mcp) =>
+    Effect.gen(function* () {
+      lastCreatedClientName = "fail-remote"
+      getOrCreateClientState("fail-remote")
+      connectShouldFail = true
+      connectError = "Connection refused"
 
-    const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status
-    expect(serverStatus.status).toBe("failed")
-    // Both StreamableHTTP and SSE transports should be closed
-    expect(transportCloseCount).toBeGreaterThanOrEqual(2)
-  }),
+      const addResult = yield* mcp.add("fail-remote", {
+        type: "remote",
+        url: "http://localhost:9999/mcp",
+        timeout: 5000,
+        oauth: false,
+      })
+
+      const serverStatus = (addResult.status as any)["fail-remote"] ?? addResult.status
+      expect(serverStatus.status).toBe("failed")
+      // Both StreamableHTTP and SSE transports should be closed
+      expect(transportCloseCount).toBeGreaterThanOrEqual(2)
+    }),
+  ),
 )

+ 10 - 4
packages/opencode/test/mcp/oauth-auto-connect.test.ts

@@ -1,4 +1,6 @@
 import { test, expect, mock, beforeEach } from "bun:test"
+import { Effect } from "effect"
+import type { MCP as MCPNS } from "../../src/mcp/index"
 
 // Mock UnauthorizedError to match the SDK's class
 class MockUnauthorizedError extends Error {
@@ -122,10 +124,14 @@ test("first connect to OAuth server shows needs_auth instead of failed", async (
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const result = await MCP.add("test-oauth", {
-        type: "remote",
-        url: "https://example.com/mcp",
-      })
+      const result = await Effect.runPromise(
+        MCP.Service.use((mcp) =>
+          mcp.add("test-oauth", {
+            type: "remote",
+            url: "https://example.com/mcp",
+          }),
+        ).pipe(Effect.provide(MCP.defaultLayer)),
+      )
 
       const serverStatus = result.status as Record<string, { status: string; error?: string }>
 

+ 22 - 3
packages/opencode/test/mcp/oauth-browser.test.ts

@@ -1,5 +1,7 @@
 import { test, expect, mock, beforeEach } from "bun:test"
 import { EventEmitter } from "events"
+import { Effect } from "effect"
+import type { MCP as MCPNS } from "../../src/mcp/index"
 
 // Track open() calls and control failure behavior
 let openShouldFail = false
@@ -100,10 +102,12 @@ beforeEach(() => {
 
 // Import modules after mocking
 const { MCP } = await import("../../src/mcp/index")
+const { AppRuntime } = await import("../../src/effect/app-runtime")
 const { Bus } = await import("../../src/bus")
 const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
 const { Instance } = await import("../../src/project/instance")
 const { tmpdir } = await import("../fixture/fixture")
+const service = MCP.Service as unknown as Effect.Effect<MCPNS.Interface, never, never>
 
 test("BrowserOpenFailed event is published when open() throws", async () => {
   await using tmp = await tmpdir({
@@ -136,7 +140,12 @@ test("BrowserOpenFailed event is published when open() throws", async () => {
       // Run authenticate with a timeout to avoid waiting forever for the callback
       // Attach a handler immediately so callback shutdown rejections
       // don't show up as unhandled between tests.
-      const authPromise = MCP.authenticate("test-oauth-server").catch(() => undefined)
+      const authPromise = AppRuntime.runPromise(
+        Effect.gen(function* () {
+          const mcp = yield* service
+          return yield* mcp.authenticate("test-oauth-server")
+        }),
+      ).catch(() => undefined)
 
       // Config.get() can be slow in tests, so give it plenty of time.
       await new Promise((resolve) => setTimeout(resolve, 2_000))
@@ -185,7 +194,12 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () =
       })
 
       // Run authenticate with a timeout to avoid waiting forever for the callback
-      const authPromise = MCP.authenticate("test-oauth-server-2").catch(() => undefined)
+      const authPromise = AppRuntime.runPromise(
+        Effect.gen(function* () {
+          const mcp = yield* service
+          return yield* mcp.authenticate("test-oauth-server-2")
+        }),
+      ).catch(() => undefined)
 
       // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
       await new Promise((resolve) => setTimeout(resolve, 2_000))
@@ -230,7 +244,12 @@ test("open() is called with the authorization URL", async () => {
       openCalledWith = undefined
 
       // Run authenticate with a timeout to avoid waiting forever for the callback
-      const authPromise = MCP.authenticate("test-oauth-server-3").catch(() => undefined)
+      const authPromise = AppRuntime.runPromise(
+        Effect.gen(function* () {
+          const mcp = yield* service
+          return yield* mcp.authenticate("test-oauth-server-3")
+        }),
+      ).catch(() => undefined)
 
       // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
       await new Promise((resolve) => setTimeout(resolve, 2_000))

+ 9 - 6
packages/opencode/test/permission-task.test.ts

@@ -3,6 +3,9 @@ import { Permission } from "../src/permission"
 import { Config } from "../src/config/config"
 import { Instance } from "../src/project/instance"
 import { tmpdir } from "./fixture/fixture"
+import { AppRuntime } from "../src/effect/app-runtime"
+
+const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
 
 afterEach(async () => {
   await Instance.disposeAll()
@@ -158,7 +161,7 @@ describe("permission.task with real config files", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const config = await Config.get()
+        const config = await load()
         const ruleset = Permission.fromConfig(config.permission ?? {})
         // general and orchestrator-fast should be allowed, code-reviewer denied
         expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
@@ -183,7 +186,7 @@ describe("permission.task with real config files", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const config = await Config.get()
+        const config = await load()
         const ruleset = Permission.fromConfig(config.permission ?? {})
         // general and code-reviewer should be ask, orchestrator-* denied
         expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
@@ -208,7 +211,7 @@ describe("permission.task with real config files", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const config = await Config.get()
+        const config = await load()
         const ruleset = Permission.fromConfig(config.permission ?? {})
         expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
         expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
@@ -235,7 +238,7 @@ describe("permission.task with real config files", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const config = await Config.get()
+        const config = await load()
         const ruleset = Permission.fromConfig(config.permission ?? {})
 
         // Verify task permissions
@@ -273,7 +276,7 @@ describe("permission.task with real config files", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const config = await Config.get()
+        const config = await load()
         const ruleset = Permission.fromConfig(config.permission ?? {})
 
         // Last matching rule wins - "*" deny is last, so all agents are denied
@@ -304,7 +307,7 @@ describe("permission.task with real config files", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const config = await Config.get()
+        const config = await load()
         const ruleset = Permission.fromConfig(config.permission ?? {})
 
         // Evaluate uses findLast - "general" allow comes after "*" deny

+ 3 - 0
packages/opencode/test/plugin/github-copilot-models.test.ts

@@ -125,6 +125,9 @@ test("remaps fallback oauth model urls to the enterprise host", async () => {
     project: {} as never,
     directory: "",
     worktree: "",
+    experimental_workspace: {
+      register() {},
+    },
     serverUrl: new URL("https://example.com"),
     $: {} as never,
   })

+ 99 - 0
packages/opencode/test/plugin/workspace-adaptor.test.ts

@@ -0,0 +1,99 @@
+import { afterAll, afterEach, describe, expect, test } from "bun:test"
+import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../fixture/fixture"
+
+const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
+process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
+
+const { Plugin } = await import("../../src/plugin/index")
+const { Workspace } = await import("../../src/control-plane/workspace")
+const { Instance } = await import("../../src/project/instance")
+
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
+afterAll(() => {
+  if (disableDefault === undefined) {
+    delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
+    return
+  }
+  process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
+})
+
+describe("plugin.workspace", () => {
+  test("plugin can install a workspace adaptor", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const type = `plug-${Math.random().toString(36).slice(2)}`
+        const file = path.join(dir, "plugin.ts")
+        const mark = path.join(dir, "created.json")
+        const space = path.join(dir, "space")
+        await Bun.write(
+          file,
+          [
+            "export default async ({ experimental_workspace }) => {",
+            `  experimental_workspace.register(${JSON.stringify(type)}, {`,
+            '    name: "plug",',
+            '    description: "plugin workspace adaptor",',
+            "    configure(input) {",
+            `      return { ...input, name: \"plug\", branch: \"plug/main\", directory: ${JSON.stringify(space)} }`,
+            "    },",
+            "    async create(input) {",
+            `      await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`,
+            "    },",
+            "    async remove() {},",
+            "    target(input) {",
+            '      return { type: "local", directory: input.directory }',
+            "    },",
+            "  })",
+            "  return {}",
+            "}",
+            "",
+          ].join("\n"),
+        )
+
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify(
+            {
+              $schema: "https://opencode.ai/config.json",
+              plugin: [pathToFileURL(file).href],
+            },
+            null,
+            2,
+          ),
+        )
+
+        return { mark, space, type }
+      },
+    })
+
+    const info = await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        await Plugin.init()
+        return Workspace.create({
+          type: tmp.extra.type,
+          branch: null,
+          extra: { key: "value" },
+          projectID: Instance.project.id,
+        })
+      },
+    })
+
+    expect(info.type).toBe(tmp.extra.type)
+    expect(info.name).toBe("plug")
+    expect(info.branch).toBe("plug/main")
+    expect(info.directory).toBe(tmp.extra.space)
+    expect(info.extra).toEqual({ key: "value" })
+    expect(JSON.parse(await Bun.file(tmp.extra.mark).text())).toMatchObject({
+      type: tmp.extra.type,
+      name: "plug",
+      branch: "plug/main",
+      directory: tmp.extra.space,
+      extra: { key: "value" },
+    })
+  })
+})

+ 34 - 0
packages/plugin/src/example-workspace.ts

@@ -0,0 +1,34 @@
+import type { Plugin } from "@opencode-ai/plugin"
+import { mkdir, rm } from "node:fs/promises"
+
+export const FolderWorkspacePlugin: Plugin = async ({ experimental_workspace }) => {
+  experimental_workspace.register("folder", {
+    name: "Folder",
+    description: "Create a blank folder",
+    configure(config) {
+      const rand = "" + Math.random()
+
+      return {
+        ...config,
+        directory: `/tmp/folder/folder-${rand}`,
+      }
+    },
+    async create(config) {
+      if (!config.directory) return
+      await mkdir(config.directory, { recursive: true })
+    },
+    async remove(config) {
+      await rm(config.directory!, { recursive: true, force: true })
+    },
+    target(config) {
+      return {
+        type: "local",
+        directory: config.directory!,
+      }
+    },
+  })
+
+  return {}
+}
+
+export default FolderWorkspacePlugin

+ 33 - 0
packages/plugin/src/index.ts

@@ -24,11 +24,44 @@ export type ProviderContext = {
   options: Record<string, any>
 }
 
+export type WorkspaceInfo = {
+  id: string
+  type: string
+  name: string
+  branch: string | null
+  directory: string | null
+  extra: unknown | null
+  projectID: string
+}
+
+export type WorkspaceTarget =
+  | {
+      type: "local"
+      directory: string
+    }
+  | {
+      type: "remote"
+      url: string | URL
+      headers?: HeadersInit
+    }
+
+export type WorkspaceAdaptor = {
+  name: string
+  description: string
+  configure(config: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
+  create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
+  remove(config: WorkspaceInfo): Promise<void>
+  target(config: WorkspaceInfo): WorkspaceTarget | Promise<WorkspaceTarget>
+}
+
 export type PluginInput = {
   client: ReturnType<typeof createOpencodeClient>
   project: Project
   directory: string
   worktree: string
+  experimental_workspace: {
+    register(type: string, adaptor: WorkspaceAdaptor): void
+  }
   serverUrl: URL
   $: BunShell
 }

+ 1 - 0
packages/plugin/tsconfig.json

@@ -2,6 +2,7 @@
   "$schema": "https://json.schemastore.org/tsconfig.json",
   "extends": "@tsconfig/node22/tsconfig.json",
   "compilerOptions": {
+    "rootDir": "src",
     "outDir": "dist",
     "module": "nodenext",
     "declaration": true,

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

@@ -29,6 +29,7 @@ import type {
   ExperimentalConsoleSwitchOrgResponses,
   ExperimentalResourceListResponses,
   ExperimentalSessionListResponses,
+  ExperimentalWorkspaceAdaptorListResponses,
   ExperimentalWorkspaceCreateErrors,
   ExperimentalWorkspaceCreateResponses,
   ExperimentalWorkspaceListResponses,
@@ -1086,6 +1087,38 @@ export class Console extends HeyApiClient {
   }
 }
 
+export class Adaptor extends HeyApiClient {
+  /**
+   * List workspace adaptors
+   *
+   * List all available workspace adaptors for the current project.
+   */
+  public list<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).get<ExperimentalWorkspaceAdaptorListResponses, unknown, ThrowOnError>({
+      url: "/experimental/workspace/adaptor",
+      ...options,
+      ...params,
+    })
+  }
+}
+
 export class Workspace extends HeyApiClient {
   /**
    * List workspaces
@@ -1229,6 +1262,11 @@ export class Workspace extends HeyApiClient {
       ...params,
     })
   }
+
+  private _adaptor?: Adaptor
+  get adaptor(): Adaptor {
+    return (this._adaptor ??= new Adaptor({ client: this.client }))
+  }
 }
 
 export class Session extends HeyApiClient {

+ 25 - 1
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -1772,8 +1772,8 @@ export type ToolList = Array<ToolListItem>
 export type Workspace = {
   id: string
   type: string
+  name: string
   branch: string | null
-  name: string | null
   directory: string | null
   extra: unknown | null
   projectID: string
@@ -2812,6 +2812,30 @@ export type ToolListResponses = {
 
 export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
 
+export type ExperimentalWorkspaceAdaptorListData = {
+  body?: never
+  path?: never
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/experimental/workspace/adaptor"
+}
+
+export type ExperimentalWorkspaceAdaptorListResponses = {
+  /**
+   * Workspace adaptors
+   */
+  200: Array<{
+    type: string
+    name: string
+    description: string
+  }>
+}
+
+export type ExperimentalWorkspaceAdaptorListResponse =
+  ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses]
+
 export type ExperimentalWorkspaceListData = {
   body?: never
   path?: never

+ 60 - 11
packages/sdk/openapi.json

@@ -1526,6 +1526,62 @@
         ]
       }
     },
+    "/experimental/workspace/adaptor": {
+      "get": {
+        "operationId": "experimental.workspace.adaptor.list",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "directory",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "in": "query",
+            "name": "workspace",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "summary": "List workspace adaptors",
+        "description": "List all available workspace adaptors for the current project.",
+        "responses": {
+          "200": {
+            "description": "Workspace adaptors",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {
+                    "type": "object",
+                    "properties": {
+                      "type": {
+                        "type": "string"
+                      },
+                      "name": {
+                        "type": "string"
+                      },
+                      "description": {
+                        "type": "string"
+                      }
+                    },
+                    "required": ["type", "name", "description"]
+                  }
+                }
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n  ...\n})"
+          }
+        ]
+      }
+    },
     "/experimental/workspace": {
       "post": {
         "operationId": "experimental.workspace.create",
@@ -11885,17 +11941,10 @@
           "type": {
             "type": "string"
           },
-          "branch": {
-            "anyOf": [
-              {
-                "type": "string"
-              },
-              {
-                "type": "null"
-              }
-            ]
-          },
           "name": {
+            "type": "string"
+          },
+          "branch": {
             "anyOf": [
               {
                 "type": "string"
@@ -11927,7 +11976,7 @@
             "type": "string"
           }
         },
-        "required": ["id", "type", "branch", "name", "directory", "extra", "projectID"]
+        "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"]
       },
       "Worktree": {
         "type": "object",