Browse Source

feat(core): allow external workspace creation (#26212)

James Long 3 months ago
parent
commit
c818c9dcb6

+ 1 - 0
packages/opencode/migration/20260507164347_add_workspace_time/migration.sql

@@ -0,0 +1 @@
+ALTER TABLE `workspace` ADD `time_used` integer NOT NULL;

+ 1459 - 0
packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json

@@ -0,0 +1,1459 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "id": "630a93f2-c6c6-4191-a351-868d8f3a05d4",
+  "prevIds": ["27114226-085b-421a-9a40-29b88747e29a"],
+  "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_message",
+      "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": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_used",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "worktree",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "vcs",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url_override",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_color",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_initialized",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "sandboxes",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "commands",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "message_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "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_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "parent_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "slug",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "path",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "title",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "version",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "share_url",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_additions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_deletions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_files",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_diffs",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "revert",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "permission",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "agent",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "model",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_compacting",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_archived",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "content",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "status",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "priority",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "position",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "secret",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "aggregate_id",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "owner_id",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "aggregate_id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "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": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_message_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_message"
+    },
+    {
+      "columns": ["project_id"],
+      "tableTo": "project",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "session"
+    },
+    {
+      "columns": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_todo_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "todo"
+    },
+    {
+      "columns": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_share_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_share"
+    },
+    {
+      "columns": ["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_message_pk",
+      "table": "session_message",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "session_pk",
+      "table": "session",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["session_id"],
+      "nameExplicit": false,
+      "name": "session_share_pk",
+      "table": "session_share",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["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": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_session_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "type",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_session_type_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "time_created",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_time_created_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "project_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_project_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_workspace_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "parent_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_parent_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "todo_session_idx",
+      "entityType": "indexes",
+      "table": "todo"
+    }
+  ],
+  "renames": []
+}

+ 1 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -107,6 +107,7 @@ export function DialogSessionList() {
             dialog,
             sdk,
             sync,
+            project,
             toast,
             onSelect: (selection) => {
               void warp(selection)

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

@@ -36,21 +36,14 @@ export type WorkspaceSelection =
 type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
 type ExistingWorkspaceSelectValue = { workspace: Workspace }
 
-export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string }>(input: {
-  sessions: readonly { workspaceID?: string; time: { updated: number } }[]
-  get: (workspaceID: string) => WorkspaceInfo | undefined
+export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string; timeUsed: number | string }>(input: {
+  workspaces: readonly WorkspaceInfo[]
   status: (workspaceID: string) => string | undefined
   limit?: number
   omitWorkspaceID?: string
 }) {
-  const workspaces = input.sessions
-    .toSorted((a, b) => b.time.updated - a.time.updated)
-    .flatMap((session) => {
-      const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined
-      return workspace && input.status(workspace.id) === "connected" ? [workspace] : []
-    })
-    .filter((workspace) => workspace.id !== input.omitWorkspaceID)
-    .filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index)
+  const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected")
+  const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed))
   const recent = workspaces.slice(0, input.limit ?? 3)
 
   return { recent, hasMore: recent.length < workspaces.length }
@@ -83,10 +76,13 @@ export async function openWorkspaceSelect(input: {
   dialog: ReturnType<typeof useDialog>
   sdk: ReturnType<typeof useSDK>
   sync: ReturnType<typeof useSync>
+  project: ReturnType<typeof useProject>
   toast: ReturnType<typeof useToast>
   onSelect: (selection: WorkspaceSelection) => Promise<void> | void
 }) {
   input.dialog.clear()
+  await input.sdk.client.experimental.workspace.syncList().catch(() => undefined)
+  await input.project.workspace.sync().catch(() => undefined)
   const adapters = await loadWorkspaceAdapters(input)
   if (!adapters) return
   input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
@@ -200,8 +196,7 @@ export function DialogWorkspaceSelect(props: {
     const list = adapters()
     if (!list) return []
     const { recent, hasMore } = recentConnectedWorkspaces({
-      sessions: sync.data.session,
-      get: project.workspace.get,
+      workspaces: project.workspace.list(),
       status: project.workspace.status,
       omitWorkspaceID: omittedWorkspaceID(),
     })

+ 2 - 0
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -610,6 +610,7 @@ export function Prompt(props: PromptProps) {
             dialog,
             sdk,
             sync,
+            project,
             toast,
             onSelect: (selection) => {
               void warpSession(selection)
@@ -1036,6 +1037,7 @@ export function Prompt(props: PromptProps) {
               dialog,
               sdk,
               sync,
+              project,
               toast,
               onSelect: (selection) => {
                 void warpSession(selection)

+ 8 - 12
packages/opencode/src/control-plane/adapters/index.ts

@@ -18,22 +18,18 @@ export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter
   throw new Error(`Unknown workspace adapter: ${type}`)
 }
 
-export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> {
-  const builtin = await Promise.all(
-    Object.entries(BUILTIN).map(async ([type, adapter]) => {
-      return {
-        type,
-        name: adapter.name,
-        description: adapter.description,
-      }
-    }),
-  )
-  const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
+export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] {
+  return registeredAdapters(projectID).map(([type, adapter]) => ({
     type,
     name: adapter.name,
     description: adapter.description,
   }))
-  return [...builtin, ...custom]
+}
+
+export function registeredAdapters(projectID: ProjectID): [string, WorkspaceAdapter][] {
+  const adapters = new Map(Object.entries(BUILTIN))
+  for (const [type, adapter] of state.get(projectID)?.entries() ?? []) adapters.set(type, adapter)
+  return [...adapters.entries()]
 }
 
 // Plugins can be loaded per-project so we need to scope them. If you

+ 19 - 4
packages/opencode/src/control-plane/adapters/worktree.ts

@@ -3,14 +3,18 @@ import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
 
 const WorktreeConfig = Schema.Struct({
   name: WorkspaceInfo.fields.name,
-  branch: Schema.String,
+  branch: Schema.optional(Schema.NullOr(Schema.String)),
   directory: Schema.String,
 })
 const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
 
 async function loadWorktree() {
-  const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
-  return { AppRuntime, Worktree }
+  const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([
+    import("@/effect/app-runtime"),
+    import("@/project/instance"),
+    import("@/worktree"),
+  ])
+  return { AppRuntime, Instance, Worktree }
 }
 
 export const WorktreeAdapter: WorkspaceAdapter = {
@@ -34,11 +38,22 @@ export const WorktreeAdapter: WorkspaceAdapter = {
         svc.createFromInfo({
           name: config.name,
           directory: config.directory,
-          branch: config.branch,
+          branch: config.branch ?? config.name,
         }),
       ),
     )
   },
+  async list() {
+    const { AppRuntime, Instance, Worktree } = await loadWorktree()
+    return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({
+      type: "worktree",
+      name: info.name,
+      branch: info.branch ?? null,
+      directory: info.directory,
+      extra: null,
+      projectID: Instance.project.id,
+    }))
+  },
   async remove(info) {
     const { AppRuntime, Worktree } = await loadWorktree()
     const config = decodeWorktreeConfig(info)

+ 7 - 1
packages/opencode/src/control-plane/types.ts

@@ -1,4 +1,4 @@
-import { Schema } from "effect"
+import { Schema, Struct } from "effect"
 import { ProjectID } from "@/project/schema"
 import { WorkspaceID } from "./schema"
 import { zod } from "@/util/effect-zod"
@@ -17,6 +17,11 @@ export const WorkspaceInfo = Schema.Struct({
   .pipe(withStatics((s) => ({ zod: zod(s) })))
 export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
 
+export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"]))
+  .annotate({ identifier: "WorkspaceListedInfo" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type WorkspaceListedInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceListedInfo>>
+
 export const WorkspaceAdapterEntry = Schema.Struct({
   type: Schema.String,
   name: Schema.String,
@@ -40,6 +45,7 @@ export type WorkspaceAdapter = {
   description: string
   configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
   create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
+  list?(): WorkspaceListedInfo[] | Promise<WorkspaceListedInfo[]>
   remove(info: WorkspaceInfo): Promise<void>
   target(info: WorkspaceInfo): Target | Promise<Target>
 }

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

@@ -1,4 +1,4 @@
-import { sqliteTable, text } from "drizzle-orm/sqlite-core"
+import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
 import { ProjectTable } from "../project/project.sql"
 import type { ProjectID } from "../project/schema"
 import type { WorkspaceID } from "./schema"
@@ -14,4 +14,7 @@ export const WorkspaceTable = sqliteTable("workspace", {
     .$type<ProjectID>()
     .notNull()
     .references(() => ProjectTable.id, { onDelete: "cascade" }),
+  time_used: integer()
+    .notNull()
+    .$default(() => Date.now()),
 })

+ 90 - 8
packages/opencode/src/control-plane/workspace.ts

@@ -17,7 +17,7 @@ import { Filesystem } from "@/util/filesystem"
 import { ProjectID } from "@/project/schema"
 import { Slug } from "@opencode-ai/core/util/slug"
 import { WorkspaceTable } from "./workspace.sql"
-import { getAdapter } from "./adapters"
+import { getAdapter, registeredAdapters } from "./adapters"
 import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
 import { WorkspaceID } from "./schema"
 import { Session } from "@/session/session"
@@ -35,8 +35,13 @@ import { Vcs } from "@/project/vcs"
 import { InstanceStore } from "@/project/instance-store"
 import { InstanceBootstrap } from "@/project/bootstrap"
 
-export const Info = WorkspaceInfoSchema
-export type Info = WorkspaceInfo
+export const Info = Schema.Struct({
+  ...WorkspaceInfoSchema.fields,
+  timeUsed: Schema.Number,
+})
+  .annotate({ identifier: "Workspace" })
+  .pipe(withStatics((s) => ({ zod: effectZod(s) })))
+export type Info = WorkspaceInfo & { timeUsed: number }
 
 export const ConnectionStatus = Schema.Struct({
   workspaceID: WorkspaceID,
@@ -69,6 +74,7 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
     directory: row.directory,
     extra: row.extra,
     projectID: row.project_id,
+    timeUsed: row.time_used,
   }
 }
 
@@ -150,6 +156,7 @@ export interface Interface {
   readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
   readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
   readonly list: (project: Project.Info) => Effect.Effect<Info[]>
+  readonly syncList: (project: Project.Info) => Effect.Effect<void>
   readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
   readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
   readonly status: () => Effect.Effect<ConnectionStatus[]>
@@ -483,7 +490,19 @@ export const layer = Layer.effect(
       if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
 
       const adapter = getAdapter(space.projectID, space.type)
-      const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
+      const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe(
+        Effect.catch((error) =>
+          Effect.sync(() => {
+            setStatus(space.id, "error")
+            log.warn("workspace target failed", {
+              workspaceID: space.id,
+              error: errorData(error),
+            })
+            return null
+          }),
+        ),
+      )
+      if (!target) return
 
       if (target.type === "local") {
         setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
@@ -523,7 +542,13 @@ export const layer = Layer.effect(
       const id = WorkspaceID.ascending(input.id)
       const adapter = getAdapter(input.projectID, input.type)
       const config = yield* EffectBridge.fromPromise(() =>
-        adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }),
+        adapter.configure({
+          ...input,
+          id,
+          name: Slug.create(),
+          directory: null,
+          extra: input.extra ?? null,
+        }),
       )
 
       const info: Info = {
@@ -534,6 +559,7 @@ export const layer = Layer.effect(
         directory: config.directory ?? null,
         extra: config.extra ?? null,
         projectID: input.projectID,
+        timeUsed: Date.now(),
       }
 
       yield* db((db) => {
@@ -546,6 +572,7 @@ export const layer = Layer.effect(
             directory: info.directory,
             extra: info.extra,
             project_id: info.projectID,
+            time_used: info.timeUsed,
           })
           .run()
       })
@@ -828,6 +855,63 @@ export const layer = Layer.effect(
       )
     })
 
+    const syncList = Effect.fn("Workspace.syncList")(function* (project: Project.Info) {
+      const names = new Set((yield* list(project)).map((workspace) => workspace.name))
+      const discovered = yield* Effect.forEach(
+        registeredAdapters(project.id),
+        ([type, adapter]) =>
+          adapter.list
+            ? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe(
+                Effect.catchCause((error) =>
+                  Effect.sync(() => {
+                    log.warn("workspace adapter list failed", { type, error })
+                    return []
+                  }),
+                ),
+              )
+            : Effect.succeed([]),
+        { concurrency: "unbounded" },
+      ).pipe(Effect.map((items) => items.flat()))
+
+      yield* Effect.forEach(
+        discovered,
+        (item) =>
+          Effect.gen(function* () {
+            if (names.has(item.name)) return
+            names.add(item.name)
+
+            const info: Info = {
+              id: WorkspaceID.ascending(),
+              type: item.type,
+              branch: item.branch,
+              name: item.name,
+              directory: item.directory,
+              extra: item.extra,
+              projectID: item.projectID,
+              timeUsed: Date.now(),
+            }
+
+            yield* db((db) => {
+              db.insert(WorkspaceTable)
+                .values({
+                  id: info.id,
+                  type: info.type,
+                  branch: info.branch,
+                  name: info.name,
+                  directory: info.directory,
+                  extra: info.extra,
+                  project_id: info.projectID,
+                  time_used: info.timeUsed,
+                })
+                .run()
+            })
+
+            yield* startSync(info)
+          }),
+        { concurrency: 1 },
+      )
+    })
+
     const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) {
       const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
       if (!row) return
@@ -916,13 +1000,10 @@ export const layer = Layer.effect(
     })
 
     const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) {
-      // This session table join makes this query only return
-      // workspaces that have sessions
       const rows = yield* db((db) =>
         db
           .selectDistinct({ workspace: WorkspaceTable })
           .from(WorkspaceTable)
-          .innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
           .where(eq(WorkspaceTable.project_id, projectID))
           .all(),
       )
@@ -947,6 +1028,7 @@ export const layer = Layer.effect(
       create,
       sessionWarp,
       list,
+      syncList,
       get,
       remove,
       status,

+ 17 - 0
packages/opencode/src/server/routes/control/workspace.ts

@@ -93,6 +93,23 @@ export const WorkspaceRoutes = lazy(() =>
         return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project))))
       },
     )
+    .post(
+      "/sync-list",
+      describeRoute({
+        summary: "Sync workspace list",
+        description: "Register missing workspaces returned by workspace adapters.",
+        operationId: "experimental.workspace.syncList",
+        responses: {
+          204: {
+            description: "Workspace list synced",
+          },
+        },
+      }),
+      async (c) => {
+        await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.syncList(Instance.project)))
+        return c.body(null, 204)
+      },
+    )
     .get(
       "/status",
       describeRoute({

+ 10 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts

@@ -29,6 +29,7 @@ export class ApiWorkspaceWarpError extends Schema.ErrorClass<ApiWorkspaceWarpErr
 export const WorkspacePaths = {
   adapters: `${root}/adapter`,
   list: root,
+  syncList: `${root}/sync-list`,
   status: `${root}/status`,
   remove: `${root}/:id`,
   warp: `${root}/warp`,
@@ -67,6 +68,15 @@ export const WorkspaceApi = HttpApi.make("workspace")
             description: "Create a workspace for the current project.",
           }),
         ),
+        HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, {
+          success: described(HttpApiSchema.NoContent, "Workspace list synced"),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "experimental.workspace.syncList",
+            summary: "Sync workspace list",
+            description: "Register missing workspaces returned by workspace adapters.",
+          }),
+        ),
         HttpApiEndpoint.get("status", WorkspacePaths.status, {
           success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
         }).annotateMerge(

+ 6 - 1
packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts

@@ -14,7 +14,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
 
     const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () {
       const instance = yield* InstanceState.context
-      return yield* Effect.promise(() => listAdapters(instance.project.id))
+      return yield* Effect.sync(() => listAdapters(instance.project.id))
     })
 
     const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
@@ -32,6 +32,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
         .pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
     })
 
+    const syncList = Effect.fn("WorkspaceHttpApi.syncList")(function* () {
+      yield* workspace.syncList((yield* InstanceState.context).project)
+    })
+
     const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
       const ids = new Set((yield* workspace.list((yield* InstanceState.context).project)).map((item) => item.id))
       return (yield* workspace.status()).filter((item) => ids.has(item.workspaceID))
@@ -73,6 +77,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
       .handle("adapters", adapters)
       .handle("list", list)
       .handle("create", create)
+      .handle("syncList", syncList)
       .handle("status", status)
       .handle("remove", remove)
       .handle("warp", warp)

+ 5 - 0
packages/opencode/src/session/projectors.ts

@@ -5,6 +5,7 @@ import { SyncEvent } from "@/sync"
 import * as Session from "./session"
 import { MessageV2 } from "./message-v2"
 import { SessionTable, MessageTable, PartTable } from "./session.sql"
+import { WorkspaceTable } from "@/control-plane/workspace.sql"
 import { Log } from "@opencode-ai/core/util/log"
 import nextProjectors from "./projectors-next"
 
@@ -69,6 +70,10 @@ export default [
     db.insert(SessionTable)
       .values(Session.toRow(data.info as Session.Info))
       .run()
+
+    if (data.info.workspaceID) {
+      db.update(WorkspaceTable).set({ time_used: Date.now() }).where(eq(WorkspaceTable.id, data.info.workspaceID)).run()
+    }
   }),
 
   SyncEvent.project(Session.Event.Updated, (db, data) => {

+ 35 - 1
packages/opencode/src/worktree/index.ts

@@ -117,6 +117,13 @@ export const ResetFailedError = NamedError.create(
   }),
 )
 
+export const ListFailedError = NamedError.create(
+  "WorktreeListFailedError",
+  z.object({
+    message: z.string(),
+  }),
+)
+
 function slugify(input: string) {
   return input
     .trim()
@@ -149,6 +156,7 @@ export interface Interface {
   readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
   readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
   readonly create: (input?: CreateInput) => Effect.Effect<Info>
+  readonly list: () => Effect.Effect<(Omit<Info, "branch"> & { branch?: string })[]>
   readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
   readonly reset: (input: ResetInput) => Effect.Effect<boolean>
 }
@@ -341,6 +349,32 @@ export const layer: Layer.Layer<
       return undefined
     })
 
+    const list = Effect.fn("Worktree.list")(function* () {
+      const ctx = yield* InstanceState.context
+      if (ctx.project.vcs !== "git") {
+        return []
+      }
+
+      const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
+      if (result.code !== 0) {
+        throw new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
+      }
+
+      const primary = yield* canonical(ctx.worktree)
+      return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
+        Effect.gen(function* () {
+          if (!entry.path) return undefined
+          const directory = yield* canonical(entry.path)
+          if (directory === primary) return undefined
+          return {
+            name: pathSvc.basename(directory),
+            directory,
+            ...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}),
+          }
+        }),
+      ).pipe(Effect.map((items) => items.filter((item) => item !== undefined)))
+    })
+
     function stopFsmonitor(target: string) {
       return fs.exists(target).pipe(
         Effect.orDie,
@@ -579,7 +613,7 @@ export const layer: Layer.Layer<
       return true
     })
 
-    return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
+    return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset })
   }),
 )
 

+ 7 - 42
packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts

@@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
 import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create"
 
 describe("recentConnectedWorkspaces", () => {
-  test("returns unique connected workspaces after filtering missing and inactive entries", () => {
+  test("returns connected workspaces sorted by time used", () => {
     const workspaces = [
-      { id: "wrk_a", name: "alpha" },
-      { id: "wrk_b", name: "beta" },
-      { id: "wrk_c", name: "gamma" },
-      { id: "wrk_d", name: "delta" },
-      { id: "wrk_e", name: "epsilon" },
+      { id: "wrk_a", name: "alpha", timeUsed: 700 },
+      { id: "wrk_b", name: "beta", timeUsed: 800 },
+      { id: "wrk_c", name: "gamma", timeUsed: 400 },
+      { id: "wrk_d", name: "delta", timeUsed: 300 },
+      { id: "wrk_e", name: "epsilon", timeUsed: 200 },
     ]
     const status = {
       wrk_a: "connected",
@@ -19,45 +19,10 @@ describe("recentConnectedWorkspaces", () => {
     } as const
 
     const { recent } = recentConnectedWorkspaces({
-      sessions: [
-        { time: { updated: 900 } },
-        { workspaceID: "wrk_b", time: { updated: 800 } },
-        { workspaceID: "wrk_a", time: { updated: 700 } },
-        { workspaceID: "wrk_a", time: { updated: 600 } },
-        { workspaceID: "wrk_missing", time: { updated: 500 } },
-        { workspaceID: "wrk_c", time: { updated: 400 } },
-        { workspaceID: "wrk_d", time: { updated: 300 } },
-        { workspaceID: "wrk_e", time: { updated: 200 } },
-      ],
-      get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
+      workspaces,
       status: (workspaceID) => status[workspaceID as keyof typeof status],
     })
 
     expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"])
   })
-
-  test("omits the active workspace before limiting recent workspaces", () => {
-    const workspaces = [
-      { id: "wrk_a", name: "alpha" },
-      { id: "wrk_b", name: "beta" },
-      { id: "wrk_c", name: "gamma" },
-      { id: "wrk_d", name: "delta" },
-    ]
-
-    const { recent, hasMore } = recentConnectedWorkspaces({
-      sessions: [
-        { workspaceID: "wrk_a", time: { updated: 400 } },
-        { workspaceID: "wrk_b", time: { updated: 300 } },
-        { workspaceID: "wrk_c", time: { updated: 200 } },
-        { workspaceID: "wrk_d", time: { updated: 100 } },
-      ],
-      get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID),
-      status: () => "connected",
-      limit: 3,
-      omitWorkspaceID: "wrk_a",
-    })
-
-    expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"])
-    expect(hasMore).toBe(false)
-  })
 })

+ 193 - 61
packages/opencode/test/control-plane/workspace.test.ts

@@ -28,7 +28,7 @@ import { registerAdapter } from "../../src/control-plane/adapters"
 import { WorkspaceID } from "../../src/control-plane/schema"
 import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
 import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types"
-import * as WorkspaceOld from "../../src/control-plane/workspace"
+import * as Workspace from "../../src/control-plane/workspace"
 import { AppRuntime } from "@/effect/app-runtime"
 import { InstanceStore } from "@/project/instance-store"
 import { InstanceBootstrap } from "@/project/bootstrap"
@@ -37,7 +37,7 @@ void Log.init({ print: false })
 
 const testServerLayer = Layer.mergeAll(
   NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
-  WorkspaceOld.defaultLayer.pipe(
+  Workspace.defaultLayer.pipe(
     Layer.provide(InstanceStore.defaultLayer),
     Layer.provide(InstanceBootstrap.defaultLayer),
   ),
@@ -64,6 +64,7 @@ type RecordedAdapter = {
   calls: {
     configure: WorkspaceInfo[]
     create: RecordedCreate[]
+    list: number
     remove: WorkspaceInfo[]
     target: WorkspaceInfo[]
   }
@@ -125,23 +126,25 @@ async function initGitRepo(dir: string) {
   await $`git commit -m "base"`.cwd(dir).quiet()
 }
 
-const runWorkspace = <A, E>(effect: Effect.Effect<A, E, WorkspaceOld.Service>) => AppRuntime.runPromise(effect)
-const createWorkspace = (input: WorkspaceOld.CreateInput) =>
-  runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input)))
-const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) =>
-  runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input)))
-const listWorkspaces = (project: Parameters<WorkspaceOld.Interface["list"]>[0]) =>
-  runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project)))
-const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id)))
-const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id)))
-const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status()))
+const runWorkspace = <A, E>(effect: Effect.Effect<A, E, Workspace.Service>) => AppRuntime.runPromise(effect)
+const createWorkspace = (input: Workspace.CreateInput) =>
+  runWorkspace(Workspace.Service.use((workspace) => workspace.create(input)))
+const warpWorkspaceSession = (input: Workspace.SessionWarpInput) =>
+  runWorkspace(Workspace.Service.use((workspace) => workspace.sessionWarp(input)))
+const listWorkspaces = (project: Parameters<Workspace.Interface["list"]>[0]) =>
+  runWorkspace(Workspace.Service.use((workspace) => workspace.list(project)))
+const syncListWorkspaces = (project: Parameters<Workspace.Interface["syncList"]>[0]) =>
+  runWorkspace(Workspace.Service.use((workspace) => workspace.syncList(project)))
+const getWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.get(id)))
+const removeWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.remove(id)))
+const workspaceStatus = () => runWorkspace(Workspace.Service.use((workspace) => workspace.status()))
 const isWorkspaceSyncing = (id: WorkspaceID) =>
-  runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id)))
+  runWorkspace(Workspace.Service.use((workspace) => workspace.isSyncing(id)))
 const startWorkspaceSyncing = (projectID: ProjectID) => {
-  void runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)))
+  void runWorkspace(Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID)))
 }
 const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) =>
-  runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
+  runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)))
 
 function captureGlobalEvents() {
   const events: GlobalEvent[] = []
@@ -187,11 +190,13 @@ function recordedAdapter(input: {
   target: (info: WorkspaceInfo) => Target | Promise<Target>
   configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo>
   create?: (info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo) => Promise<void>
+  list?: () => Omit<WorkspaceInfo, "id">[] | Promise<Omit<WorkspaceInfo, "id">[]>
   remove?: (info: WorkspaceInfo) => Promise<void>
 }): RecordedAdapter {
   const calls: RecordedAdapter["calls"] = {
     configure: [],
     create: [],
+    list: 0,
     remove: [],
     target: [],
   }
@@ -213,6 +218,14 @@ function recordedAdapter(input: {
         })
         await input.create?.(info, env, from)
       },
+      ...(input.list
+        ? {
+            async list() {
+              calls.list += 1
+              return input.list?.() ?? []
+            },
+          }
+        : {}),
       async remove(info) {
         calls.remove.push(structuredClone(info))
         await input.remove?.(info)
@@ -272,7 +285,7 @@ function serverUrl() {
   })
 }
 
-function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<WorkspaceInfo>): WorkspaceInfo {
+function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<Workspace.Info>): Workspace.Info {
   return {
     id: input?.id ?? WorkspaceID.ascending(),
     type,
@@ -281,10 +294,11 @@ function workspaceInfo(projectID: ProjectID, type: string, input?: Partial<Works
     directory: input?.directory ?? null,
     extra: input?.extra ?? null,
     projectID,
+    timeUsed: input?.timeUsed ?? Date.now(),
   }
 }
 
-function insertWorkspace(info: WorkspaceInfo) {
+function insertWorkspace(info: Workspace.Info) {
   Database.use((db) =>
     db
       .insert(WorkspaceTable)
@@ -296,6 +310,7 @@ function insertWorkspace(info: WorkspaceInfo) {
         directory: info.directory,
         extra: info.extra,
         project_id: info.projectID,
+        time_used: info.timeUsed,
       })
       .run(),
   )
@@ -348,11 +363,11 @@ function sessionUpdatedType() {
   return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
 }
 
-describe("workspace-old schemas and exports", () => {
+describe("workspace schemas and exports", () => {
   test("keeps the historical event type names", () => {
-    expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready")
-    expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed")
-    expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
+    expect(Workspace.Event.Ready.type).toBe("workspace.ready")
+    expect(Workspace.Event.Failed.type).toBe("workspace.failed")
+    expect(Workspace.Event.Status.type).toBe("workspace.status")
   })
 
   test("validates create input with workspace id, project id, branch, type, and extra", () => {
@@ -364,13 +379,13 @@ describe("workspace-old schemas and exports", () => {
       extra: { nested: true },
     }
 
-    expect(WorkspaceOld.CreateInput.zod.parse(input)).toEqual(input)
-    expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
-    expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
+    expect(Workspace.CreateInput.zod.parse(input)).toEqual(input)
+    expect(() => Workspace.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
+    expect(() => Workspace.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
   })
 })
 
-describe("workspace-old CRUD", () => {
+describe("workspace CRUD", () => {
   test("get returns undefined for a missing workspace", async () => {
     await withInstance(async () => {
       expect(await getWorkspace(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined()
@@ -447,13 +462,22 @@ describe("workspace-old CRUD", () => {
         directory: targetDir,
         extra: { configured: true },
         projectID: Instance.project.id,
+        timeUsed: info.timeUsed,
       })
       expect(await getWorkspace(workspaceID)).toEqual(info)
       expect(await listWorkspaces(Instance.project)).toEqual([info])
       expect(recorded.calls.configure).toHaveLength(1)
       expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null })
       expect(recorded.calls.create).toHaveLength(1)
-      expect(recorded.calls.create[0].info).toEqual(info)
+      expect(recorded.calls.create[0].info).toEqual({
+        id: workspaceID,
+        type,
+        branch: "configured-branch",
+        name: "Configured Name",
+        directory: targetDir,
+        extra: { configured: true },
+        projectID: Instance.project.id,
+      })
       expect(JSON.parse(recorded.calls.create[0].env.OPENCODE_AUTH_CONTENT ?? "{}")).toEqual({
         test: { type: "api", key: "secret" },
       })
@@ -532,6 +556,120 @@ describe("workspace-old CRUD", () => {
     })
   })
 
+  test("syncList registers adapter-listed workspaces that are missing by name", async () => {
+    await withInstance(async (dir) => {
+      const type = unique("list-sync")
+      const existing = workspaceInfo(Instance.project.id, type, {
+        id: WorkspaceID.ascending("wrk_list_sync_existing"),
+        name: "existing",
+        directory: path.join(dir, "existing"),
+      })
+      insertWorkspace(existing)
+
+      const discovered = {
+        type,
+        name: "discovered",
+        branch: "feature/discovered",
+        directory: path.join(dir, "discovered"),
+        extra: { source: "adapter" },
+        projectID: Instance.project.id,
+      }
+      const recorded = recordedAdapter({
+        list() {
+          return [
+            {
+              type,
+              name: existing.name,
+              branch: "ignored",
+              directory: path.join(dir, "ignored"),
+              extra: null,
+              projectID: Instance.project.id,
+            },
+            discovered,
+          ]
+        },
+        target(info) {
+          return { type: "local", directory: info.directory ?? dir }
+        },
+      })
+      registerAdapter(Instance.project.id, type, recorded.adapter)
+
+      await syncListWorkspaces(Instance.project)
+      const synced = (await listWorkspaces(Instance.project)).filter((item) => item.name === discovered.name)
+
+      expect(synced).toHaveLength(1)
+      expect(synced[0]).toMatchObject(discovered)
+      expect(synced[0]?.id).toStartWith("wrk_")
+      expect(await listWorkspaces(Instance.project)).toEqual(expect.arrayContaining([existing, synced[0]]))
+      expect(recorded.calls.list).toBe(1)
+      expect(recorded.calls.configure).toHaveLength(0)
+      expect(recorded.calls.create).toHaveLength(0)
+      expect(recorded.calls.target).toHaveLength(1)
+    })
+  })
+
+  test("syncList calls every registered adapter with a list method", async () => {
+    await withInstance(async (dir) => {
+      const typeA = unique("list-sync-a")
+      const typeB = unique("list-sync-b")
+      const adapterA = recordedAdapter({
+        list() {
+          return [
+            {
+              type: typeA,
+              name: "adapter-a",
+              branch: null,
+              directory: path.join(dir, "adapter-a"),
+              extra: null,
+              projectID: Instance.project.id,
+            },
+          ]
+        },
+        target(info) {
+          return { type: "local", directory: info.directory ?? dir }
+        },
+      })
+      const adapterB = recordedAdapter({
+        list() {
+          return [
+            {
+              type: typeB,
+              name: "adapter-b",
+              branch: null,
+              directory: path.join(dir, "adapter-b"),
+              extra: null,
+              projectID: Instance.project.id,
+            },
+          ]
+        },
+        target(info) {
+          return { type: "local", directory: info.directory ?? dir }
+        },
+      })
+      const noList = recordedAdapter({
+        target() {
+          return { type: "local", directory: dir }
+        },
+      })
+      registerAdapter(Instance.project.id, typeA, adapterA.adapter)
+      registerAdapter(Instance.project.id, typeB, adapterB.adapter)
+      registerAdapter(Instance.project.id, unique("list-sync-none"), noList.adapter)
+
+      await syncListWorkspaces(Instance.project)
+      const synced = await listWorkspaces(Instance.project)
+
+      expect(
+        synced
+          .filter((item) => item.type === typeA || item.type === typeB)
+          .map((item) => item.name)
+          .toSorted(),
+      ).toEqual(["adapter-a", "adapter-b"])
+      expect(adapterA.calls.list).toBe(1)
+      expect(adapterB.calls.list).toBe(1)
+      expect(noList.calls.list).toBe(0)
+    })
+  })
+
   it.live("remote create connects to routed event and history endpoints", () => {
     const calls: FetchCall[] = []
     return Effect.gen(function* () {
@@ -557,7 +695,7 @@ describe("workspace-old CRUD", () => {
       yield* provideTmpdirInstance(
         (dir) =>
           Effect.gen(function* () {
-            const workspace = yield* WorkspaceOld.Service
+            const workspace = yield* Workspace.Service
             const type = unique("remote-create")
             const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir })
             registerAdapter(Instance.project.id, type, recorded.adapter)
@@ -754,7 +892,7 @@ describe("workspace-old CRUD", () => {
       yield* provideTmpdirInstance(
         () =>
           Effect.gen(function* () {
-            const workspace = yield* WorkspaceOld.Service
+            const workspace = yield* Workspace.Service
             const sessionSvc = yield* SessionNs.Service
             const previousType = unique("warp-remote-source")
             const targetType = unique("warp-remote-target")
@@ -805,7 +943,7 @@ describe("workspace-old CRUD", () => {
   })
 })
 
-describe("workspace-old sync state", () => {
+describe("workspace sync state", () => {
   test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => {
     await withInstance(async (dir) => {
       Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false
@@ -823,35 +961,29 @@ describe("workspace-old sync state", () => {
     })
   })
 
-  test("startWorkspaceSyncing starts only workspaces with sessions", async () => {
+  test("startWorkspaceSyncing starts all workspaces", async () => {
     await withInstance(async (dir) => {
-      const withSessionType = unique("with-session")
-      const withoutSessionType = unique("without-session")
-      const withSession = workspaceInfo(Instance.project.id, withSessionType)
-      const withoutSession = workspaceInfo(Instance.project.id, withoutSessionType)
-      const withSessionDir = path.join(dir, "with-session")
-      const withoutSessionDir = path.join(dir, "without-session")
-      await fs.mkdir(withSessionDir, { recursive: true })
-      await fs.mkdir(withoutSessionDir, { recursive: true })
-      insertWorkspace(withSession)
-      insertWorkspace(withoutSession)
-      registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter)
-      registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter)
-      attachSessionToWorkspace(
-        (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
-        withSession.id,
-      )
+      const firstType = unique("first")
+      const secondType = unique("second")
+      const first = workspaceInfo(Instance.project.id, firstType)
+      const second = workspaceInfo(Instance.project.id, secondType)
+      await fs.mkdir(path.join(dir, "first"), { recursive: true })
+      await fs.mkdir(path.join(dir, "second"), { recursive: true })
+      insertWorkspace(first)
+      insertWorkspace(second)
+      registerAdapter(Instance.project.id, firstType, localAdapter(path.join(dir, "first")).adapter)
+      registerAdapter(Instance.project.id, secondType, localAdapter(path.join(dir, "second")).adapter)
 
       startWorkspaceSyncing(Instance.project.id)
 
       await eventually(() =>
-        workspaceStatus().then((status) =>
-          expect(status.find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"),
-        ),
+        workspaceStatus().then((status) => {
+          expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected")
+          expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected")
+        }),
       )
-      expect((await workspaceStatus()).find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined()
-      await removeWorkspace(withSession.id)
-      await removeWorkspace(withoutSession.id)
+      await removeWorkspace(first.id)
+      await removeWorkspace(second.id)
     })
   })
 
@@ -907,7 +1039,7 @@ describe("workspace-old sync state", () => {
         )
         expect(
           captured.events.filter(
-            (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
+            (event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type,
           ),
         ).toHaveLength(1)
         await removeWorkspace(info.id)
@@ -941,7 +1073,7 @@ describe("workspace-old sync state", () => {
       yield* provideTmpdirInstance(
         () =>
           Effect.gen(function* () {
-            const workspace = yield* WorkspaceOld.Service
+            const workspace = yield* Workspace.Service
             const sessionSvc = yield* SessionNs.Service
             const captured = captureGlobalEvents()
             try {
@@ -965,7 +1097,7 @@ describe("workspace-old sync state", () => {
               expect(
                 captured.events
                   .filter(
-                    (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type,
+                    (event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type,
                   )
                   .map((event) => event.payload.properties.status),
               ).toEqual(["disconnected", "connecting", "connected"])
@@ -998,7 +1130,7 @@ describe("workspace-old sync state", () => {
       yield* provideTmpdirInstance(
         () =>
           Effect.gen(function* () {
-            const workspace = yield* WorkspaceOld.Service
+            const workspace = yield* Workspace.Service
             const sessionSvc = yield* SessionNs.Service
             const type = unique("remote-connect-fail")
             const info = workspaceInfo(Instance.project.id, type)
@@ -1038,7 +1170,7 @@ describe("workspace-old sync state", () => {
       yield* provideTmpdirInstance(
         () =>
           Effect.gen(function* () {
-            const workspace = yield* WorkspaceOld.Service
+            const workspace = yield* Workspace.Service
             const sessionSvc = yield* SessionNs.Service
             const type = unique("remote-history-fail")
             const info = workspaceInfo(Instance.project.id, type)
@@ -1093,7 +1225,7 @@ describe("workspace-old sync state", () => {
       yield* provideTmpdirInstance(
         () =>
           Effect.gen(function* () {
-            const workspace = yield* WorkspaceOld.Service
+            const workspace = yield* Workspace.Service
             const sessionSvc = yield* SessionNs.Service
             const captured = captureGlobalEvents()
             try {
@@ -1160,7 +1292,7 @@ describe("workspace-old sync state", () => {
       yield* provideTmpdirInstance(
         () =>
           Effect.gen(function* () {
-            const workspace = yield* WorkspaceOld.Service
+            const workspace = yield* Workspace.Service
             const sessionSvc = yield* SessionNs.Service
             const captured = captureGlobalEvents()
             try {
@@ -1241,7 +1373,7 @@ describe("workspace-old sync state", () => {
       yield* provideTmpdirInstance(
         () =>
           Effect.gen(function* () {
-            const workspace = yield* WorkspaceOld.Service
+            const workspace = yield* Workspace.Service
             const sessionSvc = yield* SessionNs.Service
             const captured = captureGlobalEvents()
             try {
@@ -1280,7 +1412,7 @@ describe("workspace-old sync state", () => {
   })
 })
 
-describe("workspace-old waitForSync", () => {
+describe("workspace waitForSync", () => {
   test("returns immediately for an empty fence", async () => {
     await withInstance(async () => {
       await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined()

+ 54 - 0
packages/opencode/test/server/httpapi-workspace.test.ts

@@ -64,6 +64,36 @@ function localAdapter(directory: string): WorkspaceAdapter {
   }
 }
 
+function listedAdapter(directory: string, type: string): WorkspaceAdapter {
+  return {
+    name: "Listed Test",
+    description: "List a local test workspace",
+    configure(info) {
+      return { ...info, name: "unused", directory }
+    },
+    async create() {},
+    async remove() {},
+    list() {
+      return [
+        {
+          type,
+          name: "listed-test",
+          branch: "listed/main",
+          directory,
+          extra: { listed: true },
+          projectID: Instance.project.id,
+        },
+      ]
+    },
+    target() {
+      return {
+        type: "local" as const,
+        directory,
+      }
+    },
+  }
+}
+
 function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
   return {
     name: "Remote Test",
@@ -196,6 +226,30 @@ describe("workspace HttpApi", () => {
     }),
   )
 
+  it.live("serves list sync endpoint", () =>
+    Effect.gen(function* () {
+      Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
+      const dir = yield* tmpdirScoped({ git: true })
+      const project = yield* Project.use.fromDirectory(dir)
+      const type = `listed-${Math.random().toString(36).slice(2)}`
+      registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type))
+
+      const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" })
+
+      expect(response.status).toBe(204)
+      const listed = yield* request(WorkspacePaths.list, dir)
+      expect(yield* Effect.promise(() => listed.json())).toMatchObject([
+        {
+          type,
+          name: "listed-test",
+          branch: "listed/main",
+          directory: path.join(dir, ".listed"),
+          extra: { listed: true },
+        },
+      ])
+    }),
+  )
+
   it.live("creates workspace with the TUI payload shape", () =>
     Effect.gen(function* () {
       Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true

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

@@ -36,6 +36,7 @@ import type {
   ExperimentalWorkspaceRemoveErrors,
   ExperimentalWorkspaceRemoveResponses,
   ExperimentalWorkspaceStatusResponses,
+  ExperimentalWorkspaceSyncListResponses,
   ExperimentalWorkspaceWarpErrors,
   ExperimentalWorkspaceWarpResponses,
   FileListResponses,
@@ -949,6 +950,36 @@ export class Workspace extends HeyApiClient {
     })
   }
 
+  /**
+   * Sync workspace list
+   *
+   * Register missing workspaces returned by workspace adapters.
+   */
+  public syncList<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).post<ExperimentalWorkspaceSyncListResponses, unknown, ThrowOnError>({
+      url: "/experimental/workspace/sync-list",
+      ...options,
+      ...params,
+    })
+  }
+
   /**
    * Workspace status
    *

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

@@ -1755,6 +1755,7 @@ export type Workspace = {
   directory: string | null
   extra: unknown | null
   projectID: string
+  timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
 }
 
 export type WorkspaceWarpError = {
@@ -6706,6 +6707,26 @@ export type ExperimentalWorkspaceCreateResponses = {
 export type ExperimentalWorkspaceCreateResponse =
   ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses]
 
+export type ExperimentalWorkspaceSyncListData = {
+  body?: never
+  path?: never
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/experimental/workspace/sync-list"
+}
+
+export type ExperimentalWorkspaceSyncListResponses = {
+  /**
+   * Workspace list synced
+   */
+  204: void
+}
+
+export type ExperimentalWorkspaceSyncListResponse =
+  ExperimentalWorkspaceSyncListResponses[keyof ExperimentalWorkspaceSyncListResponses]
+
 export type ExperimentalWorkspaceStatusData = {
   body?: never
   path?: never

+ 61 - 1
packages/sdk/openapi.json

@@ -8417,6 +8417,43 @@
         ]
       }
     },
+    "/experimental/workspace/sync-list": {
+      "post": {
+        "tags": ["workspace"],
+        "operationId": "experimental.workspace.syncList",
+        "parameters": [
+          {
+            "name": "directory",
+            "in": "query",
+            "required": false,
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "workspace",
+            "in": "query",
+            "required": false,
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "204": {
+            "description": "Workspace list synced"
+          }
+        },
+        "description": "Register missing workspaces returned by workspace adapters.",
+        "summary": "Sync workspace list",
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.syncList({\n  ...\n})"
+          }
+        ]
+      }
+    },
     "/experimental/workspace/status": {
       "get": {
         "tags": ["workspace"],
@@ -13672,9 +13709,32 @@
           },
           "projectID": {
             "type": "string"
+          },
+          "timeUsed": {
+            "anyOf": [
+              {
+                "type": "number"
+              },
+              {
+                "type": "string",
+                "enum": ["NaN"]
+              },
+              {
+                "type": "string",
+                "enum": ["Infinity"]
+              },
+              {
+                "type": "string",
+                "enum": ["-Infinity"]
+              },
+              {
+                "type": "string",
+                "enum": ["Infinity", "-Infinity", "NaN"]
+              }
+            ]
           }
         },
-        "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"],
+        "required": ["id", "type", "name", "branch", "directory", "extra", "projectID", "timeUsed"],
         "additionalProperties": false
       },
       "WorkspaceWarpError": {