Explorar o código

feat(core): session warping (#25768)

James Long hai 4 meses
pai
achega
22a4a9df8b
Modificáronse 25 ficheiros con 3853 adicións e 1001 borrados
  1. 1 0
      packages/opencode/migration/20260504145000_add_sync_owner/migration.sql
  2. 1429 0
      packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json
  3. 2 2
      packages/opencode/script/httpapi-exercise.ts
  4. 51 69
      packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx
  5. 152 126
      packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx
  6. 266 91
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  7. 19 0
      packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx
  8. 17 12
      packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
  9. 28 25
      packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
  10. 153 128
      packages/opencode/src/control-plane/workspace.ts
  11. 23 51
      packages/opencode/src/server/routes/control/workspace.ts
  12. 16 0
      packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts
  13. 12 17
      packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts
  14. 22 2
      packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts
  15. 6 9
      packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts
  16. 1 1
      packages/opencode/src/server/routes/instance/index.ts
  17. 47 0
      packages/opencode/src/server/routes/instance/sync.ts
  18. 1 0
      packages/opencode/src/sync/event.sql.ts
  19. 25 9
      packages/opencode/src/sync/index.ts
  20. 149 357
      packages/opencode/test/control-plane/workspace.test.ts
  21. 7 12
      packages/opencode/test/server/httpapi-workspace.test.ts
  22. 72 1
      packages/opencode/test/sync/index.test.ts
  23. 196 70
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  24. 546 15
      packages/sdk/js/src/v2/gen/types.gen.ts
  25. 612 4
      packages/sdk/openapi.json

+ 1 - 0
packages/opencode/migration/20260504145000_add_sync_owner/migration.sql

@@ -0,0 +1 @@
+ALTER TABLE `event_sequence` ADD `owner_id` text;

+ 1429 - 0
packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json

@@ -0,0 +1,1429 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "id": "27114226-085b-421a-9a40-29b88747e29a",
+  "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"],
+  "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_entry",
+      "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_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_entry"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_entry"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "session_entry"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_entry"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_entry"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "session_entry"
+    },
+    {
+      "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": "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_entry_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_entry"
+    },
+    {
+      "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_entry_pk",
+      "table": "session_entry",
+      "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_entry_session_idx",
+      "entityType": "indexes",
+      "table": "session_entry"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "type",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_entry_session_type_idx",
+      "entityType": "indexes",
+      "table": "session_entry"
+    },
+    {
+      "columns": [
+        {
+          "value": "time_created",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_entry_time_created_idx",
+      "entityType": "indexes",
+      "table": "session_entry"
+    },
+    {
+      "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": []
+}

+ 2 - 2
packages/opencode/script/httpapi-exercise.ts

@@ -776,9 +776,9 @@ const scenarios: Scenario[] = [
     }))
     .status(200),
   http
-    .post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore")
+    .post("/experimental/workspace/warp", "experimental.workspace.warp")
     .at((ctx) => ({
-      path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }),
+      path: "/experimental/workspace/warp",
       headers: ctx.headers(),
       body: {},
     }))

+ 51 - 69
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog"
 import { DialogSelect } from "@tui/ui/dialog-select"
 import { useRoute } from "@tui/context/route"
 import { useSync } from "@tui/context/sync"
-import { createMemo, createResource, createSignal, onMount } from "solid-js"
+import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
 import { Locale } from "@/util/locale"
 import { useProject } from "@tui/context/project"
 import { useKeybind } from "../context/keybind"
@@ -10,15 +10,13 @@ import { useTheme } from "../context/theme"
 import { useSDK } from "../context/sdk"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { DialogSessionRename } from "./dialog-session-rename"
-import { Keybind } from "@/util/keybind"
 import { createDebouncedSignal } from "../util/signal"
 import { useToast } from "../ui/toast"
-import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
+import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
 import { Spinner } from "./spinner"
 import { errorMessage } from "@/util/error"
 import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
-
-type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
+import { WorkspaceLabel } from "./workspace-label"
 
 export function DialogSessionList() {
   const dialog = useDialog()
@@ -44,26 +42,39 @@ export function DialogSessionList() {
   const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
   const sessions = createMemo(() => searchResults() ?? sync.data.session)
 
-  function createWorkspace() {
-    dialog.replace(() => (
-      <DialogWorkspaceCreate
-        onSelect={(workspaceID) =>
-          openWorkspaceSession({
-            dialog,
-            route,
-            sdk,
-            sync,
-            toast,
-            workspaceID,
-          })
-        }
-      />
-    ))
-  }
-
   function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
     const workspace = project.workspace.get(session.workspaceID!)
     const list = () => dialog.replace(() => <DialogSessionList />)
+    const warp = async (selection: WorkspaceSelection) => {
+      const workspaceID = await (async () => {
+        if (selection.type === "none") return null
+        if (selection.type === "existing") return selection.workspaceID
+        const result = await sdk.client.experimental.workspace
+          .create({ type: selection.workspaceType, branch: null })
+          .catch(() => undefined)
+        const workspace = result?.data
+        if (!workspace) {
+          toast.show({
+            message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
+            variant: "error",
+          })
+          return
+        }
+        await project.workspace.sync()
+        return workspace.id
+      })()
+      if (workspaceID === undefined) return
+      await warpWorkspaceSession({
+        dialog,
+        sdk,
+        sync,
+        project,
+        toast,
+        workspaceID,
+        sessionID: session.id,
+        done: list,
+      })
+    }
     dialog.replace(() => (
       <DialogSessionDeleteFailed
         session={session.title}
@@ -90,22 +101,15 @@ export function DialogSessionList() {
           return true
         }}
         onRestore={() => {
-          dialog.replace(() => (
-            <DialogWorkspaceCreate
-              onSelect={(workspaceID) =>
-                restoreWorkspaceSession({
-                  dialog,
-                  sdk,
-                  sync,
-                  project,
-                  toast,
-                  workspaceID,
-                  sessionID: session.id,
-                  done: list,
-                })
-              }
-            />
-          ))
+          void openWorkspaceSelect({
+            dialog,
+            sdk,
+            sync,
+            toast,
+            onSelect: (selection) => {
+              void warp(selection)
+            },
+          })
           return false
         }}
       />
@@ -124,30 +128,17 @@ export function DialogSessionList() {
       .map((x) => {
         const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
 
-        let workspaceStatus: WorkspaceStatus | null = null
-        if (x.workspaceID) {
-          workspaceStatus = project.workspace.status(x.workspaceID) || "error"
-        }
-
-        let footer = ""
+        let footer: JSX.Element | string = ""
         if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
           if (x.workspaceID) {
-            let desc = "unknown"
-            if (workspace) {
-              desc = `${workspace.type}: ${workspace.name}`
-            }
-
-            footer = (
-              <>
-                {desc}{" "}
-                <span
-                  style={{
-                    fg: workspaceStatus === "connected" ? theme.success : theme.error,
-                  }}
-                >
-                  ●
-                </span>
-              </>
+            footer = workspace ? (
+              <WorkspaceLabel
+                type={workspace.type}
+                name={workspace.name}
+                status={project.workspace.status(x.workspaceID) ?? "error"}
+              />
+            ) : (
+              <WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
             )
           }
         } else {
@@ -250,15 +241,6 @@ export function DialogSessionList() {
             dialog.replace(() => <DialogSessionRename session={option.value} />)
           },
         },
-        {
-          keybind: Keybind.parse("ctrl+w")[0],
-          title: "new workspace",
-          side: "right",
-          disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
-          onTrigger: () => {
-            createWorkspace()
-          },
-        },
       ]}
     />
   )

+ 152 - 126
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx

@@ -1,11 +1,9 @@
-import { createOpencodeClient } from "@opencode-ai/sdk/v2"
+import type { Workspace } from "@opencode-ai/sdk/v2"
 import { useDialog } from "@tui/ui/dialog"
-import { DialogSelect } from "@tui/ui/dialog-select"
-import { useRoute } from "@tui/context/route"
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
 import { useSync } from "@tui/context/sync"
 import { useProject } from "@tui/context/project"
 import { createMemo, createSignal, onMount } from "solid-js"
-import { setTimeout as sleep } from "node:timers/promises"
 import { errorMessage } from "@/util/error"
 import { useSDK } from "../context/sdk"
 import { useToast } from "../ui/toast"
@@ -16,184 +14,212 @@ type Adapter = {
   description: string
 }
 
-function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
-  return createOpencodeClient({
-    baseUrl: sdk.url,
-    fetch: sdk.fetch,
-    directory: sync.path.directory || sdk.directory,
-    experimental_workspaceID: workspaceID,
+export type WorkspaceSelection =
+  | {
+      type: "none"
+    }
+  | {
+      type: "new"
+      workspaceType: string
+      workspaceName: string
+    }
+  | {
+      type: "existing"
+      workspaceID: string
+      workspaceType: string
+      workspaceName: string
+    }
+
+type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
+type ExistingWorkspaceSelectValue = { workspace: Workspace }
+
+async function loadWorkspaceAdapters(input: {
+  sdk: ReturnType<typeof useSDK>
+  sync: ReturnType<typeof useSync>
+  toast: ReturnType<typeof useToast>
+}) {
+  const dir = input.sync.path.directory || input.sdk.directory
+  const url = new URL("/experimental/workspace/adapter", input.sdk.url)
+  if (dir) url.searchParams.set("directory", dir)
+  const res = await input.sdk
+    .fetch(url)
+    .then((x) => x.json() as Promise<Adapter[]>)
+    .catch(() => undefined)
+  if (res) return res
+  input.toast.show({
+    message: "Failed to load workspace adapters",
+    variant: "error",
   })
 }
 
-export async function openWorkspaceSession(input: {
+export async function openWorkspaceSelect(input: {
   dialog: ReturnType<typeof useDialog>
-  route: ReturnType<typeof useRoute>
   sdk: ReturnType<typeof useSDK>
   sync: ReturnType<typeof useSync>
   toast: ReturnType<typeof useToast>
-  workspaceID: string
+  onSelect: (selection: WorkspaceSelection) => Promise<void> | void
 }) {
-  const client = scoped(input.sdk, input.sync, input.workspaceID)
-
-  while (true) {
-    const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined)
-    if (!result) {
-      input.toast.show({
-        message: "Failed to create workspace session",
-        variant: "error",
-      })
-      return
-    }
-    if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
-      await sleep(1000)
-      continue
-    }
-    if (!result.data) {
-      input.toast.show({
-        message: "Failed to create workspace session",
-        variant: "error",
-      })
-      return
-    }
-
-    input.route.navigate({
-      type: "session",
-      sessionID: result.data.id,
-    })
-    input.dialog.clear()
-    return
-  }
+  input.dialog.clear()
+  const adapters = await loadWorkspaceAdapters(input)
+  if (!adapters) return
+  input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
 }
 
-export async function restoreWorkspaceSession(input: {
+export async function warpWorkspaceSession(input: {
   dialog: ReturnType<typeof useDialog>
   sdk: ReturnType<typeof useSDK>
   sync: ReturnType<typeof useSync>
   project: ReturnType<typeof useProject>
   toast: ReturnType<typeof useToast>
-  workspaceID: string
+  workspaceID: string | null
   sessionID: string
   done?: () => void
-}) {
+}): Promise<boolean> {
   const result = await input.sdk.client.experimental.workspace
-    .sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
+    .warp({
+      id: input.workspaceID,
+      sessionID: input.sessionID,
+    })
     .catch(() => undefined)
   if (!result?.data) {
     input.toast.show({
-      message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
+      message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`,
       variant: "error",
     })
-    return
+    return false
   }
 
   input.project.workspace.set(input.workspaceID)
 
   await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
 
-  await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)])
+  await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
 
-  input.toast.show({
-    message: "Session restored into the new workspace",
-    variant: "success",
-  })
   input.done?.()
-  if (input.done) return
+  if (input.done) return true
   input.dialog.clear()
+  return true
 }
 
-export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
+export function DialogWorkspaceSelect(props: {
+  adapters?: Adapter[]
+  onSelect: (selection: WorkspaceSelection) => Promise<void> | void
+}) {
   const dialog = useDialog()
-  const sync = useSync()
   const project = useProject()
+  const sync = useSync()
   const sdk = useSDK()
   const toast = useToast()
-  const [creating, setCreating] = createSignal<string>()
-  const [adapters, setAdapters] = createSignal<Adapter[]>()
+  const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
 
   onMount(() => {
     dialog.setSize("medium")
     void (async () => {
-      const dir = sync.path.directory || sdk.directory
-      const url = new URL("/experimental/workspace/adapter", sdk.url)
-      if (dir) url.searchParams.set("directory", dir)
-      const res = await sdk
-        .fetch(url)
-        .then((x) => x.json() as Promise<Adapter[]>)
-        .catch(() => undefined)
-      if (!res) {
-        toast.show({
-          message: "Failed to load workspace adapters",
-          variant: "error",
-        })
-        return
-      }
+      if (adapters()) return
+      const res = await loadWorkspaceAdapters({ sdk, sync, toast })
+      if (!res) return
       setAdapters(res)
     })()
   })
 
-  const options = createMemo(() => {
-    const type = creating()
-    if (type) {
-      return [
-        {
-          title: `Creating ${type} workspace...`,
-          value: "creating" as const,
-          description: "This can take a while for remote environments",
-        },
-      ]
-    }
+  const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
     const list = adapters()
-    if (!list) {
-      return [
-        {
-          title: "Loading workspaces...",
-          value: "loading" as const,
-          description: "Fetching available workspace adapters",
+    if (!list) return []
+    const recent = sync.data.session
+      .toSorted((a, b) => b.time.updated - a.time.updated)
+      .flatMap((session) => (session.workspaceID ? [session.workspaceID] : []))
+      .filter((workspaceID, index, list) => list.indexOf(workspaceID) === index)
+      .slice(0, 3)
+      .flatMap((workspaceID) => {
+        const workspace = project.workspace.get(workspaceID)
+        return workspace ? [workspace] : []
+      })
+    return [
+      ...list.map((adapter) => ({
+        title: adapter.name,
+        value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
+        description: adapter.description,
+        category: "New workspace",
+      })),
+      {
+        title: "None",
+        value: { type: "none" as const },
+        description: "Use the local project",
+        category: "Choose workspace",
+      },
+      ...recent.map((workspace: Workspace) => ({
+        title: workspace.name,
+        description: `(${workspace.type})`,
+        value: {
+          type: "existing" as const,
+          workspaceID: workspace.id,
+          workspaceType: workspace.type,
+          workspaceName: workspace.name,
         },
-      ]
-    }
-    return list.map((item) => ({
-      title: item.name,
-      value: item.type,
-      description: item.description,
-    }))
+        category: "Choose workspace",
+      })),
+      {
+        title: "View all workspaces",
+        value: { type: "existing-list" as const },
+        description: "Choose from all workspaces",
+        category: "Choose workspace",
+      },
+    ]
   })
 
-  const create = async (type: string) => {
-    if (creating()) return
-    setCreating(type)
-
-    const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => {
-      toast.show({
-        message: "Creating workspace failed",
-        variant: "error",
-      })
-      return undefined
-    })
+  if (!adapters()) return null
+  return (
+    <DialogSelect<WorkspaceSelectValue>
+      title="Warp"
+      skipFilter={true}
+      renderFilter={false}
+      options={options()}
+      onSelect={(option) => {
+        if (!option.value) return
+        if (option.value.type === "none") {
+          void props.onSelect(option.value)
+          return
+        }
+        if (option.value.type === "new") {
+          void props.onSelect(option.value)
+          return
+        }
+        if (option.value.type === "existing") {
+          void props.onSelect(option.value)
+          return
+        }
+
+        dialog.replace(() => <DialogExistingWorkspaceSelect onSelect={props.onSelect} />)
+      }}
+    />
+  )
+}
 
-    const workspace = result?.data
-    if (!workspace) {
-      setCreating(undefined)
-      toast.show({
-        message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
-        variant: "error",
-      })
-      return
-    }
+function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise<void> | void }) {
+  const project = useProject()
 
-    await project.workspace.sync()
-    await props.onSelect(workspace.id)
-    setCreating(undefined)
-  }
+  const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
+    project.workspace
+      .list()
+      .filter((workspace) => project.workspace.status(workspace.id) === "connected")
+      .map((workspace: Workspace) => ({
+        title: workspace.name,
+        description: `(${workspace.type})`,
+        value: { workspace },
+      })),
+  )
 
   return (
-    <DialogSelect
-      title={creating() ? "Creating Workspace" : "New Workspace"}
-      skipFilter={true}
+    <DialogSelect<ExistingWorkspaceSelectValue>
+      title="Existing Workspace"
       options={options()}
       onSelect={(option) => {
-        if (option.value === "creating" || option.value === "loading") return
-        void create(option.value)
+        void props.onSelect({
+          type: "existing",
+          workspaceID: option.value.workspace.id,
+          workspaceType: option.value.workspace.type,
+          workspaceName: option.value.workspace.name,
+        })
       }}
     />
   )

+ 266 - 91
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem"
 import { useLocal } from "@tui/context/local"
 import { tint, useTheme } from "@tui/context/theme"
 import { EmptyBorder, SplitBorder } from "@tui/component/border"
+import { Spinner } from "@tui/component/spinner"
 import { useSDK } from "@tui/context/sdk"
 import { useRoute } from "@tui/context/route"
 import { useProject } from "@tui/context/project"
@@ -41,9 +42,11 @@ import { useKV } from "../../context/kv"
 import { createFadeIn } from "../../util/signal"
 import { useTextareaKeybindings } from "../textarea-keybindings"
 import { DialogSkill } from "../dialog-skill"
-import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
+import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create"
 import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
 import { useArgs } from "@tui/context/args"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
 
 export type PromptProps = {
   sessionID?: string
@@ -173,9 +176,92 @@ export function Prompt(props: PromptProps) {
   const [editorContextHover, setEditorContextHover] = createSignal(false)
   let lastSubmittedEditorSelectionKey: string | undefined
   const [auto, setAuto] = createSignal<AutocompleteRef>()
+  const [workspaceSelection, setWorkspaceSelection] = createSignal<WorkspaceSelection>()
+  const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
+  const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
+  const [warpNotice, setWarpNotice] = createSignal<string>()
   const currentProviderLabel = createMemo(() => local.model.parsed().provider)
   const hasRightContent = createMemo(() => Boolean(props.right))
 
+  function selectWorkspace(selection: WorkspaceSelection | undefined) {
+    setWorkspaceSelection(selection)
+  }
+
+  function setCreatingWorkspace(creating: boolean) {
+    setWorkspaceCreating(creating)
+  }
+
+  function showWarpNotice(name: string) {
+    setWarpNotice(`Warped to ${name}`)
+    setTimeout(() => setWarpNotice(undefined), 4000)
+  }
+
+  async function createWorkspace(selection: Extract<WorkspaceSelection, { type: "new" }>) {
+    setCreatingWorkspace(true)
+    const result = await sdk.client.experimental.workspace
+      .create({ type: selection.workspaceType, branch: null })
+      .catch(() => undefined)
+    if (result == undefined || result.error || !result.data) {
+      selectWorkspace(undefined)
+      setCreatingWorkspace(false)
+      toast.show({
+        message: "Creating workspace failed",
+        variant: "error",
+      })
+      return
+    }
+
+    await project.workspace.sync()
+    const workspace = result.data
+    selectWorkspace({
+      type: "existing",
+      workspaceID: workspace.id,
+      workspaceType: workspace.type,
+      workspaceName: workspace.name,
+    })
+    setCreatingWorkspace(false)
+    return workspace
+  }
+
+  async function warpSession(selection: WorkspaceSelection) {
+    if (!props.sessionID) {
+      selectWorkspace(selection)
+      dialog.clear()
+      if (selection.type === "new") void createWorkspace(selection)
+      return
+    }
+    selectWorkspace(selection)
+    dialog.clear()
+
+    const workspace =
+      selection.type === "none"
+        ? { id: null, name: "local project" }
+        : selection.type === "existing"
+          ? { id: selection.workspaceID, name: selection.workspaceName }
+          : await createWorkspace(selection)
+    if (!workspace) return
+
+    const warped = await warpWorkspaceSession({
+      dialog,
+      sdk,
+      sync,
+      project,
+      toast,
+      workspaceID: workspace.id,
+      sessionID: props.sessionID,
+    })
+    if (warped) showWarpNotice(workspace.name)
+  }
+
+  createEffect(() => {
+    if (!workspaceCreating()) {
+      setWorkspaceCreatingDots(3)
+      return
+    }
+    const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000)
+    onCleanup(() => clearInterval(timer))
+  })
+
   function promptModelWarning() {
     toast.show({
       variant: "warning",
@@ -213,6 +299,7 @@ export function Prompt(props: PromptProps) {
   })
 
   createEffect(() => {
+    if (!input || input.isDestroyed) return
     if (props.disabled) input.cursorColor = theme.backgroundElement
     if (!props.disabled) input.cursorColor = theme.text
   })
@@ -489,6 +576,27 @@ export function Prompt(props: PromptProps) {
           ))
         },
       },
+      {
+        title: "Warp",
+        description: "Change the workspace for the session",
+        value: "workspace.set",
+        category: "Session",
+        enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
+        slash: {
+          name: "warp",
+        },
+        onSelect: (dialog) => {
+          void openWorkspaceSelect({
+            dialog,
+            sdk,
+            sync,
+            toast,
+            onSelect: (selection) => {
+              void warpSession(selection)
+            },
+          })
+        },
+      },
     ]
   })
 
@@ -699,6 +807,8 @@ export function Prompt(props: PromptProps) {
   ])
 
   async function submit() {
+    setWarpNotice(undefined)
+
     // IME: double-defer may fire before onContentChange flushes the last
     // composed character (e.g. Korean hangul) to the store, so read
     // plainText directly and sync before any downstream reads.
@@ -707,6 +817,7 @@ export function Prompt(props: PromptProps) {
       syncExtmarksWithPromptParts()
     }
     if (props.disabled) return false
+    if (workspaceCreating()) return false
     if (autocomplete?.visible) return false
     if (!store.prompt.input) return false
     const agent = local.agent.current()
@@ -729,21 +840,16 @@ export function Prompt(props: PromptProps) {
       dialog.replace(() => (
         <DialogWorkspaceUnavailable
           onRestore={() => {
-            dialog.replace(() => (
-              <DialogWorkspaceCreate
-                onSelect={(nextWorkspaceID) =>
-                  restoreWorkspaceSession({
-                    dialog,
-                    sdk,
-                    sync,
-                    project,
-                    toast,
-                    workspaceID: nextWorkspaceID,
-                    sessionID: props.sessionID!,
-                  })
-                }
-              />
-            ))
+            void openWorkspaceSelect({
+              dialog,
+              sdk,
+              sync,
+              toast,
+              onSelect: (selection) => {
+                void warpSession(selection)
+              },
+            })
+            return false
           }}
         />
       ))
@@ -753,6 +859,14 @@ export function Prompt(props: PromptProps) {
     const variant = local.model.variant.current()
     let sessionID = props.sessionID
     if (sessionID == null) {
+      const workspace = workspaceSelection()
+      const workspaceID = iife(() => {
+        if (!workspace) return undefined
+        if (workspace.type === "none") return undefined
+        if (workspace.type === "existing") return workspace.workspaceID
+        return undefined
+      })
+
       const res = await sdk.client.session.create({
         workspace: props.workspaceID,
         agent: agent.name,
@@ -1025,6 +1139,29 @@ export function Prompt(props: PromptProps) {
     return `Ask anything... "${list()[store.placeholder % list().length]}"`
   })
 
+  const workspaceLabel = createMemo<
+    | { type: "new"; workspaceType: string }
+    | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
+    | undefined
+  >(() => {
+    const selected = workspaceSelection()
+    if (!selected) return
+    if (selected.type === "none") return
+    if (props.sessionID && !workspaceCreating()) return
+    if (selected.type === "new") {
+      return {
+        type: "new",
+        workspaceType: selected.workspaceType,
+      }
+    }
+    return {
+      type: "existing",
+      workspaceType: selected.workspaceType,
+      workspaceName: selected.workspaceName,
+      status: selected.type === "existing" ? "connected" : undefined,
+    }
+  })
+
   const spinnerDef = createMemo(() => {
     const agent = local.agent.current()
     const color = agent ? local.agent.color(agent.name) : theme.border
@@ -1281,7 +1418,7 @@ export function Prompt(props: PromptProps) {
               }}
               onMouseDown={(r: MouseEvent) => r.target?.focus()}
               focusedBackgroundColor={theme.backgroundElement}
-              cursorColor={theme.text}
+              cursorColor={props.disabled ? theme.backgroundElement : theme.text}
               syntaxStyle={syntax()}
             />
             <box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
@@ -1351,86 +1488,124 @@ export function Prompt(props: PromptProps) {
           />
         </box>
         <box width="100%" flexDirection="row" justifyContent="space-between">
-          <Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
-            <box
-              flexDirection="row"
-              gap={1}
-              flexGrow={1}
-              justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
-            >
-              <box flexShrink={0} flexDirection="row" gap={1}>
-                <box marginLeft={1}>
-                  <Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
-                    <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
-                  </Show>
-                </box>
-                <box flexDirection="row" gap={1} flexShrink={0}>
-                  {(() => {
-                    const retry = createMemo(() => {
-                      const s = status()
-                      if (s.type !== "retry") return
-                      return s
-                    })
-                    const message = createMemo(() => {
-                      const r = retry()
-                      if (!r) return
-                      if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
-                        return "gemini is way too hot right now"
-                      if (r.message.length > 80) return r.message.slice(0, 80) + "..."
-                      return r.message
-                    })
-                    const isTruncated = createMemo(() => {
-                      const r = retry()
-                      if (!r) return false
-                      return r.message.length > 120
-                    })
-                    const [seconds, setSeconds] = createSignal(0)
-                    onMount(() => {
-                      const timer = setInterval(() => {
-                        const next = retry()?.next
-                        if (next) setSeconds(Math.round((next - Date.now()) / 1000))
-                      }, 1000)
-
-                      onCleanup(() => {
-                        clearInterval(timer)
+          <Switch>
+            <Match when={status().type !== "idle"}>
+              <box
+                flexDirection="row"
+                gap={1}
+                flexGrow={1}
+                justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
+              >
+                <box flexShrink={0} flexDirection="row" gap={1}>
+                  <box marginLeft={1}>
+                    <Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
+                      <spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
+                    </Show>
+                  </box>
+                  <box flexDirection="row" gap={1} flexShrink={0}>
+                    {(() => {
+                      const retry = createMemo(() => {
+                        const s = status()
+                        if (s.type !== "retry") return
+                        return s
                       })
-                    })
-                    const handleMessageClick = () => {
-                      const r = retry()
-                      if (!r) return
-                      if (isTruncated()) {
-                        void DialogAlert.show(dialog, "Retry Error", r.message)
+                      const message = createMemo(() => {
+                        const r = retry()
+                        if (!r) return
+                        if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
+                          return "gemini is way too hot right now"
+                        if (r.message.length > 80) return r.message.slice(0, 80) + "..."
+                        return r.message
+                      })
+                      const isTruncated = createMemo(() => {
+                        const r = retry()
+                        if (!r) return false
+                        return r.message.length > 120
+                      })
+                      const [seconds, setSeconds] = createSignal(0)
+                      onMount(() => {
+                        const timer = setInterval(() => {
+                          const next = retry()?.next
+                          if (next) setSeconds(Math.round((next - Date.now()) / 1000))
+                        }, 1000)
+
+                        onCleanup(() => {
+                          clearInterval(timer)
+                        })
+                      })
+                      const handleMessageClick = () => {
+                        const r = retry()
+                        if (!r) return
+                        if (isTruncated()) {
+                          void DialogAlert.show(dialog, "Retry Error", r.message)
+                        }
                       }
-                    }
 
-                    const retryText = () => {
-                      const r = retry()
-                      if (!r) return ""
-                      const baseMessage = message()
-                      const truncatedHint = isTruncated() ? " (click to expand)" : ""
-                      const duration = formatDuration(seconds())
-                      const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
-                      return baseMessage + truncatedHint + retryInfo
-                    }
+                      const retryText = () => {
+                        const r = retry()
+                        if (!r) return ""
+                        const baseMessage = message()
+                        const truncatedHint = isTruncated() ? " (click to expand)" : ""
+                        const duration = formatDuration(seconds())
+                        const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
+                        return baseMessage + truncatedHint + retryInfo
+                      }
 
-                    return (
-                      <Show when={retry()}>
-                        <box onMouseUp={handleMessageClick}>
-                          <text fg={theme.error}>{retryText()}</text>
-                        </box>
-                      </Show>
-                    )
-                  })()}
+                      return (
+                        <Show when={retry()}>
+                          <box onMouseUp={handleMessageClick}>
+                            <text fg={theme.error}>{retryText()}</text>
+                          </box>
+                        </Show>
+                      )
+                    })()}
+                  </box>
                 </box>
+                <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
+                  esc{" "}
+                  <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
+                    {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
+                  </span>
+                </text>
               </box>
-              <text fg={store.interrupt > 0 ? theme.primary : theme.text}>
-                esc{" "}
-                <span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
-                  {store.interrupt > 0 ? "again to interrupt" : "interrupt"}
-                </span>
-              </text>
-            </box>
-          </Show>
+            </Match>
+            <Match when={warpNotice()}>
+              {(notice) => (
+                <box paddingLeft={3}>
+                  <text fg={theme.accent}>{notice()}</text>
+                </box>
+              )}
+            </Match>
+            <Match when={workspaceLabel()}>
+              {(workspace) => (
+                <box paddingLeft={3} flexDirection="row" gap={1}>
+                  <Show when={workspaceCreating()}>
+                    <Spinner color={theme.accent} />
+                  </Show>
+                  <text fg={workspaceCreating() ? theme.accent : theme.text}>
+                    {(() => {
+                      const item = workspace()
+                      if (item.type === "new") {
+                        if (workspaceCreating())
+                          return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}`
+                        return (
+                          <>
+                            Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
+                          </>
+                        )
+                      }
+                      return (
+                        <>
+                          Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
+                        </>
+                      )
+                    })()}
+                  </text>
+                </box>
+              )}
+            </Match>
+            <Match when={true}>{props.hint ?? <text />}</Match>
+          </Switch>
           <Show when={status().type !== "retry"}>
             <box gap={2} flexDirection="row">
               <Show when={editorFileLabelDisplay()}>

+ 19 - 0
packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx

@@ -0,0 +1,19 @@
+import { useTheme } from "@tui/context/theme"
+
+export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
+
+export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
+  const { theme } = useTheme()
+  const color = () => {
+    if (props.status === "connected") return theme.success
+    if (props.status === "error") return theme.error
+    return theme.textMuted
+  }
+
+  return (
+    <>
+      {props.icon ? <span style={{ fg: color() }}>● </span> : undefined}
+      <span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
+    </>
+  )
+}

+ 17 - 12
packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx

@@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst
 import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
 
 import { getScrollAcceleration } from "../../util/scroll"
+import { WorkspaceLabel } from "../../component/workspace-label"
 
 export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
   const project = useProject()
@@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
   const { theme } = useTheme()
   const tuiConfig = useTuiConfig()
   const session = createMemo(() => sync.session.get(props.sessionID))
-  const workspaceStatus = () => {
+  const workspace = () => {
     const workspaceID = session()?.workspaceID
-    if (!workspaceID) return "error"
-    return project.workspace.status(workspaceID) ?? "error"
-  }
-  const workspaceLabel = () => {
-    const workspaceID = session()?.workspaceID
-    if (!workspaceID) return "unknown"
-    const info = project.workspace.get(workspaceID)
-    if (!info) return "unknown"
-    return `${info.type}: ${info.name}`
+    if (!workspaceID) return
+    return project.workspace.get(workspaceID)
   }
   const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
 
@@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
                 </Show>
                 <Show when={session()!.workspaceID}>
                   <text fg={theme.textMuted}>
-                    <span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
-                    {workspaceLabel()}
+                    <Show
+                      when={workspace()}
+                      fallback={<WorkspaceLabel type="unknown" name={session()!.workspaceID!} status="error" icon />}
+                    >
+                      {(item) => (
+                        <WorkspaceLabel
+                          type={item().type}
+                          name={item().name}
+                          status={project.workspace.status(item().id) ?? "error"}
+                          icon
+                        />
+                      )}
+                    </Show>
                   </text>
                 </Show>
                 <Show when={session()!.share?.url}>

+ 28 - 25
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -23,6 +23,7 @@ export interface DialogSelectProps<T> {
   onFilter?: (query: string) => void
   onSelect?: (option: DialogSelectOption<T>) => void
   skipFilter?: boolean
+  renderFilter?: boolean
   keybind?: {
     keybind?: Keybind.Info
     title: string
@@ -81,7 +82,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   let input: InputRenderable
 
   const filtered = createMemo(() => {
-    if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
+    if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
     const needle = store.filter.toLowerCase()
     const options = pipe(
       props.options,
@@ -250,30 +251,32 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
             esc
           </text>
         </box>
-        <box paddingTop={1}>
-          <input
-            onInput={(e) => {
-              batch(() => {
-                setStore("filter", e)
-                props.onFilter?.(e)
-              })
-            }}
-            focusedBackgroundColor={theme.backgroundPanel}
-            cursorColor={theme.primary}
-            focusedTextColor={theme.textMuted}
-            ref={(r) => {
-              input = r
-              input.traits = { status: "FILTER" }
-              setTimeout(() => {
-                if (!input) return
-                if (input.isDestroyed) return
-                input.focus()
-              }, 1)
-            }}
-            placeholder={props.placeholder ?? "Search"}
-            placeholderColor={theme.textMuted}
-          />
-        </box>
+        <Show when={props.renderFilter !== false}>
+          <box paddingTop={1}>
+            <input
+              onInput={(e) => {
+                batch(() => {
+                  setStore("filter", e)
+                  props.onFilter?.(e)
+                })
+              }}
+              focusedBackgroundColor={theme.backgroundPanel}
+              cursorColor={theme.primary}
+              focusedTextColor={theme.textMuted}
+              ref={(r) => {
+                input = r
+                input.traits = { status: "FILTER" }
+                setTimeout(() => {
+                  if (!input) return
+                  if (input.isDestroyed) return
+                  input.focus()
+                }, 1)
+              }}
+              placeholder={props.placeholder ?? "Search"}
+              placeholderColor={theme.textMuted}
+            />
+          </box>
+        </Show>
       </box>
       <Show
         when={grouped().length > 0}

+ 153 - 128
packages/opencode/src/control-plane/workspace.ts

@@ -1,10 +1,11 @@
-import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect"
+import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
 import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
 import { Database } from "@/storage/db"
 import { asc } from "drizzle-orm"
 import { eq } from "drizzle-orm"
 import { inArray } from "drizzle-orm"
 import { Project } from "@/project/project"
+import { Instance } from "@/project/instance"
 import { BusEvent } from "@/bus/bus-event"
 import { GlobalBus } from "@/bus/global"
 import { Auth } from "@/auth"
@@ -20,6 +21,7 @@ import { getAdapter } from "./adapters"
 import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
 import { WorkspaceID } from "./schema"
 import { Session } from "@/session/session"
+import { SessionPrompt } from "@/session/prompt"
 import { SessionTable } from "@/session/session.sql"
 import { SessionID } from "@/session/schema"
 import { errorData } from "@/util/error"
@@ -38,13 +40,6 @@ export const ConnectionStatus = Schema.Struct({
 })
 export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
 
-const Restore = Schema.Struct({
-  workspaceID: WorkspaceID,
-  sessionID: SessionID,
-  total: NonNegativeInt,
-  step: NonNegativeInt,
-})
-
 export const Event = {
   Ready: BusEvent.define(
     "workspace.ready",
@@ -58,7 +53,6 @@ export const Event = {
       message: Schema.String,
     }),
   ),
-  Restore: BusEvent.define("workspace.restore", Restore),
   Status: BusEvent.define("workspace.status", ConnectionStatus),
 }
 
@@ -84,15 +78,15 @@ export const CreateInput = Schema.Struct({
   type: Info.fields.type,
   branch: Info.fields.branch,
   projectID: ProjectID,
-  extra: Info.fields.extra,
+  extra: Schema.optional(Info.fields.extra),
 }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
 export type CreateInput = Schema.Schema.Type<typeof CreateInput>
 
-export const SessionRestoreInput = Schema.Struct({
-  workspaceID: WorkspaceID,
+export const SessionWarpInput = Schema.Struct({
+  workspaceID: Schema.NullOr(WorkspaceID),
   sessionID: SessionID,
 }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
-export type SessionRestoreInput = Schema.Schema.Type<typeof SessionRestoreInput>
+export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
 
 export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
   message: Schema.String,
@@ -116,8 +110,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass<SessionE
   },
 ) {}
 
-export class SessionRestoreHttpError extends Schema.TaggedErrorClass<SessionRestoreHttpError>()(
-  "WorkspaceSessionRestoreHttpError",
+export class SessionWarpHttpError extends Schema.TaggedErrorClass<SessionWarpHttpError>()(
+  "WorkspaceSessionWarpHttpError",
   {
     message: Schema.String,
     workspaceID: WorkspaceID,
@@ -138,17 +132,17 @@ export class SyncAbortedError extends Schema.TaggedErrorClass<SyncAbortedError>(
 }) {}
 
 type CreateError = Auth.AuthError
-type SessionRestoreError =
+type SessionWarpError =
   | WorkspaceNotFoundError
   | SessionEventsNotFoundError
-  | SessionRestoreHttpError
+  | SessionWarpHttpError
   | HttpClientError.HttpClientError
 type WaitForSyncError = SyncTimeoutError | SyncAbortedError
 type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
 
 export interface Interface {
   readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
-  readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError>
+  readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
   readonly list: (project: Project.Info) => Effect.Effect<Info[]>
   readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
   readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
@@ -169,6 +163,7 @@ export const layer = Layer.effect(
   Effect.gen(function* () {
     const auth = yield* Auth.Service
     const session = yield* Session.Service
+    const prompt = yield* SessionPrompt.Service
     const http = yield* HttpClient.HttpClient
     const sync = yield* SyncEvent.Service
     const connections = new Map<WorkspaceID, ConnectionStatus>()
@@ -461,7 +456,7 @@ 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 }),
+        adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }),
       )
 
       const info: Info = {
@@ -518,29 +513,93 @@ export const layer = Layer.effect(
       return info
     })
 
-    const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) {
+    const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) {
       return yield* Effect.gen(function* () {
-        log.info("session restore requested", {
+        log.info("session warp requested", {
           workspaceID: input.workspaceID,
           sessionID: input.sessionID,
         })
 
-        const space = yield* get(input.workspaceID)
+        const current = yield* db((db) =>
+          db
+            .select({ workspaceID: SessionTable.workspace_id })
+            .from(SessionTable)
+            .where(eq(SessionTable.id, input.sessionID))
+            .get(),
+        )
+
+        if (current?.workspaceID) {
+          const previous = yield* get(current.workspaceID)
+          if (previous) {
+            const adapter = getAdapter(previous.projectID, previous.type)
+            const target = yield* EffectBridge.fromPromise(() => adapter.target(previous))
+
+            if (target.type === "remote") {
+              yield* syncHistory(previous, target.url, target.headers).pipe(
+                Effect.catch((error) =>
+                  Effect.sync(() => {
+                    log.warn("session warp final source sync failed", {
+                      workspaceID: previous.id,
+                      sessionID: input.sessionID,
+                      error: errorData(error),
+                    })
+                  }),
+                ),
+              )
+            } else {
+              yield* prompt.cancel(input.sessionID)
+            }
+
+            // "claim" this session so any future events coming from
+            // the old workspace are ignored
+            SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id)
+          }
+        }
+
+        if (input.workspaceID === null) {
+          yield* Effect.sync(() =>
+            SyncEvent.run(Session.Event.Updated, {
+              sessionID: input.sessionID,
+              info: {
+                workspaceID: null,
+              },
+            }),
+          )
+
+          log.info("session warp complete", {
+            workspaceID: input.workspaceID,
+            sessionID: input.sessionID,
+            target: "local",
+          })
+          return
+        }
+
+        const workspaceID = input.workspaceID
+        const space = yield* get(workspaceID)
         if (!space)
           return yield* new WorkspaceNotFoundError({
-            message: `Workspace not found: ${input.workspaceID}`,
-            workspaceID: input.workspaceID,
+            message: `Workspace not found: ${workspaceID}`,
+            workspaceID,
           })
 
         const adapter = getAdapter(space.projectID, space.type)
         const target = yield* EffectBridge.fromPromise(() => adapter.target(space))
 
-        yield* sync.run(Session.Event.Updated, {
-          sessionID: input.sessionID,
-          info: {
+        if (target.type === "local") {
+          yield* sync.run(Session.Event.Updated, {
+            sessionID: input.sessionID,
+            info: {
+              workspaceID: input.workspaceID,
+            },
+          })
+
+          log.info("session warp complete", {
             workspaceID: input.workspaceID,
-          },
-        })
+            sessionID: input.sessionID,
+            target: target.directory,
+          })
+          return
+        }
 
         const rows = yield* db((db) =>
           db
@@ -562,130 +621,95 @@ export const layer = Layer.effect(
             sessionID: input.sessionID,
           })
 
-        const size = 10
-        // TODO: look into using effect APIs to process this in chunks
-        const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) =>
-          rows.slice(i * size, (i + 1) * size),
-        )
-        const total = sets.length
+        const batches = Iterable.chunksOf(rows, 10)
+        const total = Iterable.size(batches)
 
-        log.info("session restore prepared", {
+        log.info("session warp prepared", {
           workspaceID: input.workspaceID,
           sessionID: input.sessionID,
-          workspaceType: space.type,
-          directory: space.directory,
-          target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
+          target: String(route(target.url, "/sync/replay")),
           events: rows.length,
           batches: total,
           first: rows[0]?.seq,
           last: rows.at(-1)?.seq,
         })
 
-        yield* Effect.sync(() =>
-          GlobalBus.emit("event", {
-            directory: "global",
-            workspace: input.workspaceID,
-            payload: {
-              type: Event.Restore.type,
-              properties: {
-                workspaceID: input.workspaceID,
-                sessionID: input.sessionID,
-                total,
-                step: 0,
-              },
-            },
-          }),
-        )
-
-        for (const [i, events] of sets.entries()) {
-          log.info("session restore batch starting", {
-            workspaceID: input.workspaceID,
-            sessionID: input.sessionID,
-            step: i + 1,
-            total,
-            events: events.length,
-            first: events[0]?.seq,
-            last: events.at(-1)?.seq,
-            target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
-          })
-
-          if (target.type === "local") {
-            yield* sync.replayAll(events)
-            log.info("session restore batch replayed locally", {
-              workspaceID: input.workspaceID,
-              sessionID: input.sessionID,
-              step: i + 1,
-              total,
-              events: events.length,
-            })
-          } else {
-            const url = route(target.url, "/sync/replay")
-            const res = yield* http.execute(
-              HttpClientRequest.post(url, {
-                headers: new Headers(target.headers),
-                body: HttpBody.jsonUnsafe({
-                  directory: space.directory ?? "",
-                  events,
+        yield* Effect.forEach(
+          batches,
+          (events, i) =>
+            Effect.gen(function* () {
+              const response = yield* http.execute(
+                HttpClientRequest.post(route(target.url, "/sync/replay"), {
+                  headers: new Headers(target.headers),
+                  body: HttpBody.jsonUnsafe({
+                    directory: space.directory ?? "",
+                    events,
+                  }),
                 }),
-              }),
-            )
+              )
 
-            if (res.status < 200 || res.status >= 300) {
-              const body = yield* res.text
-              log.error("session restore batch failed", {
+              if (response.status < 200 || response.status >= 300) {
+                const body = yield* response.text
+                log.error("session warp batch failed", {
+                  workspaceID: input.workspaceID,
+                  sessionID: input.sessionID,
+                  step: i + 1,
+                  total,
+                  status: response.status,
+                  body,
+                })
+                return yield* new SessionWarpHttpError({
+                  message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
+                  workspaceID,
+                  sessionID: input.sessionID,
+                  status: response.status,
+                  body,
+                })
+              }
+
+              log.info("session warp batch posted", {
                 workspaceID: input.workspaceID,
                 sessionID: input.sessionID,
                 step: i + 1,
                 total,
-                status: res.status,
-                body,
+                status: response.status,
               })
-              return yield* new SessionRestoreHttpError({
-                message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
-                workspaceID: input.workspaceID,
-                sessionID: input.sessionID,
-                status: res.status,
-                body,
-              })
-            }
-
-            log.info("session restore batch posted", {
-              workspaceID: input.workspaceID,
-              sessionID: input.sessionID,
-              step: i + 1,
-              total,
-              status: res.status,
-            })
-          }
-
-          yield* Effect.sync(() =>
-            GlobalBus.emit("event", {
-              directory: "global",
-              workspace: input.workspaceID,
-              payload: {
-                type: Event.Restore.type,
-                properties: {
-                  workspaceID: input.workspaceID,
-                  sessionID: input.sessionID,
-                  total,
-                  step: i + 1,
-                },
-              },
             }),
-          )
+          { discard: true },
+        )
+
+        const response = yield* http.execute(
+          HttpClientRequest.post(route(target.url, "/sync/steal"), {
+            headers: new Headers(target.headers),
+            body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }),
+          }),
+        )
+        if (response.status < 200 || response.status >= 300) {
+          const body = yield* response.text
+          log.error("session warp steal failed", {
+            workspaceID: input.workspaceID,
+            sessionID: input.sessionID,
+            status: response.status,
+            body,
+          })
+          return yield* new SessionWarpHttpError({
+            message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
+            workspaceID,
+            sessionID: input.sessionID,
+            status: response.status,
+            body,
+          })
         }
 
-        log.info("session restore complete", {
+        log.info("session warp complete", {
           workspaceID: input.workspaceID,
           sessionID: input.sessionID,
           batches: total,
         })
-
-        return { total }
       }).pipe(
         Effect.tapError((err) =>
           Effect.sync(() =>
-            log.error("session restore failed", {
+            log.error("session warp failed", {
               workspaceID: input.workspaceID,
               sessionID: input.sessionID,
               error: errorData(err),
@@ -814,7 +838,7 @@ export const layer = Layer.effect(
 
     return Service.of({
       create,
-      sessionRestore,
+      sessionWarp,
       list,
       get,
       remove,
@@ -830,6 +854,7 @@ export const defaultLayer = layer.pipe(
   Layer.provide(Auth.defaultLayer),
   Layer.provide(Session.defaultLayer),
   Layer.provide(SyncEvent.defaultLayer),
+  Layer.provide(SessionPrompt.defaultLayer),
   Layer.provide(FetchHttpClient.layer),
 )
 

+ 23 - 51
packages/opencode/src/server/routes/control/workspace.ts

@@ -10,10 +10,6 @@ import { zodObject } from "@/util/effect-zod"
 import { Instance } from "@/project/instance"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
-import * as Log from "@opencode-ai/core/util/log"
-import { errorData } from "@/util/error"
-
-const log = Log.create({ service: "server.workspace" })
 
 export const WorkspaceRoutes = lazy(() =>
   new Hono()
@@ -151,60 +147,36 @@ export const WorkspaceRoutes = lazy(() =>
       },
     )
     .post(
-      "/:id/session-restore",
+      "/warp",
       describeRoute({
-        summary: "Restore session into workspace",
-        description: "Replay a session's sync events into the target workspace in batches.",
-        operationId: "experimental.workspace.sessionRestore",
+        summary: "Warp session into workspace",
+        description: "Move a session's sync history into the target workspace, or detach it to the local project.",
+        operationId: "experimental.workspace.warp",
         responses: {
-          200: {
-            description: "Session replay started",
-            content: {
-              "application/json": {
-                schema: resolver(
-                  z.object({
-                    total: z.number().int().min(0),
-                  }),
-                ),
-              },
-            },
+          204: {
+            description: "Session warped",
           },
           ...errors(400),
         },
       }),
-      validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })),
-      validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })),
+      validator(
+        "json",
+        z.object({
+          id: zodObject(Workspace.Info).shape.id.nullable(),
+          sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
+        }),
+      ),
       async (c) => {
-        const { id } = c.req.valid("param")
-        const body = c.req.valid("json") as Omit<Workspace.SessionRestoreInput, "workspaceID">
-        log.info("session restore route requested", {
-          workspaceID: id,
-          sessionID: body.sessionID,
-          directory: Instance.directory,
-        })
-        try {
-          const result = await AppRuntime.runPromise(
-            Workspace.Service.use((svc) =>
-              svc.sessionRestore({
-                workspaceID: id,
-                ...body,
-              }),
-            ),
-          )
-          log.info("session restore route complete", {
-            workspaceID: id,
-            sessionID: body.sessionID,
-            total: result.total,
-          })
-          return c.json(result)
-        } catch (err) {
-          log.error("session restore route failed", {
-            workspaceID: id,
-            sessionID: body.sessionID,
-            error: errorData(err),
-          })
-          throw err
-        }
+        const body = c.req.valid("json")
+        await AppRuntime.runPromise(
+          Workspace.Service.use((workspace) =>
+            workspace.sessionWarp({
+              workspaceID: body.id,
+              sessionID: body.sessionID,
+            }),
+          ),
+        )
+        return c.body(null, 204)
       },
     ),
 )

+ 16 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts

@@ -1,4 +1,5 @@
 import { NonNegativeInt } from "@/util/schema"
+import { SessionID } from "@/session/schema"
 import { Schema } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
@@ -21,6 +22,9 @@ export const ReplayPayload = Schema.Struct({
 export const ReplayResponse = Schema.Struct({
   sessionID: Schema.String,
 })
+export const SessionPayload = Schema.Struct({
+  sessionID: SessionID,
+})
 export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
 export const HistoryEvent = Schema.Struct({
   id: Schema.String,
@@ -33,6 +37,7 @@ export const HistoryEvent = Schema.Struct({
 export const SyncPaths = {
   start: `${root}/start`,
   replay: `${root}/replay`,
+  steal: `${root}/steal`,
   history: `${root}/history`,
 } as const
 
@@ -60,6 +65,17 @@ export const SyncApi = HttpApi.make("sync")
             description: "Validate and replay a complete sync event history.",
           }),
         ),
+        HttpApiEndpoint.post("steal", SyncPaths.steal, {
+          payload: SessionPayload,
+          success: described(SessionPayload, "Session stolen into workspace"),
+          error: HttpApiError.BadRequest,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "sync.steal",
+            summary: "Steal session into workspace",
+            description: "Update a session to belong to the current workspace through the sync event system.",
+          }),
+        ),
         HttpApiEndpoint.post("history", SyncPaths.history, {
           payload: HistoryPayload,
           success: described(Schema.Array(HistoryEvent), "Sync events"),

+ 12 - 17
packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts

@@ -1,21 +1,17 @@
 import { Workspace } from "@/control-plane/workspace"
 import { WorkspaceAdapterEntry } from "@/control-plane/types"
-import { NonNegativeInt } from "@/util/schema"
 import { Schema, Struct } from "effect"
-import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
 import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
 import { described } from "./metadata"
 
 const root = "/experimental/workspace"
-export const CreatePayload = Schema.Struct({
-  ...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]),
-  extra: Schema.optional(Workspace.CreateInput.fields.extra),
-})
-export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
-export const SessionRestoreResponse = Schema.Struct({
-  total: NonNegativeInt,
+export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
+export const WarpPayload = Schema.Struct({
+  id: Schema.NullOr(Workspace.Info.fields.id),
+  sessionID: Workspace.SessionWarpInput.fields.sessionID,
 })
 
 export const WorkspacePaths = {
@@ -23,7 +19,7 @@ export const WorkspacePaths = {
   list: root,
   status: `${root}/status`,
   remove: `${root}/:id`,
-  sessionRestore: `${root}/:id/session-restore`,
+  warp: `${root}/warp`,
 } as const
 
 export const WorkspaceApi = HttpApi.make("workspace")
@@ -79,16 +75,15 @@ export const WorkspaceApi = HttpApi.make("workspace")
             description: "Remove an existing workspace.",
           }),
         ),
-        HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
-          params: { id: Workspace.Info.fields.id },
-          payload: SessionRestorePayload,
-          success: described(SessionRestoreResponse, "Session replay started"),
+        HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
+          payload: WarpPayload,
+          success: described(HttpApiSchema.NoContent, "Session warped"),
           error: HttpApiError.BadRequest,
         }).annotateMerge(
           OpenApi.annotations({
-            identifier: "experimental.workspace.sessionRestore",
-            summary: "Restore session into workspace",
-            description: "Replay a session's sync events into the target workspace in batches.",
+            identifier: "experimental.workspace.warp",
+            summary: "Warp session into workspace",
+            description: "Move a session's sync history into the target workspace, or detach it to the local project.",
           }),
         ),
       )

+ 22 - 2
packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts

@@ -1,5 +1,6 @@
 import { Workspace } from "@/control-plane/workspace"
 import * as InstanceState from "@/effect/instance-state"
+import { Session } from "@/session/session"
 import { Database } from "@/storage/db"
 import { SyncEvent } from "@/sync"
 import { EventTable } from "@/sync/event.sql"
@@ -12,7 +13,7 @@ import { or } from "drizzle-orm"
 import { Effect, Scope } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../api"
-import { HistoryPayload, ReplayPayload } from "../groups/sync"
+import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync"
 import * as Log from "@opencode-ai/core/util/log"
 
 const log = Log.create({ service: "server.sync" })
@@ -56,6 +57,25 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
       return { sessionID: source }
     })
 
+    const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) {
+      const workspaceID = yield* InstanceState.workspaceID
+      if (!workspaceID) throw new Error("Cannot steal session without workspace context")
+
+      yield* sync.run(Session.Event.Updated, {
+        sessionID: ctx.payload.sessionID,
+        info: {
+          workspaceID,
+        },
+      })
+
+      log.info("sync session stolen", {
+        sessionID: ctx.payload.sessionID,
+        workspaceID,
+      })
+
+      return { sessionID: ctx.payload.sessionID }
+    })
+
     const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
       const exclude = Object.entries(ctx.payload)
       return Database.use((db) =>
@@ -72,6 +92,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
       )
     })
 
-    return handlers.handle("start", start).handle("replay", replay).handle("history", history)
+    return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history)
   }),
 )

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

@@ -4,7 +4,7 @@ import * as InstanceState from "@/effect/instance-state"
 import { Effect } from "effect"
 import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../api"
-import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
+import { CreatePayload, WarpPayload } from "../groups/workspace"
 
 export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
   Effect.gen(function* () {
@@ -39,13 +39,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
       return yield* workspace.remove(ctx.params.id)
     })
 
-    const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
-      params: { id: Workspace.Info["id"] }
-      payload: typeof SessionRestorePayload.Type
-    }) {
-      return yield* workspace
-        .sessionRestore({
-          workspaceID: ctx.params.id,
+    const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) {
+      yield* workspace
+        .sessionWarp({
+          workspaceID: ctx.payload.id,
           sessionID: ctx.payload.sessionID,
         })
         .pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
@@ -57,6 +54,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
       .handle("create", create)
       .handle("status", status)
       .handle("remove", remove)
-      .handle("sessionRestore", sessionRestore)
+      .handle("warp", warp)
   }),
 )

+ 1 - 1
packages/opencode/src/server/routes/instance/index.ts

@@ -155,7 +155,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
     app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
     app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
     app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
-    app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
+    app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context))
   }
 
   return app

+ 47 - 0
packages/opencode/src/server/routes/instance/sync.ts

@@ -16,6 +16,9 @@ import { Workspace } from "@/control-plane/workspace"
 import { AppRuntime } from "@/effect/app-runtime"
 import { Instance } from "@/project/instance"
 import { errors } from "../../error"
+import { Session } from "@/session/session"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
+import { SessionID } from "@/session/schema"
 
 const ReplayEvent = z.object({
   id: z.string(),
@@ -24,6 +27,9 @@ const ReplayEvent = z.object({
   type: z.string(),
   data: z.record(z.string(), z.unknown()),
 })
+const SessionPayload = z.object({
+  sessionID: SessionID.zod,
+})
 
 const log = Log.create({ service: "server.sync" })
 
@@ -108,6 +114,47 @@ export const SyncRoutes = lazy(() =>
         })
       },
     )
+    .post(
+      "/steal",
+      describeRoute({
+        summary: "Steal session into workspace",
+        description: "Update a session to belong to the current workspace through the sync event system.",
+        operationId: "sync.steal",
+        responses: {
+          200: {
+            description: "Session stolen into workspace",
+            content: {
+              "application/json": {
+                schema: resolver(SessionPayload),
+              },
+            },
+          },
+          ...errors(400),
+        },
+      }),
+      validator("json", SessionPayload),
+      async (c) => {
+        const body = c.req.valid("json")
+        const workspaceID = WorkspaceContext.workspaceID
+        if (!workspaceID) throw new Error("Cannot steal session without workspace context")
+
+        SyncEvent.run(Session.Event.Updated, {
+          sessionID: body.sessionID,
+          info: {
+            workspaceID,
+          },
+        })
+
+        log.info("sync session stolen", {
+          sessionID: body.sessionID,
+          workspaceID,
+        })
+
+        return c.json({
+          sessionID: body.sessionID,
+        })
+      },
+    )
     .post(
       "/history",
       describeRoute({

+ 1 - 0
packages/opencode/src/sync/event.sql.ts

@@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
 export const EventSequenceTable = sqliteTable("event_sequence", {
   aggregate_id: text().notNull().primaryKey(),
   seq: integer().notNull(),
+  owner_id: text(),
 })
 
 export const EventTable = sqliteTable("event", {

+ 25 - 9
packages/opencode/src/sync/index.ts

@@ -59,8 +59,11 @@ export interface Interface {
     data: Event<Def>["data"],
     options?: { publish?: boolean },
   ) => Effect.Effect<void>
-  readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect<void>
-  readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect<string | undefined>
+  readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect<void>
+  readonly replayAll: (
+    events: SerializedEvent[],
+    options?: { publish: boolean; ownerID?: string },
+  ) => Effect.Effect<string | undefined>
   readonly remove: (aggregateID: string) => Effect.Effect<void>
 }
 
@@ -76,7 +79,7 @@ export const layer = Layer.effect(Service)(
 
       const row = Database.use((db) =>
         db
-          .select({ seq: EventSequenceTable.seq })
+          .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
           .from(EventSequenceTable)
           .where(eq(EventSequenceTable.aggregate_id, event.aggregateID))
           .get(),
@@ -85,6 +88,10 @@ export const layer = Layer.effect(Service)(
       const latest = row?.seq ?? -1
       if (event.seq <= latest) return
 
+      if (row?.ownerID && row.ownerID !== options?.ownerID) {
+        return
+      }
+
       const expected = latest + 1
       if (event.seq !== expected) {
         throw new Error(
@@ -99,7 +106,7 @@ export const layer = Layer.effect(Service)(
             workspace: yield* InstanceState.workspaceID,
           }
         : undefined
-      process(def, event, { publish, context })
+      process(def, event, { publish, context, ownerID: options?.ownerID })
     })
 
     const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) {
@@ -263,7 +270,7 @@ export function project<Def extends Definition>(
 function process<Def extends Definition>(
   def: Def,
   event: Event<Def>,
-  options: { publish: boolean; context?: PublishContext },
+  options: { publish: boolean; context?: PublishContext; ownerID?: string },
 ) {
   if (projectors == null) {
     throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
@@ -274,8 +281,6 @@ function process<Def extends Definition>(
     throw new Error(`Projector not found for event: ${def.type}`)
   }
 
-  // idempotent: need to ignore any events already logged
-
   Database.transaction((tx) => {
     projector(tx, event.data, event)
 
@@ -284,6 +289,7 @@ function process<Def extends Definition>(
         .values({
           aggregate_id: event.aggregateID,
           seq: event.seq,
+          owner_id: options?.ownerID,
         })
         .onConflictDoUpdate({
           target: EventSequenceTable.aggregate_id,
@@ -332,11 +338,11 @@ function process<Def extends Definition>(
   })
 }
 
-export function replay(event: SerializedEvent, options?: { publish: boolean }) {
+export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) {
   return runtime.runSync((sync) => sync.replay(event, options))
 }
 
-export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) {
+export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) {
   return runtime.runSync((sync) => sync.replayAll(events, options))
 }
 
@@ -348,6 +354,16 @@ export function remove(aggregateID: string) {
   return runtime.runSync((sync) => sync.remove(aggregateID))
 }
 
+export function claim(aggregateID: string, ownerID: string) {
+  Database.use((db) =>
+    db
+      .update(EventSequenceTable)
+      .set({ owner_id: ownerID })
+      .where(eq(EventSequenceTable.aggregate_id, aggregateID))
+      .run(),
+  )
+}
+
 export function payloads() {
   return registry
     .entries()

+ 149 - 357
packages/opencode/test/control-plane/workspace.test.ts

@@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises"
 import { NodeHttpServer } from "@effect/platform-node"
 import { Effect, Layer } from "effect"
 import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
-import { asc, eq } from "drizzle-orm"
+import { eq } from "drizzle-orm"
 import * as Log from "@opencode-ai/core/util/log"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { GlobalBus, type GlobalEvent } from "@/bus/global"
@@ -16,11 +16,10 @@ import { ProjectTable } from "@/project/project.sql"
 import { Instance } from "@/project/instance"
 import { WithInstance } from "../../src/project/with-instance"
 import { Session as SessionNs } from "@/session/session"
-import { SessionID, MessageID, PartID } from "@/session/schema"
+import { SessionID } from "@/session/schema"
 import { SessionTable } from "@/session/session.sql"
-import { ModelID, ProviderID } from "@/provider/schema"
 import { SyncEvent } from "@/sync"
-import { EventSequenceTable, EventTable } from "@/sync/event.sql"
+import { EventSequenceTable } from "@/sync/event.sql"
 import { resetDatabase } from "../fixture/db"
 import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
@@ -111,8 +110,8 @@ async function withInstance<T>(fn: (dir: string) => T | Promise<T>) {
 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 restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) =>
-  runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(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)))
@@ -317,48 +316,24 @@ function sessionSequence(sessionID: SessionID) {
   )?.seq
 }
 
-function eventRows(sessionID: SessionID) {
+function sessionSequenceOwner(sessionID: SessionID) {
   return Database.use((db) =>
     db
-      .select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data })
-      .from(EventTable)
-      .where(eq(EventTable.aggregate_id, sessionID))
-      .orderBy(asc(EventTable.seq))
-      .all(),
-  )
+      .select({ ownerID: EventSequenceTable.owner_id })
+      .from(EventSequenceTable)
+      .where(eq(EventSequenceTable.aggregate_id, sessionID))
+      .get(),
+  )?.ownerID
 }
 
 function sessionUpdatedType() {
   return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version)
 }
 
-function replaceSessionEvents(sessionID: SessionID, count: number) {
-  Database.use((db) => {
-    db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run()
-    if (count === 0) return
-
-    db.insert(EventSequenceTable)
-      .values({ aggregate_id: sessionID, seq: count - 1 })
-      .run()
-    db.insert(EventTable)
-      .values(
-        Array.from({ length: count }, (_, i) => ({
-          id: `evt_${unique(`manual-${i}`)}`,
-          aggregate_id: sessionID,
-          seq: i,
-          type: sessionUpdatedType(),
-          data: { sessionID, info: { title: `manual ${i}` } },
-        })),
-      )
-      .run()
-  })
-}
-
 describe("workspace-old 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.Restore.type).toBe("workspace.restore")
     expect(WorkspaceOld.Event.Status.type).toBe("workspace.status")
   })
 
@@ -375,17 +350,6 @@ describe("workspace-old schemas and exports", () => {
     expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow()
     expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow()
   })
-
-  test("validates session restore input", () => {
-    const input = {
-      workspaceID: WorkspaceID.ascending("wrk_schema_restore"),
-      sessionID: SessionID.descending("ses_schema_restore"),
-    }
-
-    expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input)
-    expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow()
-    expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow()
-  })
 })
 
 describe("workspace-old CRUD", () => {
@@ -651,6 +615,144 @@ describe("workspace-old CRUD", () => {
       expect(await getWorkspace(info.id)).toBeUndefined()
     })
   })
+
+  test("sessionWarp moves a session into a local workspace and claims ownership", async () => {
+    await withInstance(async (dir) => {
+      const previousType = unique("warp-prev-local")
+      const targetType = unique("warp-target-local")
+      const previous = workspaceInfo(Instance.project.id, previousType)
+      const target = workspaceInfo(Instance.project.id, targetType)
+      insertWorkspace(previous)
+      insertWorkspace(target)
+      registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
+      registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
+      const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
+      attachSessionToWorkspace(session.id, previous.id)
+
+      await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id })
+
+      expect(
+        Database.use((db) =>
+          db
+            .select({ workspaceID: SessionTable.workspace_id })
+            .from(SessionTable)
+            .where(eq(SessionTable.id, session.id))
+            .get(),
+        )?.workspaceID,
+      ).toBe(target.id)
+      expect(sessionSequenceOwner(session.id)).toBe(target.id)
+    })
+  })
+
+  test("sessionWarp detaches a session to the local project and claims project ownership", async () => {
+    await withInstance(async (dir) => {
+      const previousType = unique("warp-detach-local")
+      const previous = workspaceInfo(Instance.project.id, previousType)
+      insertWorkspace(previous)
+      registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
+      const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
+      attachSessionToWorkspace(session.id, previous.id)
+
+      await warpWorkspaceSession({ workspaceID: null, sessionID: session.id })
+
+      expect(
+        Database.use((db) =>
+          db
+            .select({ workspaceID: SessionTable.workspace_id })
+            .from(SessionTable)
+            .where(eq(SessionTable.id, session.id))
+            .get(),
+        )?.workspaceID,
+      ).toBeNull()
+      expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id)
+    })
+  })
+
+  it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => {
+    const calls: FetchCall[] = []
+    let historySessionID: SessionID | undefined
+    let historyNextSeq = 0
+    return Effect.gen(function* () {
+      yield* HttpServer.serveEffect()(
+        Effect.gen(function* () {
+          const req = yield* HttpServerRequest.HttpServerRequest
+          const bodyText = yield* req.text
+          const call = {
+            url: new URL(req.url, "http://localhost"),
+            method: req.method,
+            headers: new Headers(req.headers),
+            bodyText,
+            json: bodyText ? JSON.parse(bodyText) : undefined,
+          }
+          calls.push(call)
+          if (call.url.pathname === "/warp-source/sync/history") {
+            return yield* HttpServerResponse.json([
+              {
+                id: `evt_${unique("warp-source-history")}`,
+                aggregate_id: historySessionID!,
+                seq: historyNextSeq,
+                type: sessionUpdatedType(),
+                data: { sessionID: historySessionID!, info: { title: "from source history" } },
+              },
+            ])
+          }
+          if (call.url.pathname === "/warp-target/sync/replay")
+            return yield* HttpServerResponse.json({ sessionID: "ok" })
+          if (call.url.pathname === "/warp-target/sync/steal")
+            return yield* HttpServerResponse.json({ sessionID: "ok" })
+          return HttpServerResponse.text("unexpected", { status: 500 })
+        }),
+      )
+      const url = yield* serverUrl()
+      yield* provideTmpdirInstance(
+        () =>
+          Effect.gen(function* () {
+            const workspace = yield* WorkspaceOld.Service
+            const sessionSvc = yield* SessionNs.Service
+            const previousType = unique("warp-remote-source")
+            const targetType = unique("warp-remote-target")
+            const previous = workspaceInfo(Instance.project.id, previousType)
+            const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" })
+            insertWorkspace(previous)
+            insertWorkspace(target)
+            registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter)
+            registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter)
+            const session = yield* sessionSvc.create({})
+            attachSessionToWorkspace(session.id, previous.id)
+            historySessionID = session.id
+            historyNextSeq = (sessionSequence(session.id) ?? -1) + 1
+
+            yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
+
+            expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([
+              "POST /warp-source/sync/history",
+              "POST /warp-target/sync/replay",
+              "POST /warp-target/sync/steal",
+            ])
+            expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 })
+            expect(calls[1].json).toMatchObject({
+              directory: "remote-target-dir",
+              events: [
+                {
+                  aggregateID: session.id,
+                  seq: 0,
+                  type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version),
+                },
+                {
+                  aggregateID: session.id,
+                  seq: historyNextSeq,
+                  type: sessionUpdatedType(),
+                },
+              ],
+            })
+            expect(calls[2].json).toEqual({ sessionID: session.id })
+            expect((yield* sessionSvc.get(session.id)).title).toBe("from source history")
+            expect(sessionSequenceOwner(session.id)).toBe(target.id)
+          }),
+        { git: true },
+      )
+    })
+  })
 })
 
 describe("workspace-old sync state", () => {
@@ -1215,313 +1317,3 @@ describe("workspace-old waitForSync", () => {
     })
   }, 7000)
 })
-
-describe("workspace-old sessionRestore", () => {
-  test("throws when the workspace is missing", async () => {
-    await withInstance(async () => {
-      await expect(
-        restoreWorkspaceSession({
-          workspaceID: WorkspaceID.ascending("wrk_restore_missing"),
-          sessionID: SessionID.descending("ses_restore_missing_workspace"),
-        }),
-      ).rejects.toThrow("Workspace not found: wrk_restore_missing")
-    })
-  })
-
-  test("throws when switching a missing session fails", async () => {
-    await withInstance(async (dir) => {
-      const type = unique("restore-missing-session")
-      const info = workspaceInfo(Instance.project.id, type, { directory: dir })
-      insertWorkspace(info)
-      registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
-
-      await expect(
-        restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
-      ).rejects.toThrow("NotFoundError")
-      await removeWorkspace(info.id)
-    })
-  })
-
-  it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => {
-    const replay: FetchCall[] = []
-    return Effect.gen(function* () {
-      yield* HttpServer.serveEffect()(
-        Effect.gen(function* () {
-          const req = yield* HttpServerRequest.HttpServerRequest
-          const bodyText = yield* req.text
-          const call = {
-            url: new URL(req.url, "http://localhost"),
-            method: req.method,
-            headers: new Headers(req.headers),
-            bodyText,
-            json: bodyText ? JSON.parse(bodyText) : undefined,
-          }
-          if (call.url.pathname === "/restore/sync/replay") {
-            replay.push(call)
-            return HttpServerResponse.fromWeb(Response.json({ ok: true }))
-          }
-          return HttpServerResponse.text("unexpected", { status: 500 })
-        }),
-      )
-      const url = yield* serverUrl()
-      yield* provideTmpdirInstance(
-        (dir) =>
-          Effect.gen(function* () {
-            const workspace = yield* WorkspaceOld.Service
-            const sessionSvc = yield* SessionNs.Service
-            const captured = captureGlobalEvents()
-            try {
-              const type = unique("restore-remote")
-              const info = workspaceInfo(Instance.project.id, type, { directory: dir })
-              insertWorkspace(info)
-              registerAdapter(
-                Instance.project.id,
-                type,
-                remoteAdapter(`${url}/restore/?ignored=1#hash`, {
-                  directory: dir,
-                  headers: { authorization: "Bearer restore" },
-                }).adapter,
-              )
-              const session = yield* sessionSvc.create({ title: "restore remote" })
-              replaceSessionEvents(session.id, 24)
-
-              const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })
-
-              expect(result).toEqual({ total: 3 })
-              expect(replay).toHaveLength(3)
-              expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([
-                "/restore/sync/replay",
-                "/restore/sync/replay",
-                "/restore/sync/replay",
-              ])
-              expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true)
-              expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true)
-              expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5])
-              expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir])
-              expect(
-                replay.flatMap((call) =>
-                  (call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq),
-                ),
-              ).toEqual(Array.from({ length: 25 }, (_, i) => i))
-              expect(
-                (replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1),
-              ).toMatchObject({
-                seq: 24,
-                type: sessionUpdatedType(),
-                data: { sessionID: session.id, info: { workspaceID: info.id } },
-              })
-              expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
-              expect(
-                captured.events
-                  .filter(
-                    (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
-                  )
-                  .map((event) => event.payload.properties.step),
-              ).toEqual([0, 1, 2, 3])
-              yield* workspace.remove(info.id)
-            } finally {
-              captured.dispose()
-            }
-          }),
-        { git: true },
-      )
-    })
-  })
-
-  it.live("remote restore sends an empty directory string when the workspace directory is null", () => {
-    const replay: FetchCall[] = []
-    return Effect.gen(function* () {
-      yield* HttpServer.serveEffect()(
-        Effect.gen(function* () {
-          const req = yield* HttpServerRequest.HttpServerRequest
-          const bodyText = yield* req.text
-          replay.push({
-            url: new URL(req.url, "http://localhost"),
-            method: req.method,
-            headers: new Headers(req.headers),
-            bodyText,
-            json: bodyText ? JSON.parse(bodyText) : undefined,
-          })
-          return HttpServerResponse.fromWeb(Response.json({ ok: true }))
-        }),
-      )
-      const url = yield* serverUrl()
-      yield* provideTmpdirInstance(
-        () =>
-          Effect.gen(function* () {
-            const workspace = yield* WorkspaceOld.Service
-            const sessionSvc = yield* SessionNs.Service
-            const type = unique("restore-null-dir")
-            const info = workspaceInfo(Instance.project.id, type, { directory: null })
-            insertWorkspace(info)
-            registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter)
-            const session = yield* sessionSvc.create({ title: "null dir" })
-            replaceSessionEvents(session.id, 0)
-
-            expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
-              total: 1,
-            })
-            expect((replay[0].json as { directory: string }).directory).toBe("")
-            expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1)
-            yield* workspace.remove(info.id)
-          }),
-        { git: true },
-      )
-    })
-  })
-
-  it.live("remote restore failures include status and body and do not emit completed batch progress", () => {
-    const replay: FetchCall[] = []
-    return Effect.gen(function* () {
-      yield* HttpServer.serveEffect()(
-        Effect.gen(function* () {
-          const req = yield* HttpServerRequest.HttpServerRequest
-          const bodyText = yield* req.text
-          replay.push({
-            url: new URL(req.url, "http://localhost"),
-            method: req.method,
-            headers: new Headers(req.headers),
-            bodyText,
-            json: bodyText ? JSON.parse(bodyText) : undefined,
-          })
-          return HttpServerResponse.text("replay failed", { status: 503 })
-        }),
-      )
-      const url = yield* serverUrl()
-      yield* provideTmpdirInstance(
-        (dir) =>
-          Effect.gen(function* () {
-            const workspace = yield* WorkspaceOld.Service
-            const sessionSvc = yield* SessionNs.Service
-            const captured = captureGlobalEvents()
-            try {
-              const type = unique("restore-remote-fail")
-              const info = workspaceInfo(Instance.project.id, type, { directory: dir })
-              insertWorkspace(info)
-              registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter)
-              const session = yield* sessionSvc.create({ title: "restore fail" })
-              replaceSessionEvents(session.id, 11)
-
-              const error = yield* Effect.flip(
-                workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }),
-              )
-              expect((error as Error).message).toContain(
-                `Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`,
-              )
-
-              expect(replay).toHaveLength(1)
-              expect(
-                captured.events
-                  .filter(
-                    (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
-                  )
-                  .map((event) => event.payload.properties.step),
-              ).toEqual([0])
-              yield* workspace.remove(info.id)
-            } finally {
-              captured.dispose()
-            }
-          }),
-        { git: true },
-      )
-    })
-  })
-
-  it.live("local restore replays batches and emits progress", () =>
-    provideTmpdirInstance(
-      (dir) =>
-        Effect.gen(function* () {
-          const workspace = yield* WorkspaceOld.Service
-          const sessionSvc = yield* SessionNs.Service
-          const captured = captureGlobalEvents()
-          try {
-            const type = unique("restore-local")
-            const info = workspaceInfo(Instance.project.id, type, { directory: dir })
-            insertWorkspace(info)
-            registerAdapter(Instance.project.id, type, localAdapter(dir).adapter)
-            const session = yield* sessionSvc.create({ title: "restore local" })
-            replaceSessionEvents(session.id, 20)
-
-            expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
-              total: 3,
-            })
-            expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id)
-            expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i))
-            expect(
-              captured.events
-                .filter(
-                  (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type,
-                )
-                .map((event) => event.payload.properties.step),
-            ).toEqual([0, 1, 2, 3])
-            yield* workspace.remove(info.id)
-          } finally {
-            captured.dispose()
-          }
-        }),
-      { git: true },
-    ),
-  )
-
-  it.live("session restore includes real message and part events in sequence order", () => {
-    const replay: FetchCall[] = []
-    return Effect.gen(function* () {
-      yield* HttpServer.serveEffect()(
-        Effect.gen(function* () {
-          const req = yield* HttpServerRequest.HttpServerRequest
-          const bodyText = yield* req.text
-          replay.push({
-            url: new URL(req.url, "http://localhost"),
-            method: req.method,
-            headers: new Headers(req.headers),
-            bodyText,
-            json: bodyText ? JSON.parse(bodyText) : undefined,
-          })
-          return HttpServerResponse.fromWeb(Response.json({ ok: true }))
-        }),
-      )
-      const url = yield* serverUrl()
-      yield* provideTmpdirInstance(
-        (dir) =>
-          Effect.gen(function* () {
-            const workspace = yield* WorkspaceOld.Service
-            const sessionSvc = yield* SessionNs.Service
-            const type = unique("restore-real-events")
-            const info = workspaceInfo(Instance.project.id, type, { directory: dir })
-            insertWorkspace(info)
-            registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter)
-            const session = yield* sessionSvc.create({ title: "real events" })
-            for (let i = 0; i < 3; i++) {
-              const msg = yield* sessionSvc.updateMessage({
-                id: MessageID.ascending(),
-                role: "user",
-                sessionID: session.id,
-                agent: "build",
-                model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
-                time: { created: Date.now() },
-              })
-              yield* sessionSvc.updatePart({
-                id: PartID.ascending(),
-                sessionID: session.id,
-                messageID: msg.id,
-                type: "text",
-                text: `message ${i}`,
-              })
-            }
-            const before = eventRows(session.id)
-
-            expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({
-              total: 1,
-            })
-
-            const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events
-            expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1])
-            expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type))
-            expect(posted.at(-1)?.type).toBe(sessionUpdatedType())
-            yield* workspace.remove(info.id)
-          }),
-        { git: true },
-      )
-    })
-  })
-})

+ 7 - 12
packages/opencode/test/server/httpapi-workspace.test.ts

@@ -168,22 +168,19 @@ describe("workspace HttpApi", () => {
       const created = yield* request(WorkspacePaths.list, dir, {
         method: "POST",
         headers: { "content-type": "application/json" },
-        body: JSON.stringify({ type: "local-test", branch: null, extra: null }),
+        body: JSON.stringify({ type: "local-test", branch: null }),
       })
       expect(created.status).toBe(200)
       const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
       expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
 
       const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir))
-      const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, {
+      const warped = yield* request(WorkspacePaths.warp, dir, {
         method: "POST",
         headers: { "content-type": "application/json" },
-        body: JSON.stringify({ sessionID: session.id }),
-      })
-      expect(restored.status).toBe(200)
-      expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({
-        total: expect.any(Number),
+        body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
       })
+      expect(warped.status).toBe(204)
 
       const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
       expect(removed.status).toBe(200)
@@ -212,7 +209,6 @@ describe("workspace HttpApi", () => {
       expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
         type: "local-test",
         name: "local-test",
-        extra: null,
       })
     }),
   )
@@ -257,7 +253,6 @@ describe("workspace HttpApi", () => {
       expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
         type: "local-test",
         name: "local-test",
-        extra: null,
       })
     }),
   )
@@ -272,7 +267,7 @@ describe("workspace HttpApi", () => {
       const created = yield* request(WorkspacePaths.list, dir, {
         method: "POST",
         headers: { "content-type": "application/json" },
-        body: JSON.stringify({ type: "local-target", branch: null, extra: null }),
+        body: JSON.stringify({ type: "local-target", branch: null }),
       })
       const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
 
@@ -327,7 +322,7 @@ describe("workspace HttpApi", () => {
       const created = yield* request(WorkspacePaths.list, dir, {
         method: "POST",
         headers: { "content-type": "application/json" },
-        body: JSON.stringify({ type: "remote-target", branch: null, extra: null }),
+        body: JSON.stringify({ type: "remote-target", branch: null }),
       })
       const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
 
@@ -394,7 +389,7 @@ describe("workspace HttpApi", () => {
       const created = yield* request(WorkspacePaths.list, dir, {
         method: "POST",
         headers: { "content-type": "application/json" },
-        body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }),
+        body: JSON.stringify({ type: "remote-session-target", branch: null }),
       })
       const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
       const session = yield* Session.Service.use((svc) => svc.create()).pipe(

+ 72 - 1
packages/opencode/test/sync/index.test.ts

@@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Bus } from "../../src/bus"
 import { SyncEvent } from "../../src/sync"
 import { Database } from "@/storage/db"
-import { EventTable } from "../../src/sync/event.sql"
+import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
 import { MessageID } from "../../src/session/schema"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { initProjectors } from "../../src/server/projectors"
@@ -252,5 +252,76 @@ describe("SyncEvent", () => {
         }),
       ),
     )
+
+    it.live(
+      "claims unowned event sequence on replay with ownerID",
+      provideTmpdirInstance(() =>
+        Effect.gen(function* () {
+          const { Created } = setup()
+          const id = MessageID.ascending()
+
+          yield* SyncEvent.use.replay(
+            {
+              id: "evt_1",
+              type: SyncEvent.versionedType(Created.type, Created.version),
+              seq: 0,
+              aggregateID: id,
+              data: { id, name: "owned" },
+            },
+            { publish: false, ownerID: "owner-1" },
+          )
+
+          const row = Database.use((db) =>
+            db
+              .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
+              .from(EventSequenceTable)
+              .get(),
+          )
+          expect(row).toEqual({ seq: 0, ownerID: "owner-1" })
+        }),
+      ),
+    )
+
+    it.live(
+      "ignores replay from a different owner after sequence is claimed",
+      provideTmpdirInstance(() =>
+        Effect.gen(function* () {
+          const { Created } = setup()
+          const id = MessageID.ascending()
+
+          yield* SyncEvent.use.replay(
+            {
+              id: "evt_1",
+              type: SyncEvent.versionedType(Created.type, Created.version),
+              seq: 0,
+              aggregateID: id,
+              data: { id, name: "first" },
+            },
+            { publish: false, ownerID: "owner-1" },
+          )
+          yield* SyncEvent.use.replay(
+            {
+              id: "evt_2",
+              type: SyncEvent.versionedType(Created.type, Created.version),
+              seq: 1,
+              aggregateID: id,
+              data: { id, name: "ignored" },
+            },
+            { publish: false, ownerID: "owner-2" },
+          )
+
+          const events = Database.use((db) => db.select().from(EventTable).all())
+          const sequence = Database.use((db) =>
+            db
+              .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
+              .from(EventSequenceTable)
+              .get(),
+          )
+          expect(events).toHaveLength(1)
+          expect(events[0].id).toBe("evt_1")
+          expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" })
+        }),
+      ),
+    )
   })
 })

+ 196 - 70
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -4,58 +4,84 @@ import { client } from "./client.gen.js"
 import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js"
 import type {
   AgentPartInput,
+  AppAgentsErrors,
   AppAgentsResponses,
   AppLogErrors,
   AppLogResponses,
+  AppSkillsErrors,
   AppSkillsResponses,
   Auth as Auth3,
   AuthRemoveErrors,
   AuthRemoveResponses,
   AuthSetErrors,
   AuthSetResponses,
+  CommandListErrors,
   CommandListResponses,
   Config as Config3,
+  ConfigGetErrors,
   ConfigGetResponses,
+  ConfigProvidersErrors,
   ConfigProvidersResponses,
   ConfigUpdateErrors,
   ConfigUpdateResponses,
+  EventSubscribeErrors,
   EventSubscribeResponses,
   EventTuiCommandExecute2,
   EventTuiPromptAppend2,
   EventTuiSessionSelect2,
   EventTuiToastShow2,
+  ExperimentalConsoleGetErrors,
   ExperimentalConsoleGetResponses,
+  ExperimentalConsoleListOrgsErrors,
   ExperimentalConsoleListOrgsResponses,
   ExperimentalConsoleSwitchOrgResponses,
+  ExperimentalResourceListErrors,
   ExperimentalResourceListResponses,
+  ExperimentalSessionListErrors,
   ExperimentalSessionListResponses,
+  ExperimentalWorkspaceAdapterListErrors,
   ExperimentalWorkspaceAdapterListResponses,
   ExperimentalWorkspaceCreateErrors,
   ExperimentalWorkspaceCreateResponses,
+  ExperimentalWorkspaceListErrors,
   ExperimentalWorkspaceListResponses,
   ExperimentalWorkspaceRemoveErrors,
   ExperimentalWorkspaceRemoveResponses,
-  ExperimentalWorkspaceSessionRestoreErrors,
-  ExperimentalWorkspaceSessionRestoreResponses,
+  ExperimentalWorkspaceStatusErrors,
   ExperimentalWorkspaceStatusResponses,
+  ExperimentalWorkspaceWarpErrors,
+  ExperimentalWorkspaceWarpResponses,
+  FileListErrors,
   FileListResponses,
   FilePartInput,
   FilePartSource,
+  FileReadErrors,
   FileReadResponses,
+  FileStatusErrors,
   FileStatusResponses,
+  FindFilesErrors,
   FindFilesResponses,
+  FindSymbolsErrors,
   FindSymbolsResponses,
+  FindTextErrors,
   FindTextResponses,
+  FormatterStatusErrors,
   FormatterStatusResponses,
+  GlobalConfigGetErrors,
   GlobalConfigGetResponses,
   GlobalConfigUpdateErrors,
   GlobalConfigUpdateResponses,
+  GlobalDisposeErrors,
   GlobalDisposeResponses,
+  GlobalEventErrors,
   GlobalEventResponses,
+  GlobalHealthErrors,
   GlobalHealthResponses,
   GlobalUpgradeErrors,
   GlobalUpgradeResponses,
+  InstanceDisposeErrors,
   InstanceDisposeResponses,
+  LspStatusErrors,
   LspStatusResponses,
   McpAddErrors,
   McpAddResponses,
@@ -67,10 +93,13 @@ import type {
   McpAuthRemoveResponses,
   McpAuthStartErrors,
   McpAuthStartResponses,
+  McpConnectErrors,
   McpConnectResponses,
+  McpDisconnectErrors,
   McpDisconnectResponses,
   McpLocalConfig,
   McpRemoteConfig,
+  McpStatusErrors,
   McpStatusResponses,
   OutputFormat,
   Part as Part2,
@@ -78,20 +107,27 @@ import type {
   PartDeleteResponses,
   PartUpdateErrors,
   PartUpdateResponses,
+  PathGetErrors,
   PathGetResponses,
+  PermissionListErrors,
   PermissionListResponses,
   PermissionReplyErrors,
   PermissionReplyResponses,
   PermissionRespondErrors,
   PermissionRespondResponses,
   PermissionRuleset,
+  ProjectCurrentErrors,
   ProjectCurrentResponses,
+  ProjectInitGitErrors,
   ProjectInitGitResponses,
+  ProjectListErrors,
   ProjectListResponses,
   ProjectUpdateErrors,
   ProjectUpdateResponses,
   Prompt,
+  ProviderAuthErrors,
   ProviderAuthResponses,
+  ProviderListErrors,
   ProviderListResponses,
   ProviderOauthAuthorizeErrors,
   ProviderOauthAuthorizeResponses,
@@ -105,13 +141,16 @@ import type {
   PtyCreateResponses,
   PtyGetErrors,
   PtyGetResponses,
+  PtyListErrors,
   PtyListResponses,
   PtyRemoveErrors,
   PtyRemoveResponses,
+  PtyShellsErrors,
   PtyShellsResponses,
   PtyUpdateErrors,
   PtyUpdateResponses,
   QuestionAnswer,
+  QuestionListErrors,
   QuestionListResponses,
   QuestionRejectErrors,
   QuestionRejectResponses,
@@ -130,12 +169,15 @@ import type {
   SessionDeleteMessageResponses,
   SessionDeleteResponses,
   SessionDelivery,
+  SessionDiffErrors,
   SessionDiffResponses,
+  SessionForkErrors,
   SessionForkResponses,
   SessionGetErrors,
   SessionGetResponses,
   SessionInitErrors,
   SessionInitResponses,
+  SessionListErrors,
   SessionListResponses,
   SessionMessageErrors,
   SessionMessageResponses,
@@ -168,7 +210,10 @@ import type {
   SyncHistoryListResponses,
   SyncReplayErrors,
   SyncReplayResponses,
+  SyncStartErrors,
   SyncStartResponses,
+  SyncStealErrors,
+  SyncStealResponses,
   TextPartInput,
   ToolIdsErrors,
   ToolIdsResponses,
@@ -176,34 +221,50 @@ import type {
   ToolListResponses,
   TuiAppendPromptErrors,
   TuiAppendPromptResponses,
+  TuiClearPromptErrors,
   TuiClearPromptResponses,
+  TuiControlNextErrors,
   TuiControlNextResponses,
+  TuiControlResponseErrors,
   TuiControlResponseResponses,
   TuiExecuteCommandErrors,
   TuiExecuteCommandResponses,
+  TuiOpenHelpErrors,
   TuiOpenHelpResponses,
+  TuiOpenModelsErrors,
   TuiOpenModelsResponses,
+  TuiOpenSessionsErrors,
   TuiOpenSessionsResponses,
+  TuiOpenThemesErrors,
   TuiOpenThemesResponses,
   TuiPublishErrors,
   TuiPublishResponses,
   TuiSelectSessionErrors,
   TuiSelectSessionResponses,
+  TuiShowToastErrors,
   TuiShowToastResponses,
+  TuiSubmitPromptErrors,
   TuiSubmitPromptResponses,
+  V2SessionCompactErrors,
   V2SessionCompactResponses,
+  V2SessionContextErrors,
   V2SessionContextResponses,
   V2SessionListErrors,
   V2SessionListResponses,
   V2SessionMessagesErrors,
   V2SessionMessagesResponses,
+  V2SessionPromptErrors,
   V2SessionPromptResponses,
+  V2SessionWaitErrors,
   V2SessionWaitResponses,
+  VcsDiffErrors,
   VcsDiffResponses,
+  VcsGetErrors,
   VcsGetResponses,
   WorktreeCreateErrors,
   WorktreeCreateInput,
   WorktreeCreateResponses,
+  WorktreeListErrors,
   WorktreeListResponses,
   WorktreeRemoveErrors,
   WorktreeRemoveInput,
@@ -381,7 +442,7 @@ export class App extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<AppAgentsResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<AppAgentsResponses, AppAgentsErrors, ThrowOnError>({
       url: "/agent",
       ...options,
       ...params,
@@ -411,7 +472,7 @@ export class App extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<AppSkillsResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<AppSkillsResponses, AppSkillsErrors, ThrowOnError>({
       url: "/skill",
       ...options,
       ...params,
@@ -426,7 +487,7 @@ export class Config extends HeyApiClient {
    * Retrieve the current global OpenCode configuration settings and preferences.
    */
   public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
-    return (options?.client ?? this.client).get<GlobalConfigGetResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<GlobalConfigGetResponses, GlobalConfigGetErrors, ThrowOnError>({
       url: "/global/config",
       ...options,
     })
@@ -464,7 +525,7 @@ export class Global extends HeyApiClient {
    * Get health information about the OpenCode server.
    */
   public health<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
-    return (options?.client ?? this.client).get<GlobalHealthResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<GlobalHealthResponses, GlobalHealthErrors, ThrowOnError>({
       url: "/global/health",
       ...options,
     })
@@ -476,7 +537,7 @@ export class Global extends HeyApiClient {
    * Subscribe to global events from the OpenCode system using server-sent events.
    */
   public event<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
-    return (options?.client ?? this.client).sse.get<GlobalEventResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).sse.get<GlobalEventResponses, GlobalEventErrors, ThrowOnError>({
       url: "/global/event",
       ...options,
     })
@@ -488,7 +549,7 @@ export class Global extends HeyApiClient {
    * Clean up and dispose all OpenCode instances, releasing all resources.
    */
   public dispose<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
-    return (options?.client ?? this.client).post<GlobalDisposeResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<GlobalDisposeResponses, GlobalDisposeErrors, ThrowOnError>({
       url: "/global/dispose",
       ...options,
     })
@@ -548,7 +609,7 @@ export class Event extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).sse.get<EventSubscribeResponses, EventSubscribeErrors, ThrowOnError>({
       url: "/event",
       ...options,
       ...params,
@@ -580,7 +641,7 @@ export class Config2 extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ConfigGetResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<ConfigGetResponses, ConfigGetErrors, ThrowOnError>({
       url: "/config",
       ...options,
       ...params,
@@ -647,7 +708,7 @@ export class Config2 extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ConfigProvidersResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<ConfigProvidersResponses, ConfigProvidersErrors, ThrowOnError>({
       url: "/config/providers",
       ...options,
       ...params,
@@ -679,7 +740,11 @@ export class Console extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ExperimentalConsoleGetResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<
+      ExperimentalConsoleGetResponses,
+      ExperimentalConsoleGetErrors,
+      ThrowOnError
+    >({
       url: "/experimental/console",
       ...options,
       ...params,
@@ -709,7 +774,11 @@ export class Console extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ExperimentalConsoleListOrgsResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<
+      ExperimentalConsoleListOrgsResponses,
+      ExperimentalConsoleListOrgsErrors,
+      ThrowOnError
+    >({
       url: "/experimental/console/orgs",
       ...options,
       ...params,
@@ -792,7 +861,11 @@ export class Session extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ExperimentalSessionListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<
+      ExperimentalSessionListResponses,
+      ExperimentalSessionListErrors,
+      ThrowOnError
+    >({
       url: "/experimental/session",
       ...options,
       ...params,
@@ -824,7 +897,11 @@ export class Resource extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ExperimentalResourceListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<
+      ExperimentalResourceListResponses,
+      ExperimentalResourceListErrors,
+      ThrowOnError
+    >({
       url: "/experimental/resource",
       ...options,
       ...params,
@@ -856,7 +933,11 @@ export class Adapter extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ExperimentalWorkspaceAdapterListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<
+      ExperimentalWorkspaceAdapterListResponses,
+      ExperimentalWorkspaceAdapterListErrors,
+      ThrowOnError
+    >({
       url: "/experimental/workspace/adapter",
       ...options,
       ...params,
@@ -888,7 +969,11 @@ export class Workspace extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ExperimentalWorkspaceListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<
+      ExperimentalWorkspaceListResponses,
+      ExperimentalWorkspaceListErrors,
+      ThrowOnError
+    >({
       url: "/experimental/workspace",
       ...options,
       ...params,
@@ -965,7 +1050,11 @@ export class Workspace extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ExperimentalWorkspaceStatusResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<
+      ExperimentalWorkspaceStatusResponses,
+      ExperimentalWorkspaceStatusErrors,
+      ThrowOnError
+    >({
       url: "/experimental/workspace/status",
       ...options,
       ...params,
@@ -1009,15 +1098,15 @@ export class Workspace extends HeyApiClient {
   }
 
   /**
-   * Restore session into workspace
+   * Warp session into workspace
    *
-   * Replay a session's sync events into the target workspace in batches.
+   * Move a session's sync history into the target workspace, or detach it to the local project.
    */
-  public sessionRestore<ThrowOnError extends boolean = false>(
-    parameters: {
-      id: string
+  public warp<ThrowOnError extends boolean = false>(
+    parameters?: {
       directory?: string
       workspace?: string
+      id?: string | null
       sessionID?: string
     },
     options?: Options<never, ThrowOnError>,
@@ -1027,20 +1116,20 @@ export class Workspace extends HeyApiClient {
       [
         {
           args: [
-            { in: "path", key: "id" },
             { in: "query", key: "directory" },
             { in: "query", key: "workspace" },
+            { in: "body", key: "id" },
             { in: "body", key: "sessionID" },
           ],
         },
       ],
     )
     return (options?.client ?? this.client).post<
-      ExperimentalWorkspaceSessionRestoreResponses,
-      ExperimentalWorkspaceSessionRestoreErrors,
+      ExperimentalWorkspaceWarpResponses,
+      ExperimentalWorkspaceWarpErrors,
       ThrowOnError
     >({
-      url: "/experimental/workspace/{id}/session-restore",
+      url: "/experimental/workspace/warp",
       ...options,
       ...params,
       headers: {
@@ -1206,7 +1295,7 @@ export class Worktree extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<WorktreeListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<WorktreeListResponses, WorktreeListErrors, ThrowOnError>({
       url: "/experimental/worktree",
       ...options,
       ...params,
@@ -1314,7 +1403,7 @@ export class Find extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<FindTextResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<FindTextResponses, FindTextErrors, ThrowOnError>({
       url: "/find",
       ...options,
       ...params,
@@ -1352,7 +1441,7 @@ export class Find extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<FindFilesResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<FindFilesResponses, FindFilesErrors, ThrowOnError>({
       url: "/find/file",
       ...options,
       ...params,
@@ -1384,7 +1473,7 @@ export class Find extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<FindSymbolsResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<FindSymbolsResponses, FindSymbolsErrors, ThrowOnError>({
       url: "/find/symbol",
       ...options,
       ...params,
@@ -1418,7 +1507,7 @@ export class File extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<FileListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<FileListResponses, FileListErrors, ThrowOnError>({
       url: "/file",
       ...options,
       ...params,
@@ -1450,7 +1539,7 @@ export class File extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<FileReadResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<FileReadResponses, FileReadErrors, ThrowOnError>({
       url: "/file/content",
       ...options,
       ...params,
@@ -1480,7 +1569,7 @@ export class File extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<FileStatusResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<FileStatusResponses, FileStatusErrors, ThrowOnError>({
       url: "/file/status",
       ...options,
       ...params,
@@ -1512,7 +1601,7 @@ export class Instance extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<InstanceDisposeResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<InstanceDisposeResponses, InstanceDisposeErrors, ThrowOnError>({
       url: "/instance/dispose",
       ...options,
       ...params,
@@ -1544,7 +1633,7 @@ export class Path extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<PathGetResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<PathGetResponses, PathGetErrors, ThrowOnError>({
       url: "/path",
       ...options,
       ...params,
@@ -1576,7 +1665,7 @@ export class Vcs extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<VcsGetResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<VcsGetResponses, VcsGetErrors, ThrowOnError>({
       url: "/vcs",
       ...options,
       ...params,
@@ -1608,7 +1697,7 @@ export class Vcs extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<VcsDiffResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<VcsDiffResponses, VcsDiffErrors, ThrowOnError>({
       url: "/vcs/diff",
       ...options,
       ...params,
@@ -1640,7 +1729,7 @@ export class Command extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<CommandListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<CommandListResponses, CommandListErrors, ThrowOnError>({
       url: "/command",
       ...options,
       ...params,
@@ -1672,7 +1761,7 @@ export class Lsp extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<LspStatusResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<LspStatusResponses, LspStatusErrors, ThrowOnError>({
       url: "/lsp",
       ...options,
       ...params,
@@ -1704,7 +1793,7 @@ export class Formatter extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<FormatterStatusResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<FormatterStatusResponses, FormatterStatusErrors, ThrowOnError>({
       url: "/formatter",
       ...options,
       ...params,
@@ -1875,7 +1964,7 @@ export class Mcp extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<McpStatusResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<McpStatusResponses, McpStatusErrors, ThrowOnError>({
       url: "/mcp",
       ...options,
       ...params,
@@ -1944,7 +2033,7 @@ export class Mcp extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<McpConnectResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<McpConnectResponses, McpConnectErrors, ThrowOnError>({
       url: "/mcp/{name}/connect",
       ...options,
       ...params,
@@ -1974,7 +2063,7 @@ export class Mcp extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<McpDisconnectResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<McpDisconnectResponses, McpDisconnectErrors, ThrowOnError>({
       url: "/mcp/{name}/disconnect",
       ...options,
       ...params,
@@ -2011,7 +2100,7 @@ export class Project extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ProjectListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<ProjectListResponses, ProjectListErrors, ThrowOnError>({
       url: "/project",
       ...options,
       ...params,
@@ -2041,7 +2130,7 @@ export class Project extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ProjectCurrentResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<ProjectCurrentResponses, ProjectCurrentErrors, ThrowOnError>({
       url: "/project/current",
       ...options,
       ...params,
@@ -2071,7 +2160,7 @@ export class Project extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<ProjectInitGitResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<ProjectInitGitResponses, ProjectInitGitErrors, ThrowOnError>({
       url: "/project/git/init",
       ...options,
       ...params,
@@ -2155,7 +2244,7 @@ export class Pty extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<PtyShellsResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<PtyShellsResponses, PtyShellsErrors, ThrowOnError>({
       url: "/pty/shells",
       ...options,
       ...params,
@@ -2185,7 +2274,7 @@ export class Pty extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<PtyListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<PtyListResponses, PtyListErrors, ThrowOnError>({
       url: "/pty",
       ...options,
       ...params,
@@ -2436,7 +2525,7 @@ export class Question extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<QuestionListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<QuestionListResponses, QuestionListErrors, ThrowOnError>({
       url: "/question",
       ...options,
       ...params,
@@ -2539,7 +2628,7 @@ export class Permission extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<PermissionListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<PermissionListResponses, PermissionListErrors, ThrowOnError>({
       url: "/permission",
       ...options,
       ...params,
@@ -2749,7 +2838,7 @@ export class Provider extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ProviderListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<ProviderListResponses, ProviderListErrors, ThrowOnError>({
       url: "/provider",
       ...options,
       ...params,
@@ -2779,7 +2868,7 @@ export class Provider extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<ProviderAuthResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<ProviderAuthResponses, ProviderAuthErrors, ThrowOnError>({
       url: "/provider/auth",
       ...options,
       ...params,
@@ -2828,7 +2917,7 @@ export class Session2 extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<SessionListResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<SessionListResponses, SessionListErrors, ThrowOnError>({
       url: "/session",
       ...options,
       ...params,
@@ -3116,7 +3205,7 @@ export class Session2 extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<SessionDiffResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<SessionDiffResponses, SessionDiffErrors, ThrowOnError>({
       url: "/session/{sessionID}/diff",
       ...options,
       ...params,
@@ -3318,7 +3407,7 @@ export class Session2 extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<SessionForkResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<SessionForkResponses, SessionForkErrors, ThrowOnError>({
       url: "/session/{sessionID}/fork",
       ...options,
       ...params,
@@ -3894,7 +3983,7 @@ export class Sync extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<SyncStartResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<SyncStartResponses, SyncStartErrors, ThrowOnError>({
       url: "/sync/start",
       ...options,
       ...params,
@@ -3956,6 +4045,43 @@ export class Sync extends HeyApiClient {
     })
   }
 
+  /**
+   * Steal session into workspace
+   *
+   * Update a session to belong to the current workspace through the sync event system.
+   */
+  public steal<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+      sessionID?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+            { in: "body", key: "sessionID" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<SyncStealResponses, SyncStealErrors, ThrowOnError>({
+      url: "/sync/steal",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
   private _history?: History
   get history(): History {
     return (this._history ??= new History({ client: this.client }))
@@ -4022,7 +4148,7 @@ export class Session3 extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<V2SessionPromptResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<V2SessionPromptResponses, V2SessionPromptErrors, ThrowOnError>({
       url: "/api/session/{sessionID}/prompt",
       ...options,
       ...params,
@@ -4059,7 +4185,7 @@ export class Session3 extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<V2SessionCompactResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<V2SessionCompactResponses, V2SessionCompactErrors, ThrowOnError>({
       url: "/api/session/{sessionID}/compact",
       ...options,
       ...params,
@@ -4091,7 +4217,7 @@ export class Session3 extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<V2SessionWaitResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<V2SessionWaitResponses, V2SessionWaitErrors, ThrowOnError>({
       url: "/api/session/{sessionID}/wait",
       ...options,
       ...params,
@@ -4123,7 +4249,7 @@ export class Session3 extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<V2SessionContextResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<V2SessionContextResponses, V2SessionContextErrors, ThrowOnError>({
       url: "/api/session/{sessionID}/context",
       ...options,
       ...params,
@@ -4194,7 +4320,7 @@ export class Control extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).get<TuiControlNextResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).get<TuiControlNextResponses, TuiControlNextErrors, ThrowOnError>({
       url: "/tui/control/next",
       ...options,
       ...params,
@@ -4226,7 +4352,7 @@ export class Control extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<TuiControlResponseResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<TuiControlResponseResponses, TuiControlResponseErrors, ThrowOnError>({
       url: "/tui/control/response",
       ...options,
       ...params,
@@ -4300,7 +4426,7 @@ export class Tui extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<TuiOpenHelpResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<TuiOpenHelpResponses, TuiOpenHelpErrors, ThrowOnError>({
       url: "/tui/open-help",
       ...options,
       ...params,
@@ -4330,7 +4456,7 @@ export class Tui extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<TuiOpenSessionsResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<TuiOpenSessionsResponses, TuiOpenSessionsErrors, ThrowOnError>({
       url: "/tui/open-sessions",
       ...options,
       ...params,
@@ -4360,7 +4486,7 @@ export class Tui extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<TuiOpenThemesResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<TuiOpenThemesResponses, TuiOpenThemesErrors, ThrowOnError>({
       url: "/tui/open-themes",
       ...options,
       ...params,
@@ -4390,7 +4516,7 @@ export class Tui extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<TuiOpenModelsResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<TuiOpenModelsResponses, TuiOpenModelsErrors, ThrowOnError>({
       url: "/tui/open-models",
       ...options,
       ...params,
@@ -4420,7 +4546,7 @@ export class Tui extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<TuiSubmitPromptResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<TuiSubmitPromptResponses, TuiSubmitPromptErrors, ThrowOnError>({
       url: "/tui/submit-prompt",
       ...options,
       ...params,
@@ -4450,7 +4576,7 @@ export class Tui extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<TuiClearPromptResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<TuiClearPromptResponses, TuiClearPromptErrors, ThrowOnError>({
       url: "/tui/clear-prompt",
       ...options,
       ...params,
@@ -4525,7 +4651,7 @@ export class Tui extends HeyApiClient {
         },
       ],
     )
-    return (options?.client ?? this.client).post<TuiShowToastResponses, unknown, ThrowOnError>({
+    return (options?.client ?? this.client).post<TuiShowToastResponses, TuiShowToastErrors, ThrowOnError>({
       url: "/tui/show-toast",
       ...options,
       ...params,

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 546 - 15
packages/sdk/js/src/v2/gen/types.gen.ts


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 612 - 4
packages/sdk/openapi.json


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio