Browse Source

core: make account login upgrades safe while adding multi-account workspace auth (#15487)

Co-authored-by: Kit Langton <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
Dax 1 month ago
parent
commit
613562f504
41 changed files with 4166 additions and 163 deletions
  1. 4 0
      AGENTS.md
  2. 7 4
      bun.lock
  3. 1 0
      package.json
  4. 17 0
      packages/opencode/migration/20260228203230_blue_harpoon/migration.sql
  5. 1102 0
      packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json
  6. 3 0
      packages/opencode/migration/20260309230000_move_org_to_state/migration.sql
  7. 1215 0
      packages/opencode/migration/20260309230000_move_org_to_state/snapshot.json
  8. 2 0
      packages/opencode/package.json
  9. 35 0
      packages/opencode/src/account/account.sql.ts
  10. 43 0
      packages/opencode/src/account/index.ts
  11. 138 0
      packages/opencode/src/account/repo.ts
  12. 73 0
      packages/opencode/src/account/schema.ts
  13. 384 0
      packages/opencode/src/account/service.ts
  14. 196 0
      packages/opencode/src/cli/cmd/account.ts
  15. 24 3
      packages/opencode/src/cli/cmd/import.ts
  16. 16 28
      packages/opencode/src/cli/cmd/providers.ts
  17. 1 1
      packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
  18. 12 2
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  19. 25 0
      packages/opencode/src/cli/effect/prompt.ts
  20. 28 5
      packages/opencode/src/config/config.ts
  21. 1 1
      packages/opencode/src/control-plane/workspace.sql.ts
  22. 0 22
      packages/opencode/src/control/control.sql.ts
  23. 0 67
      packages/opencode/src/control/index.ts
  24. 4 0
      packages/opencode/src/effect/runtime.ts
  25. 7 2
      packages/opencode/src/index.ts
  26. 1 1
      packages/opencode/src/project/project.sql.ts
  27. 3 3
      packages/opencode/src/session/session.sql.ts
  28. 73 15
      packages/opencode/src/share/share-next.ts
  29. 1 1
      packages/opencode/src/share/share.sql.ts
  30. 3 3
      packages/opencode/src/storage/db.ts
  31. 2 2
      packages/opencode/src/storage/schema.ts
  32. 11 0
      packages/opencode/src/util/effect-http-client.ts
  33. 17 0
      packages/opencode/src/util/schema.ts
  34. 338 0
      packages/opencode/test/account/repo.test.ts
  35. 223 0
      packages/opencode/test/account/service.test.ts
  36. 19 1
      packages/opencode/test/cli/import.test.ts
  37. 1 1
      packages/opencode/test/cli/plugin-auth-picker.test.ts
  38. 47 0
      packages/opencode/test/config/config.test.ts
  39. 7 0
      packages/opencode/test/fixture/effect.ts
  40. 76 0
      packages/opencode/test/share/share-next.test.ts
  41. 6 1
      packages/opencode/tsconfig.json

+ 4 - 0
AGENTS.md

@@ -122,3 +122,7 @@ const table = sqliteTable("session", {
 - Avoid mocks as much as possible
 - Test actual implementation, do not duplicate logic into tests
 - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
+
+## Type Checking
+
+- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.

File diff suppressed because it is too large
+ 7 - 4
bun.lock


+ 1 - 0
package.json

@@ -43,6 +43,7 @@
       "dompurify": "3.3.1",
       "drizzle-kit": "1.0.0-beta.16-ea816b6",
       "drizzle-orm": "1.0.0-beta.16-ea816b6",
+      "effect": "4.0.0-beta.29",
       "ai": "5.0.124",
       "hono": "4.10.7",
       "hono-openapi": "1.1.2",

+ 17 - 0
packages/opencode/migration/20260228203230_blue_harpoon/migration.sql

@@ -0,0 +1,17 @@
+CREATE TABLE `account` (
+	`id` text PRIMARY KEY,
+	`email` text NOT NULL,
+	`url` text NOT NULL,
+	`access_token` text NOT NULL,
+	`refresh_token` text NOT NULL,
+	`token_expiry` integer,
+	`selected_org_id` text,
+	`time_created` integer NOT NULL,
+	`time_updated` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `account_state` (
+	`id` integer PRIMARY KEY NOT NULL,
+	`active_account_id` text,
+	FOREIGN KEY (`active_account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE set null
+);

+ 1102 - 0
packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json

@@ -0,0 +1,1102 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "id": "325559b7-104f-4d2a-a02c-934cfad7cfcc",
+  "prevIds": ["1f1dbf2d-bf66-4b25-8af4-4ba7633b7e40"],
+  "ddl": [
+    {
+      "name": "account",
+      "entityType": "tables"
+    },
+    {
+      "name": "account_state",
+      "entityType": "tables"
+    },
+    {
+      "name": "control_account",
+      "entityType": "tables"
+    },
+    {
+      "name": "workspace",
+      "entityType": "tables"
+    },
+    {
+      "name": "project",
+      "entityType": "tables"
+    },
+    {
+      "name": "message",
+      "entityType": "tables"
+    },
+    {
+      "name": "part",
+      "entityType": "tables"
+    },
+    {
+      "name": "permission",
+      "entityType": "tables"
+    },
+    {
+      "name": "session",
+      "entityType": "tables"
+    },
+    {
+      "name": "todo",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_share",
+      "entityType": "tables"
+    },
+    {
+      "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": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "selected_org_id",
+      "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": "integer",
+      "notNull": true,
+      "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": 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": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "branch",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "config",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "worktree",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "vcs",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_color",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_initialized",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "sandboxes",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "commands",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "message_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "parent_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "slug",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "title",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "version",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "share_url",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_additions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_deletions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_files",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_diffs",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "revert",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "permission",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_compacting",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_archived",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "content",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "status",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "priority",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "position",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "secret",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "columns": ["project_id"],
+      "tableTo": "project",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_workspace_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "workspace"
+    },
+    {
+      "columns": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_message_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "message"
+    },
+    {
+      "columns": ["message_id"],
+      "tableTo": "message",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_part_message_id_message_id_fk",
+      "entityType": "fks",
+      "table": "part"
+    },
+    {
+      "columns": ["project_id"],
+      "tableTo": "project",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_permission_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "permission"
+    },
+    {
+      "columns": ["project_id"],
+      "tableTo": "project",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "session"
+    },
+    {
+      "columns": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_todo_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "todo"
+    },
+    {
+      "columns": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_share_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_share"
+    },
+    {
+      "columns": ["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": ["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_pk",
+      "table": "account",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "account_state_pk",
+      "table": "account_state",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "workspace_pk",
+      "table": "workspace",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "project_pk",
+      "table": "project",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "message_pk",
+      "table": "message",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "part_pk",
+      "table": "part",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["project_id"],
+      "nameExplicit": false,
+      "name": "permission_pk",
+      "table": "permission",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "session_pk",
+      "table": "session",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["session_id"],
+      "nameExplicit": false,
+      "name": "session_share_pk",
+      "table": "session_share",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "message_session_idx",
+      "entityType": "indexes",
+      "table": "message"
+    },
+    {
+      "columns": [
+        {
+          "value": "message_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_message_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_session_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "project_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_project_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "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": []
+}

+ 3 - 0
packages/opencode/migration/20260309230000_move_org_to_state/migration.sql

@@ -0,0 +1,3 @@
+ALTER TABLE `account_state` ADD `active_org_id` text;--> statement-breakpoint
+UPDATE `account_state` SET `active_org_id` = (SELECT `selected_org_id` FROM `account` WHERE `account`.`id` = `account_state`.`active_account_id`);--> statement-breakpoint
+ALTER TABLE `account` DROP COLUMN `selected_org_id`;

+ 1215 - 0
packages/opencode/migration/20260309230000_move_org_to_state/snapshot.json

@@ -0,0 +1,1215 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "id": "fb311f30-9948-4131-b15c-7d308478a878",
+  "prevIds": [
+    "325559b7-104f-4d2a-a02c-934cfad7cfcc",
+    "4ec9de62-88a7-4bec-91cc-0a759e84db21"
+  ],
+  "ddl": [
+    {
+      "name": "account_state",
+      "entityType": "tables"
+    },
+    {
+      "name": "account",
+      "entityType": "tables"
+    },
+    {
+      "name": "control_account",
+      "entityType": "tables"
+    },
+    {
+      "name": "workspace",
+      "entityType": "tables"
+    },
+    {
+      "name": "project",
+      "entityType": "tables"
+    },
+    {
+      "name": "message",
+      "entityType": "tables"
+    },
+    {
+      "name": "part",
+      "entityType": "tables"
+    },
+    {
+      "name": "permission",
+      "entityType": "tables"
+    },
+    {
+      "name": "session",
+      "entityType": "tables"
+    },
+    {
+      "name": "todo",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_share",
+      "entityType": "tables"
+    },
+    {
+      "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": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "branch",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "extra",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "worktree",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "vcs",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_color",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_initialized",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "sandboxes",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "commands",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "message_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "parent_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "slug",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "title",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "version",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "share_url",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_additions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_deletions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_files",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_diffs",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "revert",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "permission",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_compacting",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_archived",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "content",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "status",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "priority",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "position",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "secret",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "columns": [
+        "active_account_id"
+      ],
+      "tableTo": "account",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "SET NULL",
+      "nameExplicit": false,
+      "name": "fk_account_state_active_account_id_account_id_fk",
+      "entityType": "fks",
+      "table": "account_state"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_workspace_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "workspace"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_message_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "message"
+    },
+    {
+      "columns": [
+        "message_id"
+      ],
+      "tableTo": "message",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_part_message_id_message_id_fk",
+      "entityType": "fks",
+      "table": "part"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_permission_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "permission"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "session"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_todo_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "todo"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_share_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_share"
+    },
+    {
+      "columns": [
+        "email",
+        "url"
+      ],
+      "nameExplicit": false,
+      "name": "control_account_pk",
+      "entityType": "pks",
+      "table": "control_account"
+    },
+    {
+      "columns": [
+        "session_id",
+        "position"
+      ],
+      "nameExplicit": false,
+      "name": "todo_pk",
+      "entityType": "pks",
+      "table": "todo"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "account_state_pk",
+      "table": "account_state",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "account_pk",
+      "table": "account",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "workspace_pk",
+      "table": "workspace",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "project_pk",
+      "table": "project",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "message_pk",
+      "table": "message",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "part_pk",
+      "table": "part",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "nameExplicit": false,
+      "name": "permission_pk",
+      "table": "permission",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "session_pk",
+      "table": "session",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "nameExplicit": false,
+      "name": "session_share_pk",
+      "table": "session_share",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "message_session_idx",
+      "entityType": "indexes",
+      "table": "message"
+    },
+    {
+      "columns": [
+        {
+          "value": "message_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_message_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_session_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "project_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_project_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_workspace_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "parent_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_parent_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "todo_session_idx",
+      "entityType": "indexes",
+      "table": "todo"
+    }
+  ],
+  "renames": []
+}

+ 2 - 0
packages/opencode/package.json

@@ -27,6 +27,7 @@
   },
   "devDependencies": {
     "@babel/core": "7.28.4",
+    "@effect/language-service": "0.79.0",
     "@octokit/webhooks-types": "7.6.1",
     "@opencode-ai/script": "workspace:*",
     "@parcel/watcher-darwin-arm64": "2.5.1",
@@ -108,6 +109,7 @@
     "decimal.js": "10.5.0",
     "diff": "catalog:",
     "drizzle-orm": "1.0.0-beta.16-ea816b6",
+    "effect": "catalog:",
     "fuzzysort": "3.1.0",
     "glob": "13.0.5",
     "google-auth-library": "10.5.0",

+ 35 - 0
packages/opencode/src/account/account.sql.ts

@@ -0,0 +1,35 @@
+import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
+import { Timestamps } from "../storage/schema.sql"
+
+export const AccountTable = sqliteTable("account", {
+  id: text().primaryKey(),
+  email: text().notNull(),
+  url: text().notNull(),
+  access_token: text().notNull(),
+  refresh_token: text().notNull(),
+  token_expiry: integer(),
+  ...Timestamps,
+})
+
+export const AccountStateTable = sqliteTable("account_state", {
+  id: integer().primaryKey(),
+  active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
+  active_org_id: text(),
+})
+
+// LEGACY
+export const ControlAccountTable = sqliteTable(
+  "control_account",
+  {
+    email: text().notNull(),
+    url: text().notNull(),
+    access_token: text().notNull(),
+    refresh_token: text().notNull(),
+    token_expiry: integer(),
+    active: integer({ mode: "boolean" })
+      .notNull()
+      .$default(() => false),
+    ...Timestamps,
+  },
+  (table) => [primaryKey({ columns: [table.email, table.url] })],
+)

+ 43 - 0
packages/opencode/src/account/index.ts

@@ -0,0 +1,43 @@
+import { Effect, Option, ServiceMap } from "effect"
+
+import {
+  Account as AccountSchema,
+  type AccountError,
+  type AccessToken,
+  AccountID,
+  AccountService,
+  OrgID,
+} from "./service"
+
+export { AccessToken, AccountID, OrgID } from "./service"
+
+import { runtime } from "@/effect/runtime"
+
+type AccountServiceShape = ServiceMap.Service.Shape<typeof AccountService>
+
+function runSync<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
+  return runtime.runSync(AccountService.use(f))
+}
+
+function runPromise<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
+  return runtime.runPromise(AccountService.use(f))
+}
+
+export namespace Account {
+  export const Account = AccountSchema
+  export type Account = AccountSchema
+
+  export function active(): Account | undefined {
+    return Option.getOrUndefined(runSync((service) => service.active()))
+  }
+
+  export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
+    const config = await runPromise((service) => service.config(accountID, orgID))
+    return Option.getOrUndefined(config)
+  }
+
+  export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
+    const token = await runPromise((service) => service.token(accountID))
+    return Option.getOrUndefined(token)
+  }
+}

+ 138 - 0
packages/opencode/src/account/repo.ts

@@ -0,0 +1,138 @@
+import { eq } from "drizzle-orm"
+import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
+
+import { Database } from "@/storage/db"
+import { AccountStateTable, AccountTable } from "./account.sql"
+import { Account, AccountID, AccountRepoError, OrgID } from "./schema"
+
+export type AccountRow = (typeof AccountTable)["$inferSelect"]
+
+const decodeAccount = Schema.decodeUnknownSync(Account)
+
+type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
+
+const ACCOUNT_STATE_ID = 1
+
+const db = <A>(run: (db: DbClient) => A) =>
+  Effect.try({
+    try: () => Database.use(run),
+    catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
+  })
+
+const current = (db: DbClient) => {
+  const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
+  if (!state?.active_account_id) return
+  const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
+  if (!account) return
+  return { ...account, active_org_id: state.active_org_id ?? null }
+}
+
+const setState = (db: DbClient, accountID: AccountID, orgID: string | null) =>
+  db
+    .insert(AccountStateTable)
+    .values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: orgID })
+    .onConflictDoUpdate({
+      target: AccountStateTable.id,
+      set: { active_account_id: accountID, active_org_id: orgID },
+    })
+    .run()
+
+export class AccountRepo extends ServiceMap.Service<
+  AccountRepo,
+  {
+    readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
+    readonly list: () => Effect.Effect<Account[], AccountRepoError>
+    readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
+    readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
+    readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
+    readonly persistToken: (input: {
+      accountID: AccountID
+      accessToken: string
+      refreshToken: string
+      expiry: Option.Option<number>
+    }) => Effect.Effect<void, AccountRepoError>
+    readonly persistAccount: (input: {
+      id: AccountID
+      email: string
+      url: string
+      accessToken: string
+      refreshToken: string
+      expiry: number
+      orgID: Option.Option<OrgID>
+    }) => Effect.Effect<void, AccountRepoError>
+  }
+>()("@opencode/AccountRepo") {
+  static readonly layer: Layer.Layer<AccountRepo> = Layer.succeed(
+    AccountRepo,
+    AccountRepo.of({
+      active: Effect.fn("AccountRepo.active")(() =>
+        db((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decodeAccount(row)) : Option.none()))),
+      ),
+
+      list: Effect.fn("AccountRepo.list")(() => db((db) => db.select().from(AccountTable).all().map((row) => decodeAccount({ ...row, active_org_id: null })))),
+
+      remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
+        db((db) =>
+          Database.transaction((tx) => {
+            tx.update(AccountStateTable)
+              .set({ active_account_id: null, active_org_id: null })
+              .where(eq(AccountStateTable.active_account_id, accountID))
+              .run()
+            tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
+          }),
+        ).pipe(Effect.asVoid),
+      ),
+
+      use: Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
+        db((db) => setState(db, accountID, Option.getOrNull(orgID))).pipe(Effect.asVoid),
+      ),
+
+      getRow: Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
+        db((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
+          Effect.map(Option.fromNullishOr),
+        ),
+      ),
+
+      persistToken: Effect.fn("AccountRepo.persistToken")((input) =>
+        db((db) =>
+          db
+            .update(AccountTable)
+            .set({
+              access_token: input.accessToken,
+              refresh_token: input.refreshToken,
+              token_expiry: Option.getOrNull(input.expiry),
+            })
+            .where(eq(AccountTable.id, input.accountID))
+            .run(),
+        ).pipe(Effect.asVoid),
+      ),
+
+      persistAccount: Effect.fn("AccountRepo.persistAccount")((input) => {
+        const orgID = Option.getOrNull(input.orgID)
+        return db((db) =>
+          Database.transaction((tx) => {
+            tx.insert(AccountTable)
+              .values({
+                id: input.id,
+                email: input.email,
+                url: input.url,
+                access_token: input.accessToken,
+                refresh_token: input.refreshToken,
+                token_expiry: input.expiry,
+              })
+              .onConflictDoUpdate({
+                target: AccountTable.id,
+                set: {
+                  access_token: input.accessToken,
+                  refresh_token: input.refreshToken,
+                  token_expiry: input.expiry,
+                },
+              })
+              .run()
+            setState(tx, input.id, orgID)
+          }),
+        ).pipe(Effect.asVoid)
+      }),
+    }),
+  )
+}

+ 73 - 0
packages/opencode/src/account/schema.ts

@@ -0,0 +1,73 @@
+import { Schema } from "effect"
+
+import { withStatics } from "@/util/schema"
+
+export const AccountID = Schema.String.pipe(
+  Schema.brand("AccountId"),
+  withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
+)
+export type AccountID = Schema.Schema.Type<typeof AccountID>
+
+export const OrgID = Schema.String.pipe(
+  Schema.brand("OrgId"),
+  withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
+)
+export type OrgID = Schema.Schema.Type<typeof OrgID>
+
+export const AccessToken = Schema.String.pipe(
+  Schema.brand("AccessToken"),
+  withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
+)
+export type AccessToken = Schema.Schema.Type<typeof AccessToken>
+
+export class Account extends Schema.Class<Account>("Account")({
+  id: AccountID,
+  email: Schema.String,
+  url: Schema.String,
+  active_org_id: Schema.NullOr(OrgID),
+}) {}
+
+export class Org extends Schema.Class<Org>("Org")({
+  id: OrgID,
+  name: Schema.String,
+}) {}
+
+export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
+  message: Schema.String,
+  cause: Schema.optional(Schema.Defect),
+}) {}
+
+export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
+  message: Schema.String,
+  cause: Schema.optional(Schema.Defect),
+}) {}
+
+export type AccountError = AccountRepoError | AccountServiceError
+
+export class Login extends Schema.Class<Login>("Login")({
+  code: Schema.String,
+  user: Schema.String,
+  url: Schema.String,
+  server: Schema.String,
+  expiry: Schema.Number,
+  interval: Schema.Number,
+}) {}
+
+export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
+  email: Schema.String,
+}) {}
+
+export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
+
+export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
+
+export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
+
+export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
+
+export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
+  cause: Schema.Defect,
+}) {}
+
+export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
+export type PollResult = Schema.Schema.Type<typeof PollResult>

+ 384 - 0
packages/opencode/src/account/service.ts

@@ -0,0 +1,384 @@
+import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect"
+import {
+  FetchHttpClient,
+  HttpClient,
+  HttpClientError,
+  HttpClientRequest,
+  HttpClientResponse,
+} from "effect/unstable/http"
+
+import { withTransientReadRetry } from "@/util/effect-http-client"
+import { AccountRepo, type AccountRow } from "./repo"
+import {
+  type AccountError,
+  AccessToken,
+  Account,
+  AccountID,
+  AccountServiceError,
+  Login,
+  Org,
+  OrgID,
+  PollDenied,
+  PollError,
+  PollExpired,
+  PollPending,
+  type PollResult,
+  PollSlow,
+  PollSuccess,
+} from "./schema"
+
+export * from "./schema"
+
+export type AccountOrgs = {
+  account: Account
+  orgs: Org[]
+}
+
+const RemoteOrg = Schema.Struct({
+  id: Schema.optional(OrgID),
+  name: Schema.optional(Schema.String),
+})
+
+const RemoteOrgs = Schema.Array(RemoteOrg)
+
+const RemoteConfig = Schema.Struct({
+  config: Schema.Record(Schema.String, Schema.Json),
+})
+
+const TokenRefresh = Schema.Struct({
+  access_token: Schema.String,
+  refresh_token: Schema.optional(Schema.String),
+  expires_in: Schema.optional(Schema.Number),
+})
+
+const DeviceCode = Schema.Struct({
+  device_code: Schema.String,
+  user_code: Schema.String,
+  verification_uri_complete: Schema.String,
+  expires_in: Schema.Number,
+  interval: Schema.Number,
+})
+
+const DeviceToken = Schema.Struct({
+  access_token: Schema.optional(Schema.String),
+  refresh_token: Schema.optional(Schema.String),
+  expires_in: Schema.optional(Schema.Number),
+  error: Schema.optional(Schema.String),
+  error_description: Schema.optional(Schema.String),
+})
+
+const User = Schema.Struct({
+  id: Schema.optional(AccountID),
+  email: Schema.optional(Schema.String),
+})
+
+const ClientId = Schema.Struct({ client_id: Schema.String })
+
+const DeviceTokenRequest = Schema.Struct({
+  grant_type: Schema.String,
+  device_code: Schema.String,
+  client_id: Schema.String,
+})
+
+const clientId = "opencode-cli"
+
+const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause })
+
+const mapAccountServiceError =
+  (operation: string, message = "Account service operation failed") =>
+  <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
+    effect.pipe(
+      Effect.mapError((error) =>
+        error instanceof AccountServiceError ? error : toAccountServiceError(`${message} (${operation})`, error),
+      ),
+    )
+
+export class AccountService extends ServiceMap.Service<
+  AccountService,
+  {
+    readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
+    readonly list: () => Effect.Effect<Account[], AccountError>
+    readonly orgsByAccount: () => Effect.Effect<AccountOrgs[], AccountError>
+    readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
+    readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
+    readonly orgs: (accountID: AccountID) => Effect.Effect<Org[], AccountError>
+    readonly config: (
+      accountID: AccountID,
+      orgID: OrgID,
+    ) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
+    readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
+    readonly login: (url: string) => Effect.Effect<Login, AccountError>
+    readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
+  }
+>()("@opencode/Account") {
+  static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
+    AccountService,
+    Effect.gen(function* () {
+      const repo = yield* AccountRepo
+      const http = yield* HttpClient.HttpClient
+      const httpRead = withTransientReadRetry(http)
+
+      const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
+        http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
+
+      const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
+        httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
+
+      const executeEffect = <E>(operation: string, request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
+        request.pipe(
+          Effect.flatMap((req) => http.execute(req)),
+          mapAccountServiceError(operation, "HTTP request failed"),
+        )
+
+      const okOrNone = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
+        HttpClientResponse.filterStatusOk(response).pipe(
+          Effect.map(Option.some),
+          Effect.catch((error) =>
+            HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError"
+              ? Effect.succeed(Option.none<HttpClientResponse.HttpClientResponse>())
+              : Effect.fail(error),
+          ),
+          mapAccountServiceError(operation),
+        )
+
+      const tokenForRow = Effect.fn("AccountService.tokenForRow")(function* (found: AccountRow) {
+        const now = yield* Clock.currentTimeMillis
+        if (found.token_expiry && found.token_expiry > now) return Option.some(AccessToken.make(found.access_token))
+
+        const response = yield* execute(
+          "token.refresh",
+          HttpClientRequest.post(`${found.url}/oauth/token`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.bodyUrlParams({
+              grant_type: "refresh_token",
+              refresh_token: found.refresh_token,
+            }),
+          ),
+        )
+
+        const ok = yield* okOrNone("token.refresh", response)
+        if (Option.isNone(ok)) return Option.none()
+
+        const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(ok.value).pipe(
+          mapAccountServiceError("token.refresh", "Failed to decode response"),
+        )
+
+        const expiry = Option.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000))
+
+        yield* repo.persistToken({
+          accountID: AccountID.make(found.id),
+          accessToken: parsed.access_token,
+          refreshToken: parsed.refresh_token ?? found.refresh_token,
+          expiry,
+        })
+
+        return Option.some(AccessToken.make(parsed.access_token))
+      })
+
+      const resolveAccess = Effect.fn("AccountService.resolveAccess")(function* (accountID: AccountID) {
+        const maybeAccount = yield* repo.getRow(accountID)
+        if (Option.isNone(maybeAccount)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
+
+        const account = maybeAccount.value
+        const accessToken = yield* tokenForRow(account)
+        if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
+
+        return Option.some({ account, accessToken: accessToken.value })
+      })
+
+      const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
+        resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
+      )
+
+      const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
+        const accounts = yield* repo.list()
+        return yield* Effect.forEach(
+          accounts,
+          (account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
+          { concurrency: 3 },
+        )
+      })
+
+      const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
+        const resolved = yield* resolveAccess(accountID)
+        if (Option.isNone(resolved)) return []
+
+        const { account, accessToken } = resolved.value
+
+        const response = yield* executeRead(
+          "orgs",
+          HttpClientRequest.get(`${account.url}/api/orgs`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.bearerToken(accessToken),
+          ),
+        )
+
+        const ok = yield* okOrNone("orgs", response)
+        if (Option.isNone(ok)) return []
+
+        const orgs = yield* HttpClientResponse.schemaBodyJson(RemoteOrgs)(ok.value).pipe(
+          mapAccountServiceError("orgs", "Failed to decode response"),
+        )
+        return orgs
+          .filter((org) => org.id !== undefined && org.name !== undefined)
+          .map((org) => new Org({ id: org.id!, name: org.name! }))
+      })
+
+      const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
+        const resolved = yield* resolveAccess(accountID)
+        if (Option.isNone(resolved)) return Option.none()
+
+        const { account, accessToken } = resolved.value
+
+        const response = yield* executeRead(
+          "config",
+          HttpClientRequest.get(`${account.url}/api/config`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.bearerToken(accessToken),
+            HttpClientRequest.setHeaders({ "x-org-id": orgID }),
+          ),
+        )
+
+        const ok = yield* okOrNone("config", response)
+        if (Option.isNone(ok)) return Option.none()
+
+        const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok.value).pipe(
+          mapAccountServiceError("config", "Failed to decode response"),
+        )
+        return Option.some(parsed.config)
+      })
+
+      const login = Effect.fn("AccountService.login")(function* (server: string) {
+        const response = yield* executeEffect(
+          "login",
+          HttpClientRequest.post(`${server}/auth/device/code`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }),
+          ),
+        )
+
+        const ok = yield* okOrNone("login", response)
+        if (Option.isNone(ok)) {
+          const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
+          return yield* toAccountServiceError(`Failed to initiate device flow: ${body || response.status}`)
+        }
+
+        const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceCode)(ok.value).pipe(
+          mapAccountServiceError("login", "Failed to decode response"),
+        )
+        return new Login({
+          code: parsed.device_code,
+          user: parsed.user_code,
+          url: `${server}${parsed.verification_uri_complete}`,
+          server,
+          expiry: parsed.expires_in,
+          interval: parsed.interval,
+        })
+      })
+
+      const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
+        const response = yield* executeEffect(
+          "poll",
+          HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.schemaBodyJson(DeviceTokenRequest)({
+              grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+              device_code: input.code,
+              client_id: clientId,
+            }),
+          ),
+        )
+
+        const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
+          mapAccountServiceError("poll", "Failed to decode response"),
+        )
+
+        if (!parsed.access_token) {
+          if (parsed.error === "authorization_pending") return new PollPending()
+          if (parsed.error === "slow_down") return new PollSlow()
+          if (parsed.error === "expired_token") return new PollExpired()
+          if (parsed.error === "access_denied") return new PollDenied()
+          return new PollError({ cause: parsed.error })
+        }
+
+        const access = parsed.access_token
+
+        const fetchUser = executeRead(
+          "poll.user",
+          HttpClientRequest.get(`${input.server}/api/user`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.bearerToken(access),
+          ),
+        ).pipe(
+          Effect.flatMap((r) =>
+            HttpClientResponse.schemaBodyJson(User)(r).pipe(
+              mapAccountServiceError("poll.user", "Failed to decode response"),
+            ),
+          ),
+        )
+
+        const fetchOrgs = executeRead(
+          "poll.orgs",
+          HttpClientRequest.get(`${input.server}/api/orgs`).pipe(
+            HttpClientRequest.acceptJson,
+            HttpClientRequest.bearerToken(access),
+          ),
+        ).pipe(
+          Effect.flatMap((r) =>
+            HttpClientResponse.schemaBodyJson(RemoteOrgs)(r).pipe(
+              mapAccountServiceError("poll.orgs", "Failed to decode response"),
+            ),
+          ),
+        )
+
+        const [user, remoteOrgs] = yield* Effect.all([fetchUser, fetchOrgs], { concurrency: 2 })
+
+        const userId = user.id
+        const userEmail = user.email
+
+        if (!userId || !userEmail) {
+          return new PollError({ cause: "No id or email in response" })
+        }
+
+        const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none()
+
+        const now = yield* Clock.currentTimeMillis
+        const expiry = now + (parsed.expires_in ?? 0) * 1000
+        const refresh = parsed.refresh_token ?? ""
+        if (!refresh) {
+          yield* Effect.logWarning("Server did not return a refresh token — session may expire without ability to refresh")
+        }
+
+        yield* repo.persistAccount({
+          id: userId,
+          email: userEmail,
+          url: input.server,
+          accessToken: access,
+          refreshToken: refresh,
+          expiry,
+          orgID: firstOrgID,
+        })
+
+        return new PollSuccess({ email: userEmail })
+      })
+
+      return AccountService.of({
+        active: repo.active,
+        list: repo.list,
+        orgsByAccount,
+        remove: repo.remove,
+        use: repo.use,
+        orgs,
+        config,
+        token,
+        login,
+        poll,
+      })
+    }),
+  )
+
+  static readonly defaultLayer = AccountService.layer.pipe(
+    Layer.provide(AccountRepo.layer),
+    Layer.provide(FetchHttpClient.layer),
+  )
+}

+ 196 - 0
packages/opencode/src/cli/cmd/account.ts

@@ -0,0 +1,196 @@
+import { cmd } from "./cmd"
+import { Duration, Effect, Match, Option } from "effect"
+import { UI } from "../ui"
+import { runtime } from "@/effect/runtime"
+import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
+import { type AccountError } from "@/account/schema"
+import * as Prompt from "../effect/prompt"
+import open from "open"
+
+const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
+
+const println = (msg: string) => Effect.sync(() => UI.println(msg))
+
+const loginEffect = Effect.fn("login")(function* (url: string) {
+  const service = yield* AccountService
+
+  yield* Prompt.intro("Log in")
+  const login = yield* service.login(url)
+
+  yield* Prompt.log.info("Go to: " + login.url)
+  yield* Prompt.log.info("Enter code: " + login.user)
+  yield* openBrowser(login.url)
+
+  const s = Prompt.spinner()
+  yield* s.start("Waiting for authorization...")
+
+  const poll = (wait: number): Effect.Effect<PollResult, AccountError> =>
+    Effect.gen(function* () {
+      yield* Effect.sleep(wait)
+      const result = yield* service.poll(login)
+      if (result._tag === "PollPending") return yield* poll(wait)
+      if (result._tag === "PollSlow") return yield* poll(wait + 5000)
+      return result
+    })
+
+  const result = yield* poll(login.interval * 1000).pipe(
+    Effect.timeout(Duration.seconds(login.expiry)),
+    Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
+  )
+
+  yield* Match.valueTags(result, {
+    PollSuccess: (r) =>
+      Effect.gen(function* () {
+        yield* s.stop("Logged in as " + r.email)
+        yield* Prompt.outro("Done")
+      }),
+    PollExpired: () => s.stop("Device code expired", 1),
+    PollDenied: () => s.stop("Authorization denied", 1),
+    PollError: (r) => s.stop("Error: " + String(r.cause), 1),
+    PollPending: () => s.stop("Unexpected state", 1),
+    PollSlow: () => s.stop("Unexpected state", 1),
+  })
+})
+
+const logoutEffect = Effect.fn("logout")(function* (email?: string) {
+  const service = yield* AccountService
+  const accounts = yield* service.list()
+  if (accounts.length === 0) return yield* println("Not logged in")
+
+  if (email) {
+    const match = accounts.find((a) => a.email === email)
+    if (!match) return yield* println("Account not found: " + email)
+    yield* service.remove(match.id)
+    yield* Prompt.outro("Logged out from " + email)
+    return
+  }
+
+  const active = yield* service.active()
+  const activeID = Option.map(active, (a) => a.id)
+
+  yield* Prompt.intro("Log out")
+
+  const opts = accounts.map((a) => {
+    const isActive = Option.isSome(activeID) && activeID.value === a.id
+    const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL
+    return {
+      value: a,
+      label: isActive
+        ? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)"
+        : `${a.email} ${server}`,
+    }
+  })
+
+  const selected = yield* Prompt.select({ message: "Select account to log out", options: opts })
+  if (Option.isNone(selected)) return
+
+  yield* service.remove(selected.value.id)
+  yield* Prompt.outro("Logged out from " + selected.value.email)
+})
+
+interface OrgChoice {
+  orgID: OrgID
+  accountID: AccountID
+  label: string
+}
+
+const switchEffect = Effect.fn("switch")(function* () {
+  const service = yield* AccountService
+
+  const groups = yield* service.orgsByAccount()
+  if (groups.length === 0) return yield* println("Not logged in")
+
+  const active = yield* service.active()
+  const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
+
+  const opts = groups.flatMap((group) =>
+    group.orgs.map((org) => {
+      const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
+      return {
+        value: { orgID: org.id, accountID: group.account.id, label: org.name },
+        label: isActive
+          ? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
+          : `${org.name} (${group.account.email})`,
+      }
+    }),
+  )
+  if (opts.length === 0) return yield* println("No orgs found")
+
+  yield* Prompt.intro("Switch org")
+
+  const selected = yield* Prompt.select<OrgChoice>({ message: "Select org", options: opts })
+  if (Option.isNone(selected)) return
+
+  const choice = selected.value
+  yield* service.use(choice.accountID, Option.some(choice.orgID))
+  yield* Prompt.outro("Switched to " + choice.label)
+})
+
+const orgsEffect = Effect.fn("orgs")(function* () {
+  const service = yield* AccountService
+
+  const groups = yield* service.orgsByAccount()
+  if (groups.length === 0) return yield* println("No accounts found")
+  if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
+
+  const active = yield* service.active()
+  const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
+
+  for (const group of groups) {
+    for (const org of group.orgs) {
+      const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
+      const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
+      const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
+      const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
+      const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
+      yield* println(`  ${dot} ${name}  ${email}  ${id}`)
+    }
+  }
+})
+
+export const LoginCommand = cmd({
+  command: "login <url>",
+  describe: false,
+  builder: (yargs) =>
+    yargs.positional("url", {
+      describe: "server URL",
+      type: "string",
+      demandOption: true,
+    }),
+  async handler(args) {
+    UI.empty()
+    await runtime.runPromise(loginEffect(args.url))
+  },
+})
+
+export const LogoutCommand = cmd({
+  command: "logout [email]",
+  describe: false,
+  builder: (yargs) =>
+    yargs.positional("email", {
+      describe: "account email to log out from",
+      type: "string",
+    }),
+  async handler(args) {
+    UI.empty()
+    await runtime.runPromise(logoutEffect(args.email))
+  },
+})
+
+export const SwitchCommand = cmd({
+  command: "switch",
+  describe: false,
+  async handler() {
+    UI.empty()
+    await runtime.runPromise(switchEffect())
+  },
+})
+
+export const OrgsCommand = cmd({
+  command: "orgs",
+  describe: false,
+  async handler() {
+    UI.empty()
+    await runtime.runPromise(orgsEffect())
+  },
+})

+ 24 - 3
packages/opencode/src/cli/cmd/import.ts

@@ -10,7 +10,7 @@ import { ShareNext } from "../../share/share-next"
 import { EOL } from "os"
 import { Filesystem } from "../../util/filesystem"
 
-/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */
+/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
 export type ShareData =
   | { type: "session"; data: SDKSession }
   | { type: "message"; data: Message }
@@ -24,6 +24,14 @@ export function parseShareUrl(url: string): string | null {
   return match ? match[1] : null
 }
 
+export function shouldAttachShareAuthHeaders(shareUrl: string, accountBaseUrl: string): boolean {
+  try {
+    return new URL(shareUrl).origin === new URL(accountBaseUrl).origin
+  } catch {
+    return false
+  }
+}
+
 /**
  * Transform ShareNext API response (flat array) into the nested structure for local file storage.
  *
@@ -97,8 +105,21 @@ export const ImportCommand = cmd({
           return
         }
 
-        const baseUrl = await ShareNext.url()
-        const response = await fetch(`${baseUrl}/api/share/${slug}/data`)
+        const parsed = new URL(args.file)
+        const baseUrl = parsed.origin
+        const req = await ShareNext.request()
+        const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
+
+        const dataPath = req.api.data(slug)
+        let response = await fetch(`${baseUrl}${dataPath}`, {
+          headers,
+        })
+
+        if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
+          response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
+            headers,
+          })
+        }
 
         if (!response.ok) {
           process.stdout.write(`Failed to fetch share data: ${response.statusText}`)

+ 16 - 28
packages/opencode/src/cli/cmd/auth.ts → packages/opencode/src/cli/cmd/providers.ts

@@ -13,14 +13,9 @@ import { Instance } from "../../project/instance"
 import type { Hooks } from "@opencode-ai/plugin"
 import { Process } from "../../util/process"
 import { text } from "node:stream/consumers"
-import { setTimeout as sleep } from "node:timers/promises"
 
 type PluginAuth = NonNullable<Hooks["auth"]>
 
-/**
- * Handle plugin-based authentication flow.
- * Returns true if auth was handled, false if it should fall through to default handling.
- */
 async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
   let index = 0
   if (methodName) {
@@ -33,7 +28,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
     }
     index = match
   } else if (plugin.auth.methods.length > 1) {
-    const selected = await prompts.select({
+    const method = await prompts.select({
       message: "Login method",
       options: [
         ...plugin.auth.methods.map((x, index) => ({
@@ -42,13 +37,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
         })),
       ],
     })
-    if (prompts.isCancel(selected)) throw new UI.CancelledError()
-    index = parseInt(selected)
+    if (prompts.isCancel(method)) throw new UI.CancelledError()
+    index = parseInt(method)
   }
   const method = plugin.auth.methods[index]
 
-  // Handle prompts for all auth types
-  await sleep(10)
+  await new Promise((r) => setTimeout(r, 10))
   const inputs: Record<string, string> = {}
   if (method.prompts) {
     for (const prompt of method.prompts) {
@@ -171,11 +165,6 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
   return false
 }
 
-/**
- * Build a deduplicated list of plugin-registered auth providers that are not
- * already present in models.dev, respecting enabled/disabled provider lists.
- * Pure function with no side effects; safe to test without mocking.
- */
 export function resolvePluginProviders(input: {
   hooks: Hooks[]
   existingProviders: Record<string, unknown>
@@ -203,19 +192,20 @@ export function resolvePluginProviders(input: {
   return result
 }
 
-export const AuthCommand = cmd({
-  command: "auth",
-  describe: "manage credentials",
+export const ProvidersCommand = cmd({
+  command: "providers",
+  aliases: ["auth"],
+  describe: "manage AI providers and credentials",
   builder: (yargs) =>
-    yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
+    yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(),
   async handler() {},
 })
 
-export const AuthListCommand = cmd({
+export const ProvidersListCommand = cmd({
   command: "list",
   aliases: ["ls"],
-  describe: "list providers",
-  async handler() {
+  describe: "list providers and credentials",
+  async handler(_args) {
     UI.empty()
     const authPath = path.join(Global.Path.data, "auth.json")
     const homedir = os.homedir()
@@ -231,7 +221,6 @@ export const AuthListCommand = cmd({
 
     prompts.outro(`${results.length} credentials`)
 
-    // Environment variables section
     const activeEnvVars: Array<{ provider: string; envVar: string }> = []
 
     for (const [providerID, provider] of Object.entries(database)) {
@@ -258,7 +247,7 @@ export const AuthListCommand = cmd({
   },
 })
 
-export const AuthLoginCommand = cmd({
+export const ProvidersLoginCommand = cmd({
   command: "login [url]",
   describe: "log in to a provider",
   builder: (yargs) =>
@@ -356,7 +345,7 @@ export const AuthLoginCommand = cmd({
               value: x.id,
               hint: {
                 opencode: "recommended",
-                anthropic: "Claude Max or API key",
+                anthropic: "API key",
                 openai: "ChatGPT Plus/Pro or API key",
               }[x.id],
             })),
@@ -409,7 +398,6 @@ export const AuthLoginCommand = cmd({
           if (prompts.isCancel(custom)) throw new UI.CancelledError()
           provider = custom.replace(/^@ai-sdk\//, "")
 
-          // Check if a plugin provides auth for this custom provider
           const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
           if (customPlugin && customPlugin.auth) {
             const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
@@ -461,10 +449,10 @@ export const AuthLoginCommand = cmd({
   },
 })
 
-export const AuthLogoutCommand = cmd({
+export const ProvidersLogoutCommand = cmd({
   command: "logout",
   describe: "log out from a configured provider",
-  async handler() {
+  async handler(_args) {
     UI.empty()
     const credentials = await Auth.all().then((x) => Object.entries(x))
     prompts.intro("Remove credential")

+ 1 - 1
packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx

@@ -36,7 +36,7 @@ export function createDialogProviderOptions() {
         value: provider.id,
         description: {
           opencode: "(Recommended)",
-          anthropic: "(Claude Max or API key)",
+          anthropic: "(API key)",
           openai: "(ChatGPT Plus/Pro or API key)",
           "opencode-go": "Low cost subscription for everyone",
         }[provider.id],

+ 12 - 2
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -383,7 +383,12 @@ export function Session() {
             sessionID: route.sessionID,
           })
           .then((res) => copy(res.data!.share!.url))
-          .catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
+          .catch((error) => {
+            toast.show({
+              message: error instanceof Error ? error.message : "Failed to share session",
+              variant: "error",
+            })
+          })
         dialog.clear()
       },
     },
@@ -486,7 +491,12 @@ export function Session() {
             sessionID: route.sessionID,
           })
           .then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
-          .catch(() => toast.show({ message: "Failed to unshare session", variant: "error" }))
+          .catch((error) => {
+            toast.show({
+              message: error instanceof Error ? error.message : "Failed to unshare session",
+              variant: "error",
+            })
+          })
         dialog.clear()
       },
     },

+ 25 - 0
packages/opencode/src/cli/effect/prompt.ts

@@ -0,0 +1,25 @@
+import * as prompts from "@clack/prompts"
+import { Effect, Option } from "effect"
+
+export const intro = (msg: string) => Effect.sync(() => prompts.intro(msg))
+export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
+
+export const log = {
+  info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
+}
+
+export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
+  Effect.tryPromise(() => prompts.select(opts)).pipe(
+    Effect.map((result) => {
+      if (prompts.isCancel(result)) return Option.none<Value>()
+      return Option.some(result)
+    }),
+  )
+
+export const spinner = () => {
+  const s = prompts.spinner()
+  return {
+    start: (msg: string) => Effect.sync(() => s.start(msg)),
+    stop: (msg: string, code?: number) => Effect.sync(() => s.stop(msg, code)),
+  }
+}

+ 28 - 5
packages/opencode/src/config/config.ts

@@ -12,6 +12,7 @@ import { lazy } from "../util/lazy"
 import { NamedError } from "@opencode-ai/util/error"
 import { Flag } from "../flag/flag"
 import { Auth } from "../auth"
+import { Env } from "../env"
 import {
   type ParseError as JsoncParseError,
   applyEdits,
@@ -32,7 +33,7 @@ import { Glob } from "../util/glob"
 import { PackageRegistry } from "@/bun/registry"
 import { proxied } from "@/util/proxied"
 import { iife } from "@/util/iife"
-import { Control } from "@/control"
+import { Account } from "@/account"
 import { ConfigPaths } from "./paths"
 import { Filesystem } from "@/util/filesystem"
 
@@ -108,10 +109,6 @@ export namespace Config {
       }
     }
 
-    const token = await Control.token()
-    if (token) {
-    }
-
     // Global user config overrides remote config.
     result = mergeConfigConcatArrays(result, await global())
 
@@ -178,6 +175,32 @@ export namespace Config {
       log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
     }
 
+    const active = Account.active()
+    if (active?.active_org_id) {
+      try {
+        const [config, token] = await Promise.all([
+          Account.config(active.id, active.active_org_id),
+          Account.token(active.id),
+        ])
+        if (token) {
+          process.env["OPENCODE_CONSOLE_TOKEN"] = token
+          Env.set("OPENCODE_CONSOLE_TOKEN", token)
+        }
+
+        if (config) {
+          result = mergeConfigConcatArrays(
+            result,
+            await load(JSON.stringify(config), {
+              dir: path.dirname(`${active.url}/api/config`),
+              source: `${active.url}/api/config`,
+            }),
+          )
+        }
+      } catch (err: any) {
+        log.debug("failed to fetch remote account config", { error: err?.message ?? err })
+      }
+    }
+
     // Load managed config files last (highest priority) - enterprise admin-controlled
     // Kept separate from directories array to avoid write operations when installing plugins
     // which would fail on system directories requiring elevated permissions

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

@@ -1,5 +1,5 @@
 import { sqliteTable, text } from "drizzle-orm/sqlite-core"
-import { ProjectTable } from "@/project/project.sql"
+import { ProjectTable } from "../project/project.sql"
 
 export const WorkspaceTable = sqliteTable("workspace", {
   id: text().primaryKey(),

+ 0 - 22
packages/opencode/src/control/control.sql.ts

@@ -1,22 +0,0 @@
-import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
-import { eq } from "drizzle-orm"
-import { Timestamps } from "@/storage/schema.sql"
-
-export const ControlAccountTable = sqliteTable(
-  "control_account",
-  {
-    email: text().notNull(),
-    url: text().notNull(),
-    access_token: text().notNull(),
-    refresh_token: text().notNull(),
-    token_expiry: integer(),
-    active: integer({ mode: "boolean" })
-      .notNull()
-      .$default(() => false),
-    ...Timestamps,
-  },
-  (table) => [
-    primaryKey({ columns: [table.email, table.url] }),
-    // uniqueIndex("control_account_active_idx").on(table.email).where(eq(table.active, true)),
-  ],
-)

+ 0 - 67
packages/opencode/src/control/index.ts

@@ -1,67 +0,0 @@
-import { eq, and } from "drizzle-orm"
-import { Database } from "@/storage/db"
-import { ControlAccountTable } from "./control.sql"
-import z from "zod"
-
-export * from "./control.sql"
-
-export namespace Control {
-  export const Account = z.object({
-    email: z.string(),
-    url: z.string(),
-  })
-  export type Account = z.infer<typeof Account>
-
-  function fromRow(row: (typeof ControlAccountTable)["$inferSelect"]): Account {
-    return {
-      email: row.email,
-      url: row.url,
-    }
-  }
-
-  export function account(): Account | undefined {
-    const row = Database.use((db) =>
-      db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
-    )
-    return row ? fromRow(row) : undefined
-  }
-
-  export async function token(): Promise<string | undefined> {
-    const row = Database.use((db) =>
-      db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
-    )
-    if (!row) return undefined
-    if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
-
-    const res = await fetch(`${row.url}/oauth/token`, {
-      method: "POST",
-      headers: { "Content-Type": "application/x-www-form-urlencoded" },
-      body: new URLSearchParams({
-        grant_type: "refresh_token",
-        refresh_token: row.refresh_token,
-      }).toString(),
-    })
-
-    if (!res.ok) return
-
-    const json = (await res.json()) as {
-      access_token: string
-      refresh_token?: string
-      expires_in?: number
-    }
-
-    Database.use((db) =>
-      db
-        .update(ControlAccountTable)
-        .set({
-          access_token: json.access_token,
-          refresh_token: json.refresh_token ?? row.refresh_token,
-          token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
-        })
-        .where(and(eq(ControlAccountTable.email, row.email), eq(ControlAccountTable.url, row.url)))
-        .run(),
-    )
-
-    return json.access_token
-  }
-}

+ 4 - 0
packages/opencode/src/effect/runtime.ts

@@ -0,0 +1,4 @@
+import { ManagedRuntime } from "effect"
+import { AccountService } from "@/account/service"
+
+export const runtime = ManagedRuntime.make(AccountService.defaultLayer)

+ 7 - 2
packages/opencode/src/index.ts

@@ -3,7 +3,8 @@ import { hideBin } from "yargs/helpers"
 import { RunCommand } from "./cli/cmd/run"
 import { GenerateCommand } from "./cli/cmd/generate"
 import { Log } from "./util/log"
-import { AuthCommand } from "./cli/cmd/auth"
+import { LoginCommand, LogoutCommand, SwitchCommand, OrgsCommand } from "./cli/cmd/account"
+import { ProvidersCommand } from "./cli/cmd/providers"
 import { AgentCommand } from "./cli/cmd/agent"
 import { UpgradeCommand } from "./cli/cmd/upgrade"
 import { UninstallCommand } from "./cli/cmd/uninstall"
@@ -134,7 +135,11 @@ let cli = yargs(hideBin(process.argv))
   .command(RunCommand)
   .command(GenerateCommand)
   .command(DebugCommand)
-  .command(AuthCommand)
+  .command(LoginCommand)
+  .command(LogoutCommand)
+  .command(SwitchCommand)
+  .command(OrgsCommand)
+  .command(ProvidersCommand)
   .command(AgentCommand)
   .command(UpgradeCommand)
   .command(UninstallCommand)

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

@@ -1,5 +1,5 @@
 import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
-import { Timestamps } from "@/storage/schema.sql"
+import { Timestamps } from "../storage/schema.sql"
 
 export const ProjectTable = sqliteTable("project", {
   id: text().primaryKey(),

+ 3 - 3
packages/opencode/src/session/session.sql.ts

@@ -1,9 +1,9 @@
 import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
 import { ProjectTable } from "../project/project.sql"
 import type { MessageV2 } from "./message-v2"
-import type { Snapshot } from "@/snapshot"
-import type { PermissionNext } from "@/permission/next"
-import { Timestamps } from "@/storage/schema.sql"
+import type { Snapshot } from "../snapshot"
+import type { PermissionNext } from "../permission/next"
+import { Timestamps } from "../storage/schema.sql"
 
 type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
 type InfoData = Omit<MessageV2.Info, "id" | "sessionID">

+ 73 - 15
packages/opencode/src/share/share-next.ts

@@ -1,4 +1,5 @@
 import { Bus } from "@/bus"
+import { Account } from "@/account"
 import { Config } from "@/config/config"
 import { Provider } from "@/provider/provider"
 import { Session } from "@/session"
@@ -11,8 +12,51 @@ import type * as SDK from "@opencode-ai/sdk/v2"
 export namespace ShareNext {
   const log = Log.create({ service: "share-next" })
 
+  type ApiEndpoints = {
+    create: string
+    sync: (shareId: string) => string
+    remove: (shareId: string) => string
+    data: (shareId: string) => string
+  }
+
+  function apiEndpoints(resource: string): ApiEndpoints {
+    return {
+      create: `/api/${resource}`,
+      sync: (shareId) => `/api/${resource}/${shareId}/sync`,
+      remove: (shareId) => `/api/${resource}/${shareId}`,
+      data: (shareId) => `/api/${resource}/${shareId}/data`,
+    }
+  }
+
+  const legacyApi = apiEndpoints("share")
+  const consoleApi = apiEndpoints("shares")
+
   export async function url() {
-    return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
+    const req = await request()
+    return req.baseUrl
+  }
+
+  export async function request(): Promise<{
+    headers: Record<string, string>
+    api: ApiEndpoints
+    baseUrl: string
+  }> {
+    const headers: Record<string, string> = {}
+
+    const active = Account.active()
+    if (!active?.active_org_id) {
+      const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
+      return { headers, api: legacyApi, baseUrl }
+    }
+
+    const token = await Account.token(active.id)
+    if (!token) {
+      throw new Error("No active account token available for sharing")
+    }
+
+    headers["authorization"] = `Bearer ${token}`
+    headers["x-org-id"] = active.active_org_id
+    return { headers, api: consoleApi, baseUrl: active.url }
   }
 
   const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
@@ -68,15 +112,20 @@ export namespace ShareNext {
   export async function create(sessionID: string) {
     if (disabled) return { id: "", url: "", secret: "" }
     log.info("creating share", { sessionID })
-    const result = await fetch(`${await url()}/api/share`, {
+    const req = await request()
+    const response = await fetch(`${req.baseUrl}${req.api.create}`, {
       method: "POST",
-      headers: {
-        "Content-Type": "application/json",
-      },
+      headers: { ...req.headers, "Content-Type": "application/json" },
       body: JSON.stringify({ sessionID: sessionID }),
     })
-      .then((x) => x.json())
-      .then((x) => x as { id: string; url: string; secret: string })
+
+    if (!response.ok) {
+      const message = await response.text().catch(() => response.statusText)
+      throw new Error(`Failed to create share (${response.status}): ${message || response.statusText}`)
+    }
+
+    const result = (await response.json()) as { id: string; url: string; secret: string }
+
     Database.use((db) =>
       db
         .insert(SessionShareTable)
@@ -159,16 +208,19 @@ export namespace ShareNext {
       const share = get(sessionID)
       if (!share) return
 
-      await fetch(`${await url()}/api/share/${share.id}/sync`, {
+      const req = await request()
+      const response = await fetch(`${req.baseUrl}${req.api.sync(share.id)}`, {
         method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-        },
+        headers: { ...req.headers, "Content-Type": "application/json" },
         body: JSON.stringify({
           secret: share.secret,
           data: Array.from(queued.data.values()),
         }),
       })
+
+      if (!response.ok) {
+        log.warn("failed to sync share", { sessionID, shareID: share.id, status: response.status })
+      }
     }, 1000)
     queue.set(sessionID, { timeout, data: dataMap })
   }
@@ -178,15 +230,21 @@ export namespace ShareNext {
     log.info("removing share", { sessionID })
     const share = get(sessionID)
     if (!share) return
-    await fetch(`${await url()}/api/share/${share.id}`, {
+
+    const req = await request()
+    const response = await fetch(`${req.baseUrl}${req.api.remove(share.id)}`, {
       method: "DELETE",
-      headers: {
-        "Content-Type": "application/json",
-      },
+      headers: { ...req.headers, "Content-Type": "application/json" },
       body: JSON.stringify({
         secret: share.secret,
       }),
     })
+
+    if (!response.ok) {
+      const message = await response.text().catch(() => response.statusText)
+      throw new Error(`Failed to remove share (${response.status}): ${message || response.statusText}`)
+    }
+
     Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
   }
 

+ 1 - 1
packages/opencode/src/share/share.sql.ts

@@ -1,6 +1,6 @@
 import { sqliteTable, text } from "drizzle-orm/sqlite-core"
 import { SessionTable } from "../session/session.sql"
-import { Timestamps } from "@/storage/schema.sql"
+import { Timestamps } from "../storage/schema.sql"
 
 export const SessionShareTable = sqliteTable("session_share", {
   session_id: text()

+ 3 - 3
packages/opencode/src/storage/db.ts

@@ -39,7 +39,7 @@ export namespace Database {
   type Schema = typeof schema
   export type Transaction = SQLiteTransaction<"sync", void, Schema>
 
-  type Client = SQLiteBunDatabase<Schema>
+  type Client = SQLiteBunDatabase
 
   type Journal = { sql: string; timestamp: number; name: string }[]
 
@@ -93,7 +93,7 @@ export namespace Database {
     sqlite.run("PRAGMA foreign_keys = ON")
     sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
 
-    const db = drizzle({ client: sqlite, schema })
+    const db = drizzle({ client: sqlite })
 
     // Apply schema migrations
     const entries =
@@ -124,7 +124,7 @@ export namespace Database {
     Client.reset()
   }
 
-  export type TxOrDb = Transaction | Client
+  export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client
 
   const ctx = Context.create<{
     tx: TxOrDb

+ 2 - 2
packages/opencode/src/storage/schema.ts

@@ -1,5 +1,5 @@
-export { ControlAccountTable } from "../control/control.sql"
+export { AccountTable, AccountStateTable, ControlAccountTable } from "../account/account.sql"
+export { ProjectTable } from "../project/project.sql"
 export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
 export { SessionShareTable } from "../share/share.sql"
-export { ProjectTable } from "../project/project.sql"
 export { WorkspaceTable } from "../control-plane/workspace.sql"

+ 11 - 0
packages/opencode/src/util/effect-http-client.ts

@@ -0,0 +1,11 @@
+import { Schedule } from "effect"
+import { HttpClient } from "effect/unstable/http"
+
+export const withTransientReadRetry = <E, R>(client: HttpClient.HttpClient.With<E, R>) =>
+  client.pipe(
+    HttpClient.retryTransient({
+      retryOn: "errors-and-responses",
+      times: 2,
+      schedule: Schedule.exponential(200).pipe(Schedule.jittered),
+    }),
+  )

+ 17 - 0
packages/opencode/src/util/schema.ts

@@ -0,0 +1,17 @@
+import { Schema } from "effect"
+
+/**
+ * Attach static methods to a schema object. Designed to be used with `.pipe()`:
+ *
+ * @example
+ *   export const Foo = fooSchema.pipe(
+ *     withStatics((schema) => ({
+ *       zero: schema.makeUnsafe(0),
+ *       from: Schema.decodeUnknownOption(schema),
+ *     }))
+ *   )
+ */
+export const withStatics =
+  <S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
+  (schema: S): S & M =>
+    Object.assign(schema, methods(schema))

+ 338 - 0
packages/opencode/test/account/repo.test.ts

@@ -0,0 +1,338 @@
+import { expect } from "bun:test"
+import { Effect, Layer, Option } from "effect"
+
+import { AccountRepo } from "../../src/account/repo"
+import { AccountID, OrgID } from "../../src/account/schema"
+import { Database } from "../../src/storage/db"
+import { testEffect } from "../fixture/effect"
+
+const truncate = Layer.effectDiscard(
+  Effect.sync(() => {
+    const db = Database.Client()
+    db.run(/*sql*/ `DELETE FROM account_state`)
+    db.run(/*sql*/ `DELETE FROM account`)
+  }),
+)
+
+const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
+
+it.effect(
+  "list returns empty when no accounts exist",
+  Effect.gen(function* () {
+    const accounts = yield* AccountRepo.use((r) => r.list())
+    expect(accounts).toEqual([])
+  }),
+)
+
+it.effect(
+  "active returns none when no accounts exist",
+  Effect.gen(function* () {
+    const active = yield* AccountRepo.use((r) => r.active())
+    expect(Option.isNone(active)).toBe(true)
+  }),
+)
+
+it.effect(
+  "persistAccount inserts and getRow retrieves",
+  Effect.gen(function* () {
+    const id = AccountID.make("user-1")
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "at_123",
+        refreshToken: "rt_456",
+        expiry: Date.now() + 3600_000,
+        orgID: Option.some(OrgID.make("org-1")),
+      }),
+    )
+
+    const row = yield* AccountRepo.use((r) => r.getRow(id))
+    expect(Option.isSome(row)).toBe(true)
+    const value = Option.getOrThrow(row)
+    expect(value.id).toBe("user-1")
+    expect(value.email).toBe("[email protected]")
+
+    const active = yield* AccountRepo.use((r) => r.active())
+    expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
+  }),
+)
+
+it.effect(
+  "persistAccount sets the active account and org",
+  Effect.gen(function* () {
+    const id1 = AccountID.make("user-1")
+    const id2 = AccountID.make("user-2")
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id: id1,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "at_1",
+        refreshToken: "rt_1",
+        expiry: Date.now() + 3600_000,
+        orgID: Option.some(OrgID.make("org-1")),
+      }),
+    )
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id: id2,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "at_2",
+        refreshToken: "rt_2",
+        expiry: Date.now() + 3600_000,
+        orgID: Option.some(OrgID.make("org-2")),
+      }),
+    )
+
+    // Last persisted account is active with its org
+    const active = yield* AccountRepo.use((r) => r.active())
+    expect(Option.isSome(active)).toBe(true)
+    expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
+    expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
+  }),
+)
+
+it.effect(
+  "list returns all accounts",
+  Effect.gen(function* () {
+    const id1 = AccountID.make("user-1")
+    const id2 = AccountID.make("user-2")
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id: id1,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "at_1",
+        refreshToken: "rt_1",
+        expiry: Date.now() + 3600_000,
+        orgID: Option.none(),
+      }),
+    )
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id: id2,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "at_2",
+        refreshToken: "rt_2",
+        expiry: Date.now() + 3600_000,
+        orgID: Option.some(OrgID.make("org-1")),
+      }),
+    )
+
+    const accounts = yield* AccountRepo.use((r) => r.list())
+    expect(accounts.length).toBe(2)
+    expect(accounts.map((a) => a.email).sort()).toEqual(["[email protected]", "[email protected]"])
+  }),
+)
+
+it.effect(
+  "remove deletes an account",
+  Effect.gen(function* () {
+    const id = AccountID.make("user-1")
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "at_1",
+        refreshToken: "rt_1",
+        expiry: Date.now() + 3600_000,
+        orgID: Option.none(),
+      }),
+    )
+
+    yield* AccountRepo.use((r) => r.remove(id))
+
+    const row = yield* AccountRepo.use((r) => r.getRow(id))
+    expect(Option.isNone(row)).toBe(true)
+  }),
+)
+
+it.effect(
+  "use stores the selected org and marks the account active",
+  Effect.gen(function* () {
+    const id1 = AccountID.make("user-1")
+    const id2 = AccountID.make("user-2")
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id: id1,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "at_1",
+        refreshToken: "rt_1",
+        expiry: Date.now() + 3600_000,
+        orgID: Option.none(),
+      }),
+    )
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id: id2,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "at_2",
+        refreshToken: "rt_2",
+        expiry: Date.now() + 3600_000,
+        orgID: Option.none(),
+      }),
+    )
+
+    yield* AccountRepo.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
+    const active1 = yield* AccountRepo.use((r) => r.active())
+    expect(Option.getOrThrow(active1).id).toBe(id1)
+    expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
+
+    yield* AccountRepo.use((r) => r.use(id1, Option.none()))
+    const active2 = yield* AccountRepo.use((r) => r.active())
+    expect(Option.getOrThrow(active2).active_org_id).toBeNull()
+  }),
+)
+
+it.effect(
+  "persistToken updates token fields",
+  Effect.gen(function* () {
+    const id = AccountID.make("user-1")
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "old_token",
+        refreshToken: "old_refresh",
+        expiry: 1000,
+        orgID: Option.none(),
+      }),
+    )
+
+    const expiry = Date.now() + 7200_000
+    yield* AccountRepo.use((r) =>
+      r.persistToken({
+        accountID: id,
+        accessToken: "new_token",
+        refreshToken: "new_refresh",
+        expiry: Option.some(expiry),
+      }),
+    )
+
+    const row = yield* AccountRepo.use((r) => r.getRow(id))
+    const value = Option.getOrThrow(row)
+    expect(value.access_token).toBe("new_token")
+    expect(value.refresh_token).toBe("new_refresh")
+    expect(value.token_expiry).toBe(expiry)
+  }),
+)
+
+it.effect(
+  "persistToken with no expiry sets token_expiry to null",
+  Effect.gen(function* () {
+    const id = AccountID.make("user-1")
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "old_token",
+        refreshToken: "old_refresh",
+        expiry: 1000,
+        orgID: Option.none(),
+      }),
+    )
+
+    yield* AccountRepo.use((r) =>
+      r.persistToken({
+        accountID: id,
+        accessToken: "new_token",
+        refreshToken: "new_refresh",
+        expiry: Option.none(),
+      }),
+    )
+
+    const row = yield* AccountRepo.use((r) => r.getRow(id))
+    expect(Option.getOrThrow(row).token_expiry).toBeNull()
+  }),
+)
+
+it.effect(
+  "persistAccount upserts on conflict",
+  Effect.gen(function* () {
+    const id = AccountID.make("user-1")
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "at_v1",
+        refreshToken: "rt_v1",
+        expiry: 1000,
+        orgID: Option.some(OrgID.make("org-1")),
+      }),
+    )
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "at_v2",
+        refreshToken: "rt_v2",
+        expiry: 2000,
+        orgID: Option.some(OrgID.make("org-2")),
+      }),
+    )
+
+    const accounts = yield* AccountRepo.use((r) => r.list())
+    expect(accounts.length).toBe(1)
+
+    const row = yield* AccountRepo.use((r) => r.getRow(id))
+    const value = Option.getOrThrow(row)
+    expect(value.access_token).toBe("at_v2")
+
+    const active = yield* AccountRepo.use((r) => r.active())
+    expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
+  }),
+)
+
+it.effect(
+  "remove clears active state when deleting the active account",
+  Effect.gen(function* () {
+    const id = AccountID.make("user-1")
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id,
+        email: "[email protected]",
+        url: "https://control.example.com",
+        accessToken: "at_1",
+        refreshToken: "rt_1",
+        expiry: Date.now() + 3600_000,
+        orgID: Option.some(OrgID.make("org-1")),
+      }),
+    )
+
+    yield* AccountRepo.use((r) => r.remove(id))
+
+    const active = yield* AccountRepo.use((r) => r.active())
+    expect(Option.isNone(active)).toBe(true)
+  }),
+)
+
+it.effect(
+  "getRow returns none for nonexistent account",
+  Effect.gen(function* () {
+    const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
+    expect(Option.isNone(row)).toBe(true)
+  }),
+)

+ 223 - 0
packages/opencode/test/account/service.test.ts

@@ -0,0 +1,223 @@
+import { expect } from "bun:test"
+import { Effect, Layer, Option, Ref, Schema } from "effect"
+import { HttpClient, HttpClientResponse } from "effect/unstable/http"
+
+import { AccountRepo } from "../../src/account/repo"
+import { AccountService } from "../../src/account/service"
+import { AccountID, Login, Org, OrgID } from "../../src/account/schema"
+import { Database } from "../../src/storage/db"
+import { testEffect } from "../fixture/effect"
+
+const truncate = Layer.effectDiscard(
+  Effect.sync(() => {
+    const db = Database.Client()
+    db.run(/*sql*/ `DELETE FROM account_state`)
+    db.run(/*sql*/ `DELETE FROM account`)
+  }),
+)
+
+const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
+
+const live = (client: HttpClient.HttpClient) =>
+  AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
+
+const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
+  HttpClientResponse.fromWeb(
+    req,
+    new Response(JSON.stringify(body), {
+      status,
+      headers: { "content-type": "application/json" },
+    }),
+  )
+
+const encodeOrg = Schema.encodeSync(Org)
+
+const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
+
+it.effect(
+  "orgsByAccount groups orgs per account",
+  Effect.gen(function* () {
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id: AccountID.make("user-1"),
+        email: "[email protected]",
+        url: "https://one.example.com",
+        accessToken: "at_1",
+        refreshToken: "rt_1",
+        expiry: Date.now() + 60_000,
+        orgID: Option.none(),
+      }),
+    )
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id: AccountID.make("user-2"),
+        email: "[email protected]",
+        url: "https://two.example.com",
+        accessToken: "at_2",
+        refreshToken: "rt_2",
+        expiry: Date.now() + 60_000,
+        orgID: Option.none(),
+      }),
+    )
+
+    const seen = yield* Ref.make<string[]>([])
+    const client = HttpClient.make((req) =>
+      Effect.gen(function* () {
+        yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`])
+
+        if (req.url === "https://one.example.com/api/orgs") {
+          return json(req, [org("org-1", "One")])
+        }
+
+        if (req.url === "https://two.example.com/api/orgs") {
+          return json(req, [org("org-2", "Two A"), org("org-3", "Two B")])
+        }
+
+        return json(req, [], 404)
+      }),
+    )
+
+    const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
+
+    expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
+      [AccountID.make("user-1"), [OrgID.make("org-1")]],
+      [AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
+    ])
+    expect(yield* Ref.get(seen)).toEqual([
+      "GET https://one.example.com/api/orgs",
+      "GET https://two.example.com/api/orgs",
+    ])
+  }),
+)
+
+it.effect(
+  "token refresh persists the new token",
+  Effect.gen(function* () {
+    const id = AccountID.make("user-1")
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id,
+        email: "[email protected]",
+        url: "https://one.example.com",
+        accessToken: "at_old",
+        refreshToken: "rt_old",
+        expiry: Date.now() - 1_000,
+        orgID: Option.none(),
+      }),
+    )
+
+    const client = HttpClient.make((req) =>
+      Effect.succeed(
+        req.url === "https://one.example.com/oauth/token"
+          ? json(req, {
+              access_token: "at_new",
+              refresh_token: "rt_new",
+              expires_in: 60,
+            })
+          : json(req, {}, 404),
+      ),
+    )
+
+    const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
+
+    expect(Option.getOrThrow(token)).toBeDefined()
+    expect(String(Option.getOrThrow(token))).toBe("at_new")
+
+    const row = yield* AccountRepo.use((r) => r.getRow(id))
+    const value = Option.getOrThrow(row)
+    expect(value.access_token).toBe("at_new")
+    expect(value.refresh_token).toBe("rt_new")
+    expect(value.token_expiry).toBeGreaterThan(Date.now())
+  }),
+)
+
+it.effect(
+  "config sends the selected org header",
+  Effect.gen(function* () {
+    const id = AccountID.make("user-1")
+
+    yield* AccountRepo.use((r) =>
+      r.persistAccount({
+        id,
+        email: "[email protected]",
+        url: "https://one.example.com",
+        accessToken: "at_1",
+        refreshToken: "rt_1",
+        expiry: Date.now() + 60_000,
+        orgID: Option.none(),
+      }),
+    )
+
+    const seen = yield* Ref.make<{ auth?: string; org?: string }>({})
+    const client = HttpClient.make((req) =>
+      Effect.gen(function* () {
+        yield* Ref.set(seen, {
+          auth: req.headers.authorization,
+          org: req.headers["x-org-id"],
+        })
+
+        if (req.url === "https://one.example.com/api/config") {
+          return json(req, { config: { theme: "light", seats: 5 } })
+        }
+
+        return json(req, {}, 404)
+      }),
+    )
+
+    const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
+
+    expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
+    expect(yield* Ref.get(seen)).toEqual({
+      auth: "Bearer at_1",
+      org: "org-9",
+    })
+  }),
+)
+
+it.effect(
+  "poll stores the account and first org on success",
+  Effect.gen(function* () {
+    const login = new Login({
+      code: "device-code",
+      user: "user-code",
+      url: "https://one.example.com/verify",
+      server: "https://one.example.com",
+      expiry: 600,
+      interval: 5,
+    })
+
+    const client = HttpClient.make((req) =>
+      Effect.succeed(
+        req.url === "https://one.example.com/auth/device/token"
+          ? json(req, {
+              access_token: "at_1",
+              refresh_token: "rt_1",
+              expires_in: 60,
+            })
+          : req.url === "https://one.example.com/api/user"
+            ? json(req, { id: "user-1", email: "[email protected]" })
+            : req.url === "https://one.example.com/api/orgs"
+              ? json(req, [org("org-1", "One")])
+              : json(req, {}, 404),
+      ),
+    )
+
+    const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
+
+    expect(res._tag).toBe("PollSuccess")
+    if (res._tag === "PollSuccess") {
+      expect(res.email).toBe("[email protected]")
+    }
+
+    const active = yield* AccountRepo.use((r) => r.active())
+    expect(Option.getOrThrow(active)).toEqual(
+      expect.objectContaining({
+        id: "user-1",
+        email: "[email protected]",
+        active_org_id: "org-1",
+      }),
+    )
+  }),
+)

+ 19 - 1
packages/opencode/test/cli/import.test.ts

@@ -1,5 +1,10 @@
 import { test, expect } from "bun:test"
-import { parseShareUrl, transformShareData, type ShareData } from "../../src/cli/cmd/import"
+import {
+  parseShareUrl,
+  shouldAttachShareAuthHeaders,
+  transformShareData,
+  type ShareData,
+} from "../../src/cli/cmd/import"
 
 // parseShareUrl tests
 test("parses valid share URLs", () => {
@@ -15,6 +20,19 @@ test("rejects invalid URLs", () => {
   expect(parseShareUrl("not-a-url")).toBeNull()
 })
 
+test("only attaches share auth headers for same-origin URLs", () => {
+  expect(shouldAttachShareAuthHeaders("https://control.example.com/share/abc", "https://control.example.com")).toBe(
+    true,
+  )
+  expect(
+    shouldAttachShareAuthHeaders("https://other.example.com/share/abc", "https://control.example.com"),
+  ).toBe(false)
+  expect(shouldAttachShareAuthHeaders("https://control.example.com:443/share/abc", "https://control.example.com")).toBe(
+    true,
+  )
+  expect(shouldAttachShareAuthHeaders("not-a-url", "https://control.example.com")).toBe(false)
+})
+
 // transformShareData tests
 test("transforms share data to storage format", () => {
   const data: ShareData[] = [

+ 1 - 1
packages/opencode/test/cli/plugin-auth-picker.test.ts

@@ -1,5 +1,5 @@
 import { test, expect, describe } from "bun:test"
-import { resolvePluginProviders } from "../../src/cli/cmd/auth"
+import { resolvePluginProviders } from "../../src/cli/cmd/providers"
 import type { Hooks } from "@opencode-ai/plugin"
 
 function hookWithAuth(provider: string): Hooks {

+ 47 - 0
packages/opencode/test/config/config.test.ts

@@ -2,6 +2,7 @@ import { test, expect, describe, mock, afterEach } from "bun:test"
 import { Config } from "../../src/config/config"
 import { Instance } from "../../src/project/instance"
 import { Auth } from "../../src/auth"
+import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
 import { tmpdir } from "../fixture/fixture"
 import path from "path"
 import fs from "fs/promises"
@@ -242,6 +243,52 @@ test("preserves env variables when adding $schema to config", async () => {
   }
 })
 
+test("resolves env templates in account config with account token", async () => {
+  const originalActive = Account.active
+  const originalConfig = Account.config
+  const originalToken = Account.token
+  const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
+
+  Account.active = mock(() => ({
+    id: AccountID.make("account-1"),
+    email: "[email protected]",
+    url: "https://control.example.com",
+    active_org_id: OrgID.make("org-1"),
+  }))
+
+  Account.config = mock(async () => ({
+    provider: {
+      opencode: {
+        options: {
+          apiKey: "{env:OPENCODE_CONSOLE_TOKEN}",
+        },
+      },
+    },
+  }))
+
+  Account.token = mock(async () => AccessToken.make("st_test_token"))
+
+  try {
+    await using tmp = await tmpdir()
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const config = await Config.get()
+        expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
+      },
+    })
+  } finally {
+    Account.active = originalActive
+    Account.config = originalConfig
+    Account.token = originalToken
+    if (originalControlToken !== undefined) {
+      process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
+    } else {
+      delete process.env["OPENCODE_CONSOLE_TOKEN"]
+    }
+  }
+})
+
 test("handles file inclusion substitution", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {

+ 7 - 0
packages/opencode/test/fixture/effect.ts

@@ -0,0 +1,7 @@
+import { test } from "bun:test"
+import { Effect, Layer } from "effect"
+
+export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
+  effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
+    test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
+})

+ 76 - 0
packages/opencode/test/share/share-next.test.ts

@@ -0,0 +1,76 @@
+import { test, expect, mock } from "bun:test"
+import { ShareNext } from "../../src/share/share-next"
+import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
+import { Config } from "../../src/config/config"
+
+test("ShareNext.request uses legacy share API without active org account", async () => {
+  const originalActive = Account.active
+  const originalConfigGet = Config.get
+
+  Account.active = mock(() => undefined)
+  Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } }))
+
+  try {
+    const req = await ShareNext.request()
+
+    expect(req.api.create).toBe("/api/share")
+    expect(req.api.sync("shr_123")).toBe("/api/share/shr_123/sync")
+    expect(req.api.remove("shr_123")).toBe("/api/share/shr_123")
+    expect(req.api.data("shr_123")).toBe("/api/share/shr_123/data")
+    expect(req.baseUrl).toBe("https://legacy-share.example.com")
+    expect(req.headers).toEqual({})
+  } finally {
+    Account.active = originalActive
+    Config.get = originalConfigGet
+  }
+})
+
+test("ShareNext.request uses org share API with auth headers when account is active", async () => {
+  const originalActive = Account.active
+  const originalToken = Account.token
+
+  Account.active = mock(() => ({
+    id: AccountID.make("account-1"),
+    email: "[email protected]",
+    url: "https://control.example.com",
+    active_org_id: OrgID.make("org-1"),
+  }))
+  Account.token = mock(async () => AccessToken.make("st_test_token"))
+
+  try {
+    const req = await ShareNext.request()
+
+    expect(req.api.create).toBe("/api/shares")
+    expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync")
+    expect(req.api.remove("shr_123")).toBe("/api/shares/shr_123")
+    expect(req.api.data("shr_123")).toBe("/api/shares/shr_123/data")
+    expect(req.baseUrl).toBe("https://control.example.com")
+    expect(req.headers).toEqual({
+      authorization: "Bearer st_test_token",
+      "x-org-id": "org-1",
+    })
+  } finally {
+    Account.active = originalActive
+    Account.token = originalToken
+  }
+})
+
+test("ShareNext.request fails when org account has no token", async () => {
+  const originalActive = Account.active
+  const originalToken = Account.token
+
+  Account.active = mock(() => ({
+    id: AccountID.make("account-1"),
+    email: "[email protected]",
+    url: "https://control.example.com",
+    active_org_id: OrgID.make("org-1"),
+  }))
+  Account.token = mock(async () => undefined)
+
+  try {
+    await expect(ShareNext.request()).rejects.toThrow("No active account token available for sharing")
+  } finally {
+    Account.active = originalActive
+    Account.token = originalToken
+  }
+})

+ 6 - 1
packages/opencode/tsconfig.json

@@ -11,6 +11,11 @@
     "paths": {
       "@/*": ["./src/*"],
       "@tui/*": ["./src/cli/cmd/tui/*"]
-    }
+    },
+    "plugins": [{
+      "name": "@effect/language-service",
+      "transform": "@effect/language-service/transform",
+      "namespaceImportPackages": ["effect", "@effect/*"]
+    }]
   }
 }

Some files were not shown because too many files changed in this diff