summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorJames Long <[email protected]>2026-04-13 13:33:13 -0400
committerGitHub <[email protected]>2026-04-13 13:33:13 -0400
commitbf50d1c028e973ccc0beffdf568fca417b62f020 (patch)
treecfbbaf0c18554bd442bb0058246480d4c79fe2e6 /packages
parentb8801dbd22e561e3ddaf83744726d8d98744f255 (diff)
downloadopencode-bf50d1c028e973ccc0beffdf568fca417b62f020.tar.gz
opencode-bf50d1c028e973ccc0beffdf568fca417b62f020.zip
feat(core): expose workspace adaptors to plugins (#21927)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/migration/20260410174513_workspace-name/migration.sql16
-rw-r--r--packages/opencode/migration/20260410174513_workspace-name/snapshot.json1337
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx48
-rw-r--r--packages/opencode/src/control-plane/adaptors/index.ts54
-rw-r--r--packages/opencode/src/control-plane/adaptors/worktree.ts20
-rw-r--r--packages/opencode/src/control-plane/types.ts14
-rw-r--r--packages/opencode/src/control-plane/workspace.sql.ts2
-rw-r--r--packages/opencode/src/control-plane/workspace.ts9
-rw-r--r--packages/opencode/src/plugin/index.ts15
-rw-r--r--packages/opencode/src/server/instance/middleware.ts2
-rw-r--r--packages/opencode/src/server/instance/workspace.ts28
-rw-r--r--packages/opencode/test/control-plane/adaptors.test.ts71
-rw-r--r--packages/opencode/test/plugin/github-copilot-models.test.ts3
-rw-r--r--packages/opencode/test/plugin/workspace-adaptor.test.ts99
-rw-r--r--packages/plugin/src/example-workspace.ts34
-rw-r--r--packages/plugin/src/index.ts33
-rw-r--r--packages/plugin/tsconfig.json1
17 files changed, 1744 insertions, 42 deletions
diff --git a/packages/opencode/migration/20260410174513_workspace-name/migration.sql b/packages/opencode/migration/20260410174513_workspace-name/migration.sql
new file mode 100644
index 000000000..2a27248e4
--- /dev/null
+++ b/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; \ No newline at end of file
diff --git a/packages/opencode/migration/20260410174513_workspace-name/snapshot.json b/packages/opencode/migration/20260410174513_workspace-name/snapshot.json
new file mode 100644
index 000000000..ab7028008
--- /dev/null
+++ b/packages/opencode/migration/20260410174513_workspace-name/snapshot.json
@@ -0,0 +1,1337 @@
+{
+ "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": []
+} \ No newline at end of file
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
index 40cc1013e..447a1c325 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
+++ b/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)
}}
/>
diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts
index a43fce248..291e392ea 100644
--- a/packages/opencode/src/control-plane/adaptors/index.ts
+++ b/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)
}
diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts
index 9fb6c7479..6cc4c20a4 100644
--- a/packages/opencode/src/control-plane/adaptors/worktree.ts
+++ b/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,
diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts
index dd17c56d9..4e499e45e 100644
--- a/packages/opencode/src/control-plane/types.ts
+++ b/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>
}
diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts
index 272907da1..a6a4ce2c8 100644
--- a/packages/opencode/src/control-plane/workspace.sql.ts
+++ b/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()
diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts
index bbf79620c..f330e07b7 100644
--- a/packages/opencode/src/control-plane/workspace.ts
+++ b/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
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index e0478e0b3..60942915a 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/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")
},
diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts
index 19bd26535..868131eb8 100644
--- a/packages/opencode/src/server/instance/middleware.ts
+++ b/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") {
diff --git a/packages/opencode/src/server/instance/workspace.ts b/packages/opencode/src/server/instance/workspace.ts
index 419321654..7cee03197 100644
--- a/packages/opencode/src/server/instance/workspace.ts
+++ b/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({
diff --git a/packages/opencode/test/control-plane/adaptors.test.ts b/packages/opencode/test/control-plane/adaptors.test.ts
new file mode 100644
index 000000000..a8e490226
--- /dev/null
+++ b/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",
+ })
+ })
+})
diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts
index 0b67588a7..33ddef5dd 100644
--- a/packages/opencode/test/plugin/github-copilot-models.test.ts
+++ b/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,
})
diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts
new file mode 100644
index 000000000..cc098b124
--- /dev/null
+++ b/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" },
+ })
+ })
+})
diff --git a/packages/plugin/src/example-workspace.ts b/packages/plugin/src/example-workspace.ts
new file mode 100644
index 000000000..925328450
--- /dev/null
+++ b/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
diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts
index 1afb55daa..49d995c6f 100644
--- a/packages/plugin/src/index.ts
+++ b/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
}
diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json
index 117381878..f8e9370d8 100644
--- a/packages/plugin/tsconfig.json
+++ b/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,