Przeglądaj źródła

feat(core): add location-based permission service (#30287)

Dax 3 miesięcy temu
rodzic
commit
9b815bcbd2
65 zmienionych plików z 4971 dodań i 553 usunięć
  1. 1 0
      packages/core/migration/20260601202201_amazing_prowler/migration.sql
  2. 1566 0
      packages/core/migration/20260601202201_amazing_prowler/snapshot.json
  3. 11 0
      packages/core/migration/20260602002951_lowly_union_jack/migration.sql
  4. 1676 0
      packages/core/migration/20260602002951_lowly_union_jack/snapshot.json
  5. 2 2
      packages/core/src/agent.ts
  6. 2 0
      packages/core/src/database/migration.gen.ts
  7. 11 0
      packages/core/src/database/migration/20260601202201_amazing_prowler.ts
  8. 22 0
      packages/core/src/database/migration/20260602002951_lowly_union_jack.ts
  9. 8 0
      packages/core/src/location-layer.ts
  10. 280 35
      packages/core/src/permission.ts
  11. 96 0
      packages/core/src/permission/legacy.ts
  12. 78 0
      packages/core/src/permission/saved.ts
  13. 16 0
      packages/core/src/permission/schema.ts
  14. 20 0
      packages/core/src/permission/sql.ts
  15. 34 34
      packages/core/src/plugin/agent.ts
  16. 2 2
      packages/core/src/session/legacy.ts
  17. 2 10
      packages/core/src/session/sql.ts
  18. 11 11
      packages/core/test/config/agent.test.ts
  19. 6 6
      packages/core/test/config/config.test.ts
  20. 1 1
      packages/core/test/database-migration.test.ts
  21. 176 0
      packages/core/test/permission.test.ts
  22. 2 1
      packages/opencode/src/agent/agent.ts
  23. 3 2
      packages/opencode/src/agent/subagent-permissions.ts
  24. 3 2
      packages/opencode/src/cli/cmd/debug/agent.ts
  25. 2 1
      packages/opencode/src/cli/cmd/run.ts
  26. 1 1
      packages/opencode/src/permission/evaluate.ts
  27. 49 137
      packages/opencode/src/permission/index.ts
  28. 0 13
      packages/opencode/src/permission/schema.ts
  29. 1 35
      packages/opencode/src/project/project.ts
  30. 4 4
      packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts
  31. 4 4
      packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
  32. 4 0
      packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts
  33. 3 2
      packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts
  34. 90 0
      packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts
  35. 3 3
      packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts
  36. 2 2
      packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
  37. 14 1
      packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts
  38. 105 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts
  39. 3 3
      packages/opencode/src/session/llm.ts
  40. 2 1
      packages/opencode/src/session/llm/request.ts
  41. 2 1
      packages/opencode/src/session/processor.ts
  42. 2 1
      packages/opencode/src/session/prompt.ts
  43. 10 9
      packages/opencode/src/session/session.ts
  44. 2 30
      packages/opencode/src/storage/json-migration.ts
  45. 1 1
      packages/opencode/src/storage/schema.ts
  46. 2 1
      packages/opencode/src/tool/tool.ts
  47. 2 1
      packages/opencode/test/agent/agent.test.ts
  48. 4 3
      packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts
  49. 3 2
      packages/opencode/test/permission-task.test.ts
  50. 50 50
      packages/opencode/test/permission/next.test.ts
  51. 1 19
      packages/opencode/test/project/project.test.ts
  52. 26 0
      packages/opencode/test/server/httpapi-exercise/index.ts
  53. 2 2
      packages/opencode/test/server/httpapi-instance.test.ts
  54. 2 2
      packages/opencode/test/server/httpapi-session.test.ts
  55. 2 1
      packages/opencode/test/session/llm.test.ts
  56. 6 13
      packages/opencode/test/storage/json-migration.test.ts
  57. 2 1
      packages/opencode/test/tool/external-directory.test.ts
  58. 3 2
      packages/opencode/test/tool/glob.test.ts
  59. 3 2
      packages/opencode/test/tool/grep.test.ts
  60. 3 2
      packages/opencode/test/tool/lsp.test.ts
  61. 5 4
      packages/opencode/test/tool/read.test.ts
  62. 33 32
      packages/opencode/test/tool/shell.test.ts
  63. 2 1
      packages/opencode/test/tool/skill.test.ts
  64. 177 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  65. 310 60
      packages/sdk/js/src/v2/gen/types.gen.ts

+ 1 - 0
packages/core/migration/20260601202201_amazing_prowler/migration.sql

@@ -0,0 +1 @@
+DROP TABLE `permission`;

+ 1566 - 0
packages/core/migration/20260601202201_amazing_prowler/snapshot.json

@@ -0,0 +1,1566 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "id": "226375f1-a19f-4c7b-8aa2-ccc5513d3b0d",
+  "prevIds": [
+    "bf93c73b-5a48-4d63-9909-3c36a79b9788"
+  ],
+  "ddl": [
+    {
+      "name": "workspace",
+      "entityType": "tables"
+    },
+    {
+      "name": "data_migration",
+      "entityType": "tables"
+    },
+    {
+      "name": "account_state",
+      "entityType": "tables"
+    },
+    {
+      "name": "account",
+      "entityType": "tables"
+    },
+    {
+      "name": "control_account",
+      "entityType": "tables"
+    },
+    {
+      "name": "event_sequence",
+      "entityType": "tables"
+    },
+    {
+      "name": "event",
+      "entityType": "tables"
+    },
+    {
+      "name": "project",
+      "entityType": "tables"
+    },
+    {
+      "name": "message",
+      "entityType": "tables"
+    },
+    {
+      "name": "part",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_message",
+      "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": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "''",
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "branch",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "extra",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_used",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "data_migration"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_completed",
+      "entityType": "columns",
+      "table": "data_migration"
+    },
+    {
+      "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": "aggregate_id",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "owner_id",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "aggregate_id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "worktree",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "vcs",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url_override",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_color",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_initialized",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "sandboxes",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "commands",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "message_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "parent_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "slug",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "path",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "title",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "version",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "share_url",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_additions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_deletions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_files",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_diffs",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "metadata",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "real",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "cost",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_input",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_output",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_reasoning",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_cache_read",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_cache_write",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "revert",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "permission",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "agent",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "model",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_compacting",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_archived",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "content",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "status",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "priority",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "position",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "secret",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "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": [
+        "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": [
+        "aggregate_id"
+      ],
+      "tableTo": "event_sequence",
+      "columnsTo": [
+        "aggregate_id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk",
+      "entityType": "fks",
+      "table": "event"
+    },
+    {
+      "columns": [
+        "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": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_message_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "session"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_todo_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "todo"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_share_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_share"
+    },
+    {
+      "columns": [
+        "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": "workspace_pk",
+      "table": "workspace",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "name"
+      ],
+      "nameExplicit": false,
+      "name": "data_migration_pk",
+      "table": "data_migration",
+      "entityType": "pks"
+    },
+    {
+      "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": [
+        "aggregate_id"
+      ],
+      "nameExplicit": false,
+      "name": "event_sequence_pk",
+      "table": "event_sequence",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "event_pk",
+      "table": "event",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "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": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "session_message_pk",
+      "table": "session_message",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "session_pk",
+      "table": "session",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "nameExplicit": false,
+      "name": "session_share_pk",
+      "table": "session_share",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "time_created",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "message_session_time_created_id_idx",
+      "entityType": "indexes",
+      "table": "message"
+    },
+    {
+      "columns": [
+        {
+          "value": "message_id",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_message_id_id_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_session_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_session_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "type",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_session_type_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "time_created",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_time_created_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "project_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_project_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_workspace_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "parent_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_parent_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "todo_session_idx",
+      "entityType": "indexes",
+      "table": "todo"
+    }
+  ],
+  "renames": []
+}

+ 11 - 0
packages/core/migration/20260602002951_lowly_union_jack/migration.sql

@@ -0,0 +1,11 @@
+CREATE TABLE `permission` (
+	`id` text PRIMARY KEY,
+	`project_id` text NOT NULL,
+	`action` text NOT NULL,
+	`resource` text NOT NULL,
+	`time_created` integer NOT NULL,
+	`time_updated` integer NOT NULL,
+	CONSTRAINT `fk_permission_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `permission_project_action_resource_idx` ON `permission` (`project_id`,`action`,`resource`);

+ 1676 - 0
packages/core/migration/20260602002951_lowly_union_jack/snapshot.json

@@ -0,0 +1,1676 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "id": "80d6efb8-93fd-4ce5-b320-45a05aaebdd7",
+  "prevIds": [
+    "226375f1-a19f-4c7b-8aa2-ccc5513d3b0d"
+  ],
+  "ddl": [
+    {
+      "name": "workspace",
+      "entityType": "tables"
+    },
+    {
+      "name": "data_migration",
+      "entityType": "tables"
+    },
+    {
+      "name": "account_state",
+      "entityType": "tables"
+    },
+    {
+      "name": "account",
+      "entityType": "tables"
+    },
+    {
+      "name": "control_account",
+      "entityType": "tables"
+    },
+    {
+      "name": "event_sequence",
+      "entityType": "tables"
+    },
+    {
+      "name": "event",
+      "entityType": "tables"
+    },
+    {
+      "name": "permission",
+      "entityType": "tables"
+    },
+    {
+      "name": "project",
+      "entityType": "tables"
+    },
+    {
+      "name": "message",
+      "entityType": "tables"
+    },
+    {
+      "name": "part",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_message",
+      "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": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "''",
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "branch",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "extra",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_used",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "data_migration"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_completed",
+      "entityType": "columns",
+      "table": "data_migration"
+    },
+    {
+      "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": "aggregate_id",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "owner_id",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "aggregate_id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "action",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "resource",
+      "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": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "worktree",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "vcs",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url_override",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_color",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_initialized",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "sandboxes",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "commands",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "message_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "parent_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "slug",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "path",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "title",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "version",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "share_url",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_additions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_deletions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_files",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_diffs",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "metadata",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "real",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "cost",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_input",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_output",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_reasoning",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_cache_read",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "0",
+      "generated": null,
+      "name": "tokens_cache_write",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "revert",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "permission",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "agent",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "model",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_compacting",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_archived",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "content",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "status",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "priority",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "position",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "secret",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "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": [
+        "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": [
+        "aggregate_id"
+      ],
+      "tableTo": "event_sequence",
+      "columnsTo": [
+        "aggregate_id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk",
+      "entityType": "fks",
+      "table": "event"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_permission_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "permission"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_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": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_message_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "session"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_todo_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "todo"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_share_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_share"
+    },
+    {
+      "columns": [
+        "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": "workspace_pk",
+      "table": "workspace",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "name"
+      ],
+      "nameExplicit": false,
+      "name": "data_migration_pk",
+      "table": "data_migration",
+      "entityType": "pks"
+    },
+    {
+      "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": [
+        "aggregate_id"
+      ],
+      "nameExplicit": false,
+      "name": "event_sequence_pk",
+      "table": "event_sequence",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "event_pk",
+      "table": "event",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "permission_pk",
+      "table": "permission",
+      "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": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "session_message_pk",
+      "table": "session_message",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "session_pk",
+      "table": "session",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "nameExplicit": false,
+      "name": "session_share_pk",
+      "table": "session_share",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        {
+          "value": "project_id",
+          "isExpression": false
+        },
+        {
+          "value": "action",
+          "isExpression": false
+        },
+        {
+          "value": "resource",
+          "isExpression": false
+        }
+      ],
+      "isUnique": true,
+      "where": null,
+      "origin": "manual",
+      "name": "permission_project_action_resource_idx",
+      "entityType": "indexes",
+      "table": "permission"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "time_created",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "message_session_time_created_id_idx",
+      "entityType": "indexes",
+      "table": "message"
+    },
+    {
+      "columns": [
+        {
+          "value": "message_id",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_message_id_id_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_session_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_session_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "type",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_session_type_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "time_created",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_time_created_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "project_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_project_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_workspace_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "parent_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_parent_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "todo_session_idx",
+      "entityType": "indexes",
+      "table": "todo"
+    }
+  ],
+  "renames": []
+}

+ 2 - 2
packages/core/src/agent.ts

@@ -3,7 +3,7 @@ export * as AgentV2 from "./agent"
 import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
 import { castDraft, enableMapSet, type Draft } from "immer"
 import { ModelV2 } from "./model"
-import { PermissionV2 } from "./permission"
+import { PermissionSchema } from "./permission/schema"
 import { ProviderV2 } from "./provider"
 import { PositiveInt } from "./schema"
 import { State } from "./state"
@@ -26,7 +26,7 @@ export class Info extends Schema.Class<Info>("AgentV2.Info")({
   hidden: Schema.Boolean,
   color: Color.pipe(Schema.optional),
   steps: PositiveInt.pipe(Schema.optional),
-  permissions: PermissionV2.Ruleset,
+  permissions: PermissionSchema.Ruleset,
 }) {
   static empty(id: ID) {
     return new Info({

+ 2 - 0
packages/core/src/database/migration.gen.ts

@@ -24,5 +24,7 @@ export const migrations = (
     import("./migration/20260511000411_data_migration_state"),
     import("./migration/20260511173437_session-metadata"),
     import("./migration/20260601010001_normalize_storage_paths"),
+    import("./migration/20260601202201_amazing_prowler"),
+    import("./migration/20260602002951_lowly_union_jack"),
   ])
 ).map((module) => module.default) satisfies DatabaseMigration.Migration[]

+ 11 - 0
packages/core/src/database/migration/20260601202201_amazing_prowler.ts

@@ -0,0 +1,11 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+export default {
+  id: "20260601202201_amazing_prowler",
+  up(tx) {
+    return Effect.gen(function* () {
+      yield* tx.run(`DROP TABLE \`permission\`;`)
+    })
+  },
+} satisfies DatabaseMigration.Migration

+ 22 - 0
packages/core/src/database/migration/20260602002951_lowly_union_jack.ts

@@ -0,0 +1,22 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+export default {
+  id: "20260602002951_lowly_union_jack",
+  up(tx) {
+    return Effect.gen(function* () {
+      yield* tx.run(`
+        CREATE TABLE \`permission\` (
+          \`id\` text PRIMARY KEY,
+          \`project_id\` text NOT NULL,
+          \`action\` text NOT NULL,
+          \`resource\` text NOT NULL,
+          \`time_created\` integer NOT NULL,
+          \`time_updated\` integer NOT NULL,
+          CONSTRAINT \`fk_permission_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
+        );
+      `)
+      yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`)
+    })
+  },
+} satisfies DatabaseMigration.Migration

+ 8 - 0
packages/core/src/location-layer.ts

@@ -13,6 +13,10 @@ import { Npm } from "./npm"
 import { ModelsDev } from "./models-dev"
 import { AppFileSystem } from "./filesystem"
 import { Global } from "./global"
+import { Database } from "./database/database"
+import { PermissionV2 } from "./permission"
+import { PermissionSaved } from "./permission/saved"
+import { SessionV2 } from "./session"
 
 export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
   lookup: (ref: Location.Ref) => {
@@ -25,6 +29,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
       Catalog.locationLayer,
       AgentV2.locationLayer,
       PluginBoot.locationLayer,
+      PermissionV2.locationLayer,
     ).pipe(Layer.provideMerge(location), Layer.fresh)
   },
   idleTimeToLive: "60 minutes",
@@ -36,5 +41,8 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
     ModelsDev.defaultLayer,
     AppFileSystem.defaultLayer,
     Global.defaultLayer,
+    Database.defaultLayer,
+    SessionV2.defaultLayer,
+    PermissionSaved.defaultLayer,
   ],
 }) {}

+ 280 - 35
packages/core/src/permission.ts

@@ -1,42 +1,110 @@
 export * as PermissionV2 from "./permission"
 
-import { Schema } from "effect"
+import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
+import { EventV2 } from "./event"
+import { Location } from "./location"
+import { AgentV2 } from "./agent"
+import { SessionV2 } from "./session"
+import { withStatics } from "./schema"
+import { Identifier } from "./util/identifier"
 import { Wildcard } from "./util/wildcard"
-import { Identifier } from "./id/id"
-import { Newtype } from "./schema"
-
-export class PermissionID extends Newtype<PermissionID>()(
-  "PermissionID",
-  Schema.String.check(Schema.isStartsWith("per")),
-) {
-  static ascending(id?: string): PermissionID {
-    return this.make(Identifier.ascending("permission", id))
-  }
+import { PermissionSchema } from "./permission/schema"
+import { PermissionSaved } from "./permission/saved"
+
+export { Effect, Rule, Ruleset } from "./permission/schema"
+type Effect = PermissionSchema.Effect
+type Rule = PermissionSchema.Rule
+type Ruleset = PermissionSchema.Ruleset
+
+export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
+  Schema.brand("PermissionV2.ID"),
+  withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
+)
+export type ID = typeof ID.Type
+
+export const Source = Schema.Union([
+  Schema.Struct({
+    type: Schema.Literal("tool"),
+    messageID: Schema.String,
+    callID: Schema.String,
+  }),
+]).annotate({ identifier: "PermissionV2.Source" })
+export type Source = typeof Source.Type
+
+export const Request = Schema.Struct({
+  id: ID,
+  sessionID: SessionV2.ID,
+  action: Schema.String,
+  resources: Schema.Array(Schema.String),
+  save: Schema.Array(Schema.String).pipe(Schema.optional),
+  metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
+  source: Source.pipe(Schema.optional),
+}).annotate({ identifier: "PermissionV2.Request" })
+export type Request = typeof Request.Type
+
+export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" })
+export type Reply = typeof Reply.Type
+
+export const AssertInput = Schema.Struct({
+  id: ID.pipe(Schema.optional),
+  sessionID: SessionV2.ID,
+  action: Schema.String,
+  resources: Schema.Array(Schema.String),
+  save: Schema.Array(Schema.String).pipe(Schema.optional),
+  metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
+  source: Source.pipe(Schema.optional),
+}).annotate({ identifier: "PermissionV2.AssertInput" })
+export type AssertInput = typeof AssertInput.Type
+
+export const ReplyInput = Schema.Struct({
+  requestID: ID,
+  reply: Reply,
+  message: Schema.String.pipe(Schema.optional),
+}).annotate({ identifier: "PermissionV2.ReplyInput" })
+export type ReplyInput = typeof ReplyInput.Type
+
+export const AskResult = Schema.Struct({
+  id: ID,
+  effect: PermissionSchema.Effect,
+}).annotate({ identifier: "PermissionV2.AskResult" })
+export type AskResult = typeof AskResult.Type
+
+export const Event = {
+  Asked: EventV2.define({ type: "permission.v2.asked", schema: Request.fields }),
+  Replied: EventV2.define({
+    type: "permission.v2.replied",
+    schema: {
+      sessionID: SessionV2.ID,
+      requestID: ID,
+      reply: Reply,
+    },
+  }),
 }
 
-export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "Permission.Action" })
-export type Action = typeof Action.Type
+export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
+
+export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
+  feedback: Schema.String,
+}) {}
 
-export const Rule = Schema.Struct({
-  permission: Schema.String,
-  pattern: Schema.String,
-  action: Action,
-}).annotate({ identifier: "Permission.Rule" })
-export type Rule = typeof Rule.Type
+export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
+  rules: PermissionSchema.Ruleset,
+}) {}
 
-export const Ruleset = Schema.Array(Rule).annotate({ identifier: "Permission.Ruleset" })
-export type Ruleset = typeof Ruleset.Type
+export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
+  requestID: ID,
+}) {}
 
-const EDIT_TOOLS = ["edit", "write", "apply_patch"]
+export type Error = DeniedError | RejectedError | CorrectedError
 
-export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
+export function evaluate(action: string, resource: string, ...rulesets: Ruleset[]): Rule {
   return (
     rulesets
       .flat()
-      .findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
-      action: "ask",
-      permission,
-      pattern: "*",
+      .findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? {
+      action,
+      resource: "*",
+      effect: "ask",
     }
   )
 }
@@ -45,12 +113,189 @@ export function merge(...rulesets: Ruleset[]): Ruleset {
   return rulesets.flat()
 }
 
-export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
-  return new Set(
-    tools.filter((tool) => {
-      const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
-      const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
-      return rule?.pattern === "*" && rule.action === "deny"
-    }),
-  )
+export interface Interface {
+  readonly ask: (input: AssertInput) => EffectRuntime.Effect<AskResult, SessionV2.NotFoundError>
+  readonly assert: (input: AssertInput) => EffectRuntime.Effect<void, Error | SessionV2.NotFoundError>
+  readonly reply: (input: ReplyInput) => EffectRuntime.Effect<void, NotFoundError>
+  readonly get: (id: ID) => EffectRuntime.Effect<Request | undefined>
+  readonly forSession: (sessionID: SessionV2.ID) => EffectRuntime.Effect<ReadonlyArray<Request>>
+  readonly list: () => EffectRuntime.Effect<ReadonlyArray<Request>>
 }
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Permission") {}
+
+interface Pending {
+  readonly request: Request
+  readonly deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
+}
+
+export const layer = Layer.effect(
+  Service,
+  EffectRuntime.gen(function* () {
+    const events = yield* EventV2.Service
+    const location = yield* Location.Service
+    const agents = yield* AgentV2.Service
+    const sessions = yield* SessionV2.Service
+    const saved = yield* PermissionSaved.Service
+    const pending = new Map<ID, Pending>()
+
+    yield* EffectRuntime.addFinalizer(() =>
+      EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
+        discard: true,
+      }).pipe(
+        EffectRuntime.ensuring(
+          EffectRuntime.sync(() => {
+            pending.clear()
+          }),
+        ),
+      ),
+    )
+
+    const savedRules = EffectRuntime.fnUntraced(function* () {
+      return (yield* saved.list({ projectID: location.project.id })).map(
+        (item): Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
+      )
+    })
+
+    const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) {
+      const session = yield* sessions.get(sessionID)
+      if (!session.agent) return []
+      return (yield* agents.get(AgentV2.ID.make(session.agent)))?.permissions ?? []
+    })
+
+    function denied(input: AssertInput, rules: Ruleset) {
+      return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
+    }
+
+    function relevant(input: AssertInput, rules: Ruleset) {
+      return rules.filter((rule) => Wildcard.match(input.action, rule.action))
+    }
+
+    const evaluateInput = EffectRuntime.fnUntraced(function* (input: AssertInput) {
+      const rules = yield* configured(input.sessionID)
+      if (denied(input, rules)) return { effect: "deny" as const, rules }
+      const all = [...rules, ...(yield* savedRules())]
+      const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
+      const effect: Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
+      return { effect, rules: all }
+    })
+
+    function request(input: AssertInput): Request {
+      return {
+        id: input.id ?? ID.create(),
+        sessionID: input.sessionID,
+        action: input.action,
+        resources: input.resources,
+        save: input.save,
+        metadata: input.metadata,
+        source: input.source,
+      }
+    }
+
+    const create = EffectRuntime.fnUntraced(function* (request: Request) {
+      const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
+      const item = { request, deferred }
+      pending.set(request.id, item)
+      yield* events.publish(Event.Asked, request)
+      return item
+    })
+
+    const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) {
+      const result = yield* evaluateInput(input)
+      const value = request(input)
+      if (result.effect === "ask") yield* create(value)
+      return { id: value.id, effect: result.effect }
+    })
+
+    const assert = EffectRuntime.fn("PermissionV2.assert")(function* (input: AssertInput) {
+      const result = yield* evaluateInput(input)
+      if (result.effect === "deny") {
+        return yield* new DeniedError({
+          rules: relevant(input, result.rules),
+        })
+      }
+      if (result.effect === "allow") return
+      const item = yield* create(request(input))
+      return yield* Deferred.await(item.deferred).pipe(
+        EffectRuntime.ensuring(
+          EffectRuntime.sync(() => {
+            pending.delete(item.request.id)
+          }),
+        ),
+      )
+    })
+
+    const reply = EffectRuntime.fn("PermissionV2.reply")(function* (input: ReplyInput) {
+      const existing = pending.get(input.requestID)
+      if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
+      pending.delete(input.requestID)
+      yield* events.publish(Event.Replied, {
+        sessionID: existing.request.sessionID,
+        requestID: existing.request.id,
+        reply: input.reply,
+      })
+
+      if (input.reply === "reject") {
+        yield* Deferred.fail(
+          existing.deferred,
+          input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
+        )
+        for (const [id, item] of pending) {
+          if (item.request.sessionID !== existing.request.sessionID) continue
+          pending.delete(id)
+          yield* events.publish(Event.Replied, {
+            sessionID: item.request.sessionID,
+            requestID: item.request.id,
+            reply: "reject",
+          })
+          yield* Deferred.fail(item.deferred, new RejectedError())
+        }
+        return
+      }
+
+      if (input.reply === "always" && existing.request.save?.length) {
+        yield* saved.add({ projectID: location.project.id, action: existing.request.action, resources: existing.request.save })
+      }
+      yield* Deferred.succeed(existing.deferred, undefined)
+      if (input.reply !== "always" || !existing.request.save?.length) return
+
+      const rememberedRules = yield* savedRules()
+      for (const [id, item] of pending) {
+        const input = { ...item.request }
+        const rules = yield* configured(item.request.sessionID).pipe(
+          EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
+        )
+        if (!rules) continue
+        if (denied(input, rules)) continue
+        const effective = [...rules, ...rememberedRules]
+        if (
+          !item.request.resources.every((resource) => evaluate(item.request.action, resource, effective).effect === "allow")
+        )
+          continue
+        pending.delete(id)
+        yield* events.publish(Event.Replied, {
+          sessionID: item.request.sessionID,
+          requestID: item.request.id,
+          reply: "always",
+        })
+        yield* Deferred.succeed(item.deferred, undefined)
+      }
+    })
+
+    const list = EffectRuntime.fn("PermissionV2.list")(function* () {
+      return Array.from(pending.values(), (item) => item.request)
+    })
+
+    const get = EffectRuntime.fn("PermissionV2.get")(function* (id: ID) {
+      return pending.get(id)?.request
+    })
+
+    const forSession = EffectRuntime.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) {
+      return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
+    })
+
+    return Service.of({ ask, assert, reply, get, forSession, list })
+  }),
+)
+
+export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))

+ 96 - 0
packages/core/src/permission/legacy.ts

@@ -0,0 +1,96 @@
+export * as PermissionLegacy from "./legacy"
+
+import { Schema } from "effect"
+import { ProjectV2 } from "../project"
+import { withStatics } from "../schema"
+import { SessionSchema } from "../session/schema"
+import { Identifier } from "../util/identifier"
+
+export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
+  Schema.brand("PermissionID"),
+  withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
+)
+export type ID = typeof ID.Type
+
+export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" })
+export type Action = typeof Action.Type
+
+export const Rule = Schema.Struct({
+  permission: Schema.String,
+  pattern: Schema.String,
+  action: Action,
+}).annotate({ identifier: "PermissionRule" })
+export type Rule = typeof Rule.Type
+
+export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
+export type Ruleset = typeof Ruleset.Type
+
+export const Request = Schema.Struct({
+  id: ID,
+  sessionID: SessionSchema.ID,
+  permission: Schema.String,
+  patterns: Schema.Array(Schema.String),
+  metadata: Schema.Record(Schema.String, Schema.Unknown),
+  always: Schema.Array(Schema.String),
+  tool: Schema.Struct({
+    messageID: Schema.String,
+    callID: Schema.String,
+  }).pipe(Schema.optional),
+}).annotate({ identifier: "PermissionRequest" })
+export type Request = typeof Request.Type
+
+export const Reply = Schema.Literals(["once", "always", "reject"])
+export type Reply = typeof Reply.Type
+
+export const ReplyBody = Schema.Struct({
+  reply: Reply,
+  message: Schema.String.pipe(Schema.optional),
+}).annotate({ identifier: "PermissionReplyBody" })
+export type ReplyBody = typeof ReplyBody.Type
+
+export const Approval = Schema.Struct({
+  projectID: ProjectV2.ID,
+  patterns: Schema.Array(Schema.String),
+}).annotate({ identifier: "PermissionApproval" })
+export type Approval = typeof Approval.Type
+
+export const AskInput = Schema.Struct({
+  ...Request.fields,
+  id: ID.pipe(Schema.optional),
+  ruleset: Ruleset,
+}).annotate({ identifier: "PermissionAskInput" })
+export type AskInput = typeof AskInput.Type
+
+export const ReplyInput = Schema.Struct({
+  requestID: ID,
+  ...ReplyBody.fields,
+}).annotate({ identifier: "PermissionReplyInput" })
+export type ReplyInput = typeof ReplyInput.Type
+
+export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
+  override get message() {
+    return "The user rejected permission to use this specific tool call."
+  }
+}
+
+export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
+  feedback: Schema.String,
+}) {
+  override get message() {
+    return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
+  }
+}
+
+export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
+  ruleset: Schema.Any,
+}) {
+  override get message() {
+    return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
+  }
+}
+
+export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Permission.NotFoundError", {
+  requestID: ID,
+}) {}
+
+export type Error = DeniedError | RejectedError | CorrectedError

+ 78 - 0
packages/core/src/permission/saved.ts

@@ -0,0 +1,78 @@
+export * as PermissionSaved from "./saved"
+
+import { eq } from "drizzle-orm"
+import { Context, Effect, Layer, Schema } from "effect"
+import { Database } from "../database/database"
+import { ProjectV2 } from "../project"
+import { withStatics } from "../schema"
+import { Identifier } from "../util/identifier"
+import { PermissionTable } from "./sql"
+
+export const ID = Schema.String.pipe(
+  Schema.brand("PermissionSaved.ID"),
+  withStatics((schema) => ({ create: () => schema.make("psv_" + Identifier.ascending()) })),
+)
+export type ID = typeof ID.Type
+
+export const Info = Schema.Struct({
+  id: ID,
+  projectID: ProjectV2.ID,
+  action: Schema.String,
+  resource: Schema.String,
+}).annotate({ identifier: "PermissionSaved.Info" })
+export type Info = typeof Info.Type
+
+export const ListInput = Schema.Struct({
+  projectID: ProjectV2.ID.pipe(Schema.optional),
+}).annotate({ identifier: "PermissionSaved.ListInput" })
+export type ListInput = typeof ListInput.Type
+
+export const AddInput = Schema.Struct({
+  projectID: ProjectV2.ID,
+  action: Schema.String,
+  resources: Schema.Array(Schema.String),
+}).annotate({ identifier: "PermissionSaved.AddInput" })
+export type AddInput = typeof AddInput.Type
+
+export interface Interface {
+  readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
+  readonly add: (input: AddInput) => Effect.Effect<void>
+  readonly remove: (id: ID) => Effect.Effect<void>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PermissionSaved") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const { db } = yield* Database.Service
+
+    const list = Effect.fn("PermissionSaved.list")(function* (input?: ListInput) {
+      const rows = yield* db
+        .select()
+        .from(PermissionTable)
+        .where(input?.projectID ? eq(PermissionTable.project_id, input.projectID) : undefined)
+        .all()
+        .pipe(Effect.orDie)
+      return rows.map((row): Info => ({ id: row.id, projectID: row.project_id, action: row.action, resource: row.resource }))
+    })
+
+    const add = Effect.fn("PermissionSaved.add")(function* (input: AddInput) {
+      if (!input.resources.length) return
+      yield* db
+        .insert(PermissionTable)
+        .values(input.resources.map((resource) => ({ id: ID.create(), project_id: input.projectID, action: input.action, resource })))
+        .onConflictDoNothing()
+        .run()
+        .pipe(Effect.orDie)
+    })
+
+    const remove = Effect.fn("PermissionSaved.remove")(function* (id: ID) {
+      yield* db.delete(PermissionTable).where(eq(PermissionTable.id, id)).run().pipe(Effect.orDie)
+    })
+
+    return Service.of({ list, add, remove })
+  }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))

+ 16 - 0
packages/core/src/permission/schema.ts

@@ -0,0 +1,16 @@
+export * as PermissionSchema from "./schema"
+
+import { Schema } from "effect"
+
+export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" })
+export type Effect = typeof Effect.Type
+
+export const Rule = Schema.Struct({
+  action: Schema.String,
+  resource: Schema.String,
+  effect: Effect,
+}).annotate({ identifier: "PermissionV2.Rule" })
+export type Rule = typeof Rule.Type
+
+export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
+export type Ruleset = typeof Ruleset.Type

+ 20 - 0
packages/core/src/permission/sql.ts

@@ -0,0 +1,20 @@
+import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
+import { Timestamps } from "../database/schema.sql"
+import { ProjectV2 } from "../project"
+import { ProjectTable } from "../project/sql"
+import type { PermissionSaved } from "./saved"
+
+export const PermissionTable = sqliteTable(
+  "permission",
+  {
+    id: text().$type<PermissionSaved.ID>().primaryKey(),
+    project_id: text()
+      .$type<ProjectV2.ID>()
+      .notNull()
+      .references(() => ProjectTable.id, { onDelete: "cascade" }),
+    action: text().notNull(),
+    resource: text().notNull(),
+    ...Timestamps,
+  },
+  (table) => [uniqueIndex("permission_project_action_resource_idx").on(table.project_id, table.action, table.resource)],
+)

+ 34 - 34
packages/core/src/plugin/agent.ts

@@ -104,23 +104,23 @@ export const Plugin = PluginV2.define({
     const worktree = location.directory
     const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
     const readonlyExternalDirectory: PermissionV2.Ruleset = [
-      { permission: "external_directory", pattern: "*", action: "ask" },
+      { action: "external_directory", resource: "*", effect: "ask" },
       ...whitelistedDirs.map(
-        (pattern): PermissionV2.Rule => ({ permission: "external_directory", pattern, action: "allow" }),
+        (resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }),
       ),
     ]
     const defaults: PermissionV2.Ruleset = [
-      { permission: "*", pattern: "*", action: "allow" },
+      { action: "*", resource: "*", effect: "allow" },
       ...readonlyExternalDirectory,
-      { permission: "question", pattern: "*", action: "deny" },
-      { permission: "plan_enter", pattern: "*", action: "deny" },
-      { permission: "plan_exit", pattern: "*", action: "deny" },
-      { permission: "repo_clone", pattern: "*", action: "deny" },
-      { permission: "repo_overview", pattern: "*", action: "deny" },
-      { permission: "read", pattern: "*", action: "allow" },
-      { permission: "read", pattern: "*.env", action: "ask" },
-      { permission: "read", pattern: "*.env.*", action: "ask" },
-      { permission: "read", pattern: "*.env.example", action: "allow" },
+      { action: "question", resource: "*", effect: "deny" },
+      { action: "plan_enter", resource: "*", effect: "deny" },
+      { action: "plan_exit", resource: "*", effect: "deny" },
+      { action: "repo_clone", resource: "*", effect: "deny" },
+      { action: "repo_overview", resource: "*", effect: "deny" },
+      { action: "read", resource: "*", effect: "allow" },
+      { action: "read", resource: "*.env", effect: "ask" },
+      { action: "read", resource: "*.env.*", effect: "ask" },
+      { action: "read", resource: "*.env.example", effect: "allow" },
     ]
 
     yield* agent.update((editor) => {
@@ -129,8 +129,8 @@ export const Plugin = PluginV2.define({
         item.mode = "primary"
         item.permissions.push(
           ...PermissionV2.merge(defaults, [
-            { permission: "question", pattern: "*", action: "allow" },
-            { permission: "plan_enter", pattern: "*", action: "allow" },
+            { action: "question", resource: "*", effect: "allow" },
+            { action: "plan_enter", resource: "*", effect: "allow" },
           ]),
         )
       })
@@ -140,15 +140,15 @@ export const Plugin = PluginV2.define({
         item.mode = "primary"
         item.permissions.push(
           ...PermissionV2.merge(defaults, [
-            { permission: "question", pattern: "*", action: "allow" },
-            { permission: "plan_exit", pattern: "*", action: "allow" },
-            { permission: "external_directory", pattern: path.join(Global.Path.data, "plans", "*"), action: "allow" },
-            { permission: "edit", pattern: "*", action: "deny" },
-            { permission: "edit", pattern: path.join(".opencode", "plans", "*.md"), action: "allow" },
+            { action: "question", resource: "*", effect: "allow" },
+            { action: "plan_exit", resource: "*", effect: "allow" },
+            { action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
+            { action: "edit", resource: "*", effect: "deny" },
+            { action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" },
             {
-              permission: "edit",
-              pattern: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
-              action: "allow",
+              action: "edit",
+              resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
+              effect: "allow",
             },
           ]),
         )
@@ -159,7 +159,7 @@ export const Plugin = PluginV2.define({
           "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
         item.mode = "subagent"
         item.permissions.push(
-          ...PermissionV2.merge(defaults, [{ permission: "todowrite", pattern: "*", action: "deny" }]),
+          ...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }]),
         )
       })
 
@@ -172,14 +172,14 @@ export const Plugin = PluginV2.define({
           ...PermissionV2.merge(
             defaults,
             [
-              { permission: "*", pattern: "*", action: "deny" },
-              { permission: "grep", pattern: "*", action: "allow" },
-              { permission: "glob", pattern: "*", action: "allow" },
-              { permission: "list", pattern: "*", action: "allow" },
-              { permission: "bash", pattern: "*", action: "allow" },
-              { permission: "webfetch", pattern: "*", action: "allow" },
-              { permission: "websearch", pattern: "*", action: "allow" },
-              { permission: "read", pattern: "*", action: "allow" },
+              { action: "*", resource: "*", effect: "deny" },
+              { action: "grep", resource: "*", effect: "allow" },
+              { action: "glob", resource: "*", effect: "allow" },
+              { action: "list", resource: "*", effect: "allow" },
+              { action: "bash", resource: "*", effect: "allow" },
+              { action: "webfetch", resource: "*", effect: "allow" },
+              { action: "websearch", resource: "*", effect: "allow" },
+              { action: "read", resource: "*", effect: "allow" },
             ],
             readonlyExternalDirectory,
           ),
@@ -190,21 +190,21 @@ export const Plugin = PluginV2.define({
         item.mode = "primary"
         item.hidden = true
         item.system = PROMPT_COMPACTION
-        item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }]))
+        item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
       })
 
       editor.update(AgentV2.ID.make("title"), (item) => {
         item.mode = "primary"
         item.hidden = true
         item.system = PROMPT_TITLE
-        item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }]))
+        item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
       })
 
       editor.update(AgentV2.ID.make("summary"), (item) => {
         item.mode = "primary"
         item.hidden = true
         item.system = PROMPT_SUMMARY
-        item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }]))
+        item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
       })
     })
   }),

+ 2 - 2
packages/core/src/session/legacy.ts

@@ -2,7 +2,7 @@ export * as SessionLegacy from "./legacy"
 
 import { Effect, Schema, Types } from "effect"
 import { EventV2 } from "../event"
-import { PermissionV2 } from "../permission"
+import { PermissionLegacy } from "../permission/legacy"
 import { ProjectV2 } from "../project"
 import { ProviderV2 } from "../provider"
 import { optionalOmitUndefined, withStatics } from "../schema"
@@ -558,7 +558,7 @@ export const SessionInfo = Schema.Struct({
     compacting: optionalOmitUndefined(NonNegativeInt),
     archived: optionalOmitUndefined(Schema.Finite),
   }),
-  permission: optionalOmitUndefined(PermissionV2.Ruleset),
+  permission: optionalOmitUndefined(PermissionLegacy.Ruleset),
   revert: optionalOmitUndefined(SessionRevert),
 }).annotate({ identifier: "Session" })
 export type SessionInfo = typeof SessionInfo.Type

+ 2 - 10
packages/core/src/session/sql.ts

@@ -3,7 +3,7 @@ import * as DatabasePath from "../database/path"
 import { ProjectTable } from "../project/sql"
 import type { SessionMessage } from "./message"
 import type { Snapshot } from "../snapshot"
-import { PermissionV2 } from "../permission"
+import { PermissionLegacy } from "../permission/legacy"
 import { ProjectV2 } from "../project"
 import type { SessionSchema } from "./schema"
 import type { MessageID, PartID, Info as LegacyMessageInfo, Part as LegacyMessagePart } from "./legacy"
@@ -42,7 +42,7 @@ export const SessionTable = sqliteTable(
     tokens_cache_read: integer().notNull().default(0),
     tokens_cache_write: integer().notNull().default(0),
     revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
-    permission: text({ mode: "json" }).$type<PermissionV2.Ruleset>(),
+    permission: text({ mode: "json" }).$type<PermissionLegacy.Ruleset>(),
     agent: text(),
     model: text({ mode: "json" }).$type<{
       id: string
@@ -129,11 +129,3 @@ export const SessionMessageTable = sqliteTable(
     index("session_message_time_created_idx").on(table.time_created),
   ],
 )
-
-export const PermissionTable = sqliteTable("permission", {
-  project_id: text()
-    .primaryKey()
-    .references(() => ProjectTable.id, { onDelete: "cascade" }),
-  ...Timestamps,
-  data: text({ mode: "json" }).notNull().$type<PermissionV2.Ruleset>(),
-})

+ 11 - 11
packages/core/test/config/agent.test.ts

@@ -19,7 +19,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
       yield* defaults((editor) =>
         editor.update(build, (agent) => {
           agent.mode = "primary"
-          agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" })
+          agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
         }),
       )
 
@@ -30,16 +30,16 @@ describe("ConfigAgentPlugin.Plugin", () => {
             new Config.Loaded({
               source: { type: "memory" },
               info: decode({
-                permissions: [{ permission: "bash", pattern: "*", action: "ask" }],
+                permissions: [{ action: "bash", resource: "*", effect: "ask" }],
                 agents: {
                   build: {
-                    permissions: [{ permission: "bash", pattern: "git *", action: "allow" }],
+                    permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
                   },
                   reviewer: {
                     model: "openrouter/openai/gpt-5",
                     description: "Review changes",
                     mode: "subagent",
-                    permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
+                    permissions: [{ action: "edit", resource: "*", effect: "deny" }],
                   },
                   removed: { description: "Removed later" },
                 },
@@ -65,12 +65,12 @@ describe("ConfigAgentPlugin.Plugin", () => {
       const buildAgent = yield* agents.get(build)
       if (!buildAgent) throw new Error("expected configured build agent")
       expect(buildAgent.permissions).toEqual([
-        { permission: "bash", pattern: "*", action: "allow" },
-        { permission: "bash", pattern: "*", action: "ask" },
-        { permission: "bash", pattern: "git *", action: "allow" },
+        { action: "bash", resource: "*", effect: "allow" },
+        { action: "bash", resource: "*", effect: "ask" },
+        { action: "bash", resource: "git *", effect: "allow" },
       ])
-      expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow")
-      expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask")
+      expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
+      expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
 
       const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
       if (!reviewer) throw new Error("expected configured reviewer agent")
@@ -81,8 +81,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
         model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
       })
       expect(reviewer.permissions).toEqual([
-        { permission: "bash", pattern: "*", action: "ask" },
-        { permission: "edit", pattern: "*", action: "deny" },
+        { action: "bash", resource: "*", effect: "ask" },
+        { action: "edit", resource: "*", effect: "deny" },
       ])
       expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
     }),

+ 6 - 6
packages/core/test/config/config.test.ts

@@ -170,8 +170,8 @@ describe("Config", () => {
                 enterprise: { url: "https://share.example.com" },
                 username: "test-user",
                 permissions: [
-                  { permission: "bash", pattern: "*", action: "ask" },
-                  { permission: "bash", pattern: "git status", action: "allow" },
+                  { action: "bash", resource: "*", effect: "ask" },
+                  { action: "bash", resource: "git status", effect: "allow" },
                 ],
                 agents: {
                   reviewer: {
@@ -188,7 +188,7 @@ describe("Config", () => {
                     color: "warning",
                     steps: 12,
                     disabled: false,
-                    permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
+                    permissions: [{ action: "edit", resource: "*", effect: "deny" }],
                   },
                 },
                 snapshots: false,
@@ -254,8 +254,8 @@ describe("Config", () => {
             expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
             expect(documents[0]?.info.username).toBe("test-user")
             expect(documents[0]?.info.permissions).toEqual([
-              { permission: "bash", pattern: "*", action: "ask" },
-              { permission: "bash", pattern: "git status", action: "allow" },
+              { action: "bash", resource: "*", effect: "ask" },
+              { action: "bash", resource: "git status", effect: "allow" },
             ])
             expect(documents[0]?.info.agents?.reviewer).toEqual({
               model: "openrouter/openai/gpt-5",
@@ -271,7 +271,7 @@ describe("Config", () => {
               color: "warning",
               steps: 12,
               disabled: false,
-              permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
+              permissions: [{ action: "edit", resource: "*", effect: "deny" }],
             })
             expect(documents[0]?.info.snapshots).toBe(false)
             expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })

+ 1 - 1
packages/core/test/database-migration.test.ts

@@ -43,7 +43,7 @@ describe("DatabaseMigration", () => {
         expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
           name: "session",
         })
-        expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 22 })
+        expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 24 })
       }),
     )
   })

+ 176 - 0
packages/core/test/permission.test.ts

@@ -0,0 +1,176 @@
+import { describe, expect } from "bun:test"
+import { Deferred, Effect, Fiber, Layer } from "effect"
+import { AgentV2 } from "@opencode-ai/core/agent"
+import { Database } from "@opencode-ai/core/database/database"
+import { EventV2 } from "@opencode-ai/core/event"
+import { Location } from "@opencode-ai/core/location"
+import { PermissionV2 } from "@opencode-ai/core/permission"
+import { PermissionTable } from "@opencode-ai/core/permission/sql"
+import { PermissionSaved } from "@opencode-ai/core/permission/saved"
+import { Project } from "@opencode-ai/core/project"
+import { ProjectTable } from "@opencode-ai/core/project/sql"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { SessionV2 } from "@opencode-ai/core/session"
+import { SessionTable } from "@opencode-ai/core/session/sql"
+import { eq } from "drizzle-orm"
+import { location } from "./fixture/location"
+import { testEffect } from "./lib/effect"
+
+const database = Database.layerFromPath(":memory:")
+const current = Layer.succeed(
+  Location.Service,
+  Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
+)
+const events = EventV2.layer.pipe(Layer.provide(database))
+const sessions = SessionV2.layer.pipe(Layer.provide(database))
+const saved = PermissionSaved.layer.pipe(Layer.provide(database))
+const layer = PermissionV2.locationLayer.pipe(
+  Layer.provideMerge(database),
+  Layer.provideMerge(events),
+  Layer.provideMerge(current),
+  Layer.provideMerge(sessions),
+  Layer.provideMerge(saved),
+)
+const it = testEffect(layer)
+
+function setup(rules: PermissionV2.Ruleset = []) {
+  return Effect.gen(function* () {
+    const { db } = yield* Database.Service
+    yield* db
+      .insert(ProjectTable)
+      .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
+      .onConflictDoNothing()
+      .run()
+      .pipe(Effect.orDie)
+    yield* db
+      .insert(SessionTable)
+      .values({
+        id: SessionV2.ID.make("ses_test"),
+        project_id: Project.ID.global,
+        slug: "test",
+        directory: "/project",
+        title: "test",
+        version: "test",
+        agent: "test",
+      })
+      .onConflictDoNothing()
+      .run()
+      .pipe(Effect.orDie)
+    yield* setRules(rules)
+  })
+}
+
+function setRules(rules: PermissionV2.Ruleset) {
+  return Effect.gen(function* () {
+    const agents = yield* AgentV2.Service
+    const update = yield* agents.transform()
+    yield* update((editor) =>
+      editor.update(AgentV2.ID.make("test"), (agent) => {
+        agent.permissions = [...rules]
+      }),
+    )
+  })
+}
+
+function assertion(input: Partial<PermissionV2.AssertInput> = {}) {
+  return {
+    id: PermissionV2.ID.create("per_test"),
+    sessionID: SessionV2.ID.make("ses_test"),
+    action: "read",
+    resources: ["src/index.ts"],
+    ...input,
+  } satisfies PermissionV2.AssertInput
+}
+
+function waitForRequest() {
+  return Effect.gen(function* () {
+    const service = yield* PermissionV2.Service
+    const events = yield* EventV2.Service
+    const asked = yield* Deferred.make<PermissionV2.Request>()
+    const unsubscribe = yield* events.listen((event) =>
+      event.type === PermissionV2.Event.Asked.type
+        ? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid)
+        : Effect.void,
+    )
+    yield* Effect.addFinalizer(() => unsubscribe)
+    const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
+    const request = yield* Deferred.await(asked)
+    return { service, fiber, request }
+  })
+}
+
+describe("PermissionV2", () => {
+  it.effect("returns the evaluated effect and only queues prompts", () =>
+    Effect.gen(function* () {
+      yield* setup([{ action: "read", resource: "*", effect: "allow" }])
+      const service = yield* PermissionV2.Service
+      expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" })
+      expect(yield* service.list()).toEqual([])
+      yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
+      expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" })
+      expect(yield* service.list()).toEqual([])
+      yield* setRules([])
+      expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" })
+      expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined()
+    }),
+  )
+
+  it.effect("allows and denies from explicit rules without asking", () =>
+    Effect.gen(function* () {
+      yield* setup([{ action: "read", resource: "*", effect: "allow" }])
+      const service = yield* PermissionV2.Service
+      yield* service.assert(assertion())
+      yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
+      const denied = yield* service.assert(assertion()).pipe(Effect.flip)
+      expect(denied).toBeInstanceOf(PermissionV2.DeniedError)
+      expect(yield* service.list()).toEqual([])
+    }),
+  )
+
+  it.effect("resolves an asked permission once", () =>
+    Effect.gen(function* () {
+      yield* setup()
+      const { service, fiber, request } = yield* waitForRequest()
+      expect(yield* service.list()).toEqual([request])
+      expect(yield* service.forSession(request.sessionID)).toEqual([request])
+      expect(yield* service.forSession(SessionV2.ID.make("ses_other"))).toEqual([])
+      expect(yield* service.get(request.id)).toEqual(request)
+      yield* service.reply({ requestID: request.id, reply: "once" })
+      yield* Fiber.join(fiber)
+      expect(yield* service.list()).toEqual([])
+      expect(yield* service.get(request.id)).toBeUndefined()
+    }),
+  )
+
+  it.effect("stores and removes saved resources for a project", () =>
+    Effect.gen(function* () {
+      yield* setup()
+      const service = yield* PermissionV2.Service
+      const asked = yield* Deferred.make<PermissionV2.Request>()
+      const events = yield* EventV2.Service
+      const unsubscribe = yield* events.listen((event) =>
+        event.type === PermissionV2.Event.Asked.type
+          ? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid)
+          : Effect.void,
+      )
+      yield* Effect.addFinalizer(() => unsubscribe)
+      const fiber = yield* service.assert(assertion({ save: ["src/*"] })).pipe(Effect.forkScoped)
+      const request = yield* Deferred.await(asked)
+      yield* service.reply({ requestID: request.id, reply: "always" })
+      yield* Fiber.join(fiber)
+
+      const { db } = yield* Database.Service
+      expect(yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Project.ID.global)).all()).toMatchObject([
+        { action: "read", resource: "src/*" },
+      ])
+      const saved = yield* PermissionSaved.Service
+      const id = (yield* saved.list())[0]!.id
+      expect(yield* saved.list()).toEqual([
+        { id, projectID: Project.ID.global, action: "read", resource: "src/*" },
+      ])
+      yield* service.assert(assertion({ id: PermissionV2.ID.create("per_next"), resources: ["src/next.ts"] }))
+      yield* saved.remove(id)
+      expect(yield* saved.list()).toEqual([])
+    }),
+  )
+})

+ 2 - 1
packages/opencode/src/agent/agent.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { Config } from "@/config/config"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { Provider } from "@/provider/provider"
@@ -36,7 +37,7 @@ export const Info = Schema.Struct({
   topP: Schema.optional(Schema.Finite),
   temperature: Schema.optional(Schema.Finite),
   color: Schema.optional(Schema.String),
-  permission: Permission.Ruleset,
+  permission: PermissionLegacy.Ruleset,
   model: Schema.optional(
     Schema.Struct({
       modelID: ProviderV2.ModelID,

+ 3 - 2
packages/opencode/src/agent/subagent-permissions.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import type { Permission } from "../permission"
 import type { Agent } from "./agent"
 
@@ -15,10 +16,10 @@ import type { Agent } from "./agent"
  *    doesn't already permit them.
  */
 export function deriveSubagentSessionPermission(input: {
-  parentSessionPermission: Permission.Ruleset
+  parentSessionPermission: PermissionLegacy.Ruleset
   parentAgent: Agent.Info | undefined
   subagent: Agent.Info
-}): Permission.Ruleset {
+}): PermissionLegacy.Ruleset {
   const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
   const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
   const parentAgentDenies =

+ 3 - 2
packages/opencode/src/cli/cmd/debug/agent.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { EOL } from "os"
 import { SessionLegacy } from "@opencode-ai/core/session/legacy"
 import { basename } from "path"
@@ -193,12 +194,12 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
     abort: new AbortController().signal,
     messages: [],
     metadata: () => Effect.void,
-    ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
+    ask(req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) {
       return Effect.sync(() => {
         for (const pattern of req.patterns) {
           const rule = Permission.evaluate(req.permission, pattern, ruleset)
           if (rule.action === "deny") {
-            throw new Permission.DeniedError({ ruleset })
+            throw new PermissionLegacy.DeniedError({ ruleset })
           }
         }
       })

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

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 // CLI entry point for `opencode run`.
 //
 // Handles three modes:
@@ -367,7 +368,7 @@ export const RunCommand = effectCmd({
         process.exit(1)
       }
 
-      const rules: Permission.Ruleset = args.interactive
+      const rules: PermissionLegacy.Ruleset = args.interactive
         ? []
         : [
             {

+ 1 - 1
packages/opencode/src/permission/evaluate.ts

@@ -1 +1 @@
-export { evaluate } from "@opencode-ai/core/permission"
+export { evaluate } from "."

+ 49 - 137
packages/opencode/src/permission/index.ts

@@ -1,142 +1,53 @@
 import { ConfigPermission } from "@/config/permission"
 import { InstanceState } from "@/effect/instance-state"
-import { ProjectV2 } from "@opencode-ai/core/project"
-import { MessageID, SessionID } from "@/session/schema"
-import { PermissionTable } from "@opencode-ai/core/session/sql"
-import { Database } from "@opencode-ai/core/database/database"
-import { eq } from "drizzle-orm"
 import * as Log from "@opencode-ai/core/util/log"
 import { Wildcard } from "@opencode-ai/core/util/wildcard"
-import { Deferred, Effect, Layer, Schema, Context } from "effect"
+import { Deferred, Effect, Layer, Context } from "effect"
 import os from "os"
-import { PermissionV2 } from "@opencode-ai/core/permission"
-import { PermissionID } from "./schema"
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { EventV2 } from "@opencode-ai/core/event"
 
 const log = Log.create({ service: "permission" })
 
-export const Action = PermissionV2.Action.annotate({ identifier: "PermissionAction" })
-export type Action = Schema.Schema.Type<typeof Action>
-
-export const Rule = Schema.Struct({
-  permission: Schema.String,
-  pattern: Schema.String,
-  action: Action,
-}).annotate({ identifier: "PermissionRule" })
-export type Rule = Schema.Schema.Type<typeof Rule>
-
-export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
-export type Ruleset = Schema.Schema.Type<typeof Ruleset>
-
-// Pure data; nothing checks class identity. As `Schema.Struct` + type alias,
-// `Permission.ask` can trust its already-typed input and skip the inner
-// `decodeUnknownSync` that would otherwise throw uncaught on any structural
-// mismatch. Same pattern as `Question.Request` in PR #28570.
-export const Request = Schema.Struct({
-  id: PermissionID,
-  sessionID: SessionID,
-  permission: Schema.String,
-  patterns: Schema.Array(Schema.String),
-  metadata: Schema.Record(Schema.String, Schema.Unknown),
-  always: Schema.Array(Schema.String),
-  tool: Schema.optional(
-    Schema.Struct({
-      messageID: MessageID,
-      callID: Schema.String,
-    }),
-  ),
-}).annotate({ identifier: "PermissionRequest" })
-export type Request = Schema.Schema.Type<typeof Request>
-
-export const Reply = Schema.Literals(["once", "always", "reject"])
-export type Reply = Schema.Schema.Type<typeof Reply>
-
-const reply = {
-  reply: Reply,
-  message: Schema.optional(Schema.String),
-}
-
-export const ReplyBody = Schema.Struct(reply).annotate({ identifier: "PermissionReplyBody" })
-export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
-
-export const Approval = Schema.Struct({
-  projectID: ProjectV2.ID,
-  patterns: Schema.Array(Schema.String),
-}).annotate({ identifier: "PermissionApproval" })
-export type Approval = Schema.Schema.Type<typeof Approval>
-
 export const Event = {
-  Asked: EventV2.define({ type: "permission.asked", schema: Request.fields }),
+  Asked: EventV2.define({ type: "permission.asked", schema: PermissionLegacy.Request.fields }),
   Replied: EventV2.define({
     type: "permission.replied",
     schema: {
-      sessionID: SessionID,
-      requestID: PermissionID,
-      reply: Reply,
+      sessionID: PermissionLegacy.Request.fields.sessionID,
+      requestID: PermissionLegacy.ID,
+      reply: PermissionLegacy.Reply,
     },
   }),
 }
 
-export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
-  override get message() {
-    return "The user rejected permission to use this specific tool call."
-  }
-}
-
-export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
-  feedback: Schema.String,
-}) {
-  override get message() {
-    return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
-  }
-}
-
-export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
-  ruleset: Schema.Any,
-}) {
-  override get message() {
-    return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
-  }
-}
-
-export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Permission.NotFoundError", {
-  requestID: PermissionID,
-}) {}
-
-export type Error = DeniedError | RejectedError | CorrectedError
-
-export const AskInput = Schema.Struct({
-  ...Request.fields,
-  id: Schema.optional(PermissionID),
-  ruleset: Ruleset,
-}).annotate({ identifier: "PermissionAskInput" })
-export type AskInput = Schema.Schema.Type<typeof AskInput>
-
-export const ReplyInput = Schema.Struct({
-  requestID: PermissionID,
-  ...reply,
-}).annotate({ identifier: "PermissionReplyInput" })
-export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
-
 export interface Interface {
-  readonly ask: (input: AskInput) => Effect.Effect<void, Error>
-  readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
-  readonly list: () => Effect.Effect<ReadonlyArray<Request>>
+  readonly ask: (input: PermissionLegacy.AskInput) => Effect.Effect<void, PermissionLegacy.Error>
+  readonly reply: (input: PermissionLegacy.ReplyInput) => Effect.Effect<void, PermissionLegacy.NotFoundError>
+  readonly list: () => Effect.Effect<ReadonlyArray<PermissionLegacy.Request>>
 }
 
 interface PendingEntry {
-  info: Request
-  deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
+  info: PermissionLegacy.Request
+  deferred: Deferred.Deferred<void, PermissionLegacy.RejectedError | PermissionLegacy.CorrectedError>
 }
 
 interface State {
-  pending: Map<PermissionID, PendingEntry>
-  approved: Rule[]
+  pending: Map<PermissionLegacy.ID, PendingEntry>
+  approved: PermissionLegacy.Rule[]
 }
 
-export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
-  return PermissionV2.evaluate(permission, pattern, ...rulesets)
+export function evaluate(permission: string, pattern: string, ...rulesets: PermissionLegacy.Ruleset[]): PermissionLegacy.Rule {
+  return (
+    rulesets
+      .flat()
+      .findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
+      action: "ask",
+      permission,
+      pattern: "*",
+    }
+  )
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
@@ -145,24 +56,18 @@ export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const events = yield* EventV2Bridge.Service
-    const { db } = yield* Database.Service
     const state = yield* InstanceState.make<State>(
       Effect.fn("Permission.state")(function* (ctx) {
-        const row = yield* db
-          .select()
-          .from(PermissionTable)
-          .where(eq(PermissionTable.project_id, ctx.project.id))
-          .get()
-          .pipe(Effect.orDie)
+        void ctx
         const state = {
-          pending: new Map<PermissionID, PendingEntry>(),
-          approved: [...(row?.data ?? [])],
+          pending: new Map<PermissionLegacy.ID, PendingEntry>(),
+          approved: [],
         }
 
         yield* Effect.addFinalizer(() =>
           Effect.gen(function* () {
             for (const item of state.pending.values()) {
-              yield* Deferred.fail(item.deferred, new RejectedError())
+              yield* Deferred.fail(item.deferred, new PermissionLegacy.RejectedError())
             }
             state.pending.clear()
           }),
@@ -172,7 +77,7 @@ export const layer = Layer.effect(
       }),
     )
 
-    const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
+    const ask = Effect.fn("Permission.ask")(function* (input: PermissionLegacy.AskInput) {
       const { approved, pending } = yield* InstanceState.get(state)
       const { ruleset, ...request } = input
       let needsAsk = false
@@ -181,7 +86,7 @@ export const layer = Layer.effect(
         const rule = evaluate(request.permission, pattern, ruleset, approved)
         log.info("evaluated", { permission: request.permission, pattern, action: rule })
         if (rule.action === "deny") {
-          return yield* new DeniedError({
+          return yield* new PermissionLegacy.DeniedError({
             ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
           })
         }
@@ -191,8 +96,8 @@ export const layer = Layer.effect(
 
       if (!needsAsk) return
 
-      const id = request.id ?? PermissionID.ascending()
-      const info: Request = {
+      const id = request.id ?? PermissionLegacy.ID.ascending()
+      const info: PermissionLegacy.Request = {
         id,
         sessionID: request.sessionID,
         permission: request.permission,
@@ -203,7 +108,7 @@ export const layer = Layer.effect(
       }
       log.info("asking", { id, permission: info.permission, patterns: info.patterns })
 
-      const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
+      const deferred = yield* Deferred.make<void, PermissionLegacy.RejectedError | PermissionLegacy.CorrectedError>()
       pending.set(id, { info, deferred })
       yield* events.publish(Event.Asked, info)
       return yield* Effect.ensuring(
@@ -214,10 +119,10 @@ export const layer = Layer.effect(
       )
     })
 
-    const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
+    const reply = Effect.fn("Permission.reply")(function* (input: PermissionLegacy.ReplyInput) {
       const { approved, pending } = yield* InstanceState.get(state)
       const existing = pending.get(input.requestID)
-      if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
+      if (!existing) return yield* new PermissionLegacy.NotFoundError({ requestID: input.requestID })
 
       pending.delete(input.requestID)
       yield* events.publish(Event.Replied, {
@@ -229,7 +134,7 @@ export const layer = Layer.effect(
       if (input.reply === "reject") {
         yield* Deferred.fail(
           existing.deferred,
-          input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
+          input.message ? new PermissionLegacy.CorrectedError({ feedback: input.message }) : new PermissionLegacy.RejectedError(),
         )
 
         for (const [id, item] of pending.entries()) {
@@ -240,7 +145,7 @@ export const layer = Layer.effect(
             requestID: item.info.id,
             reply: "reject",
           })
-          yield* Deferred.fail(item.deferred, new RejectedError())
+          yield* Deferred.fail(item.deferred, new PermissionLegacy.RejectedError())
         }
         return
       }
@@ -290,7 +195,7 @@ function expand(pattern: string): string {
 }
 
 export function fromConfig(permission: ConfigPermission.Info) {
-  const ruleset: Rule[] = []
+  const ruleset: PermissionLegacy.Rule[] = []
   for (const [key, value] of Object.entries(permission)) {
     if (typeof value === "string") {
       ruleset.push({ permission: key, action: value, pattern: "*" })
@@ -303,14 +208,21 @@ export function fromConfig(permission: ConfigPermission.Info) {
   return ruleset
 }
 
-export function merge(...rulesets: Ruleset[]): Rule[] {
-  return [...PermissionV2.merge(...rulesets)]
+export function merge(...rulesets: PermissionLegacy.Ruleset[]): PermissionLegacy.Rule[] {
+  return rulesets.flat()
 }
 
-export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
-  return PermissionV2.disabled(tools, ruleset)
+export function disabled(tools: string[], ruleset: PermissionLegacy.Ruleset): Set<string> {
+  const edits = ["edit", "write", "apply_patch"]
+  return new Set(
+    tools.filter((tool) => {
+      const permission = edits.includes(tool) ? "edit" : tool
+      const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
+      return rule?.pattern === "*" && rule.action === "deny"
+    }),
+  )
 }
 
-export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer))
+export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
 
 export * as Permission from "."

+ 0 - 13
packages/opencode/src/permission/schema.ts

@@ -1,13 +0,0 @@
-import { Schema } from "effect"
-
-import { Identifier } from "@/id/id"
-import { Newtype } from "@opencode-ai/core/schema"
-
-export class PermissionID extends Newtype<PermissionID>()(
-  "PermissionID",
-  Schema.String.check(Schema.isStartsWith("per")),
-) {
-  static ascending(id?: string): PermissionID {
-    return this.make(Identifier.ascending("permission", id))
-  }
-}

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

@@ -1,7 +1,7 @@
 import { and, eq, sql } from "drizzle-orm"
 import { Database } from "@opencode-ai/core/database/database"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
-import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
+import { SessionTable } from "@opencode-ai/core/session/sql"
 import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
 import * as Log from "@opencode-ai/core/util/log"
 import { Flag } from "@opencode-ai/core/flag/flag"
@@ -86,10 +86,6 @@ export function fromRow(row: Row): Info {
   }
 }
 
-function mergePermissionRules<T extends readonly unknown[]>(oldRules: T, newRules: T): T {
-  return [...new Map([...oldRules, ...newRules].map((rule) => [JSON.stringify(rule), rule])).values()] as unknown as T
-}
-
 export const UpdateInput = Schema.Struct({
   projectID: ProjectV2.ID,
   name: Schema.optional(Schema.String),
@@ -201,36 +197,6 @@ export const layer = Layer.effect(
                   .run()
               }
 
-              const oldPermission = yield* d
-                .select()
-                .from(PermissionTable)
-                .where(eq(PermissionTable.project_id, oldID))
-                .get()
-              const newPermission = yield* d
-                .select()
-                .from(PermissionTable)
-                .where(eq(PermissionTable.project_id, newID))
-                .get()
-              if (oldPermission && newPermission) {
-                yield* d
-                  .update(PermissionTable)
-                  .set({
-                    data: mergePermissionRules(oldPermission.data, newPermission.data),
-                    time_created: Math.min(oldPermission.time_created, newPermission.time_created),
-                    time_updated: Date.now(),
-                  })
-                  .where(eq(PermissionTable.project_id, newID))
-                  .run()
-                yield* d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
-              }
-              if (oldPermission && !newPermission) {
-                yield* d
-                  .update(PermissionTable)
-                  .set({ project_id: newID })
-                  .where(eq(PermissionTable.project_id, oldID))
-                  .run()
-              }
-
               yield* d
                 .update(SessionTable)
                 .set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })

+ 4 - 4
packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts

@@ -1,5 +1,5 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { Permission } from "@/permission"
-import { PermissionID } from "@/permission/schema"
 import { Schema } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { PermissionNotFoundError } from "../errors"
@@ -10,7 +10,7 @@ import { described } from "./metadata"
 
 const root = "/permission"
 const ReplyPayload = Schema.Struct({
-  reply: Permission.Reply,
+  reply: PermissionLegacy.Reply,
   message: Schema.optional(Schema.String),
 })
 
@@ -20,7 +20,7 @@ export const PermissionApi = HttpApi.make("permission")
       .add(
         HttpApiEndpoint.get("list", root, {
           query: WorkspaceRoutingQuery,
-          success: described(Schema.Array(Permission.Request), "List of pending permissions"),
+          success: described(Schema.Array(PermissionLegacy.Request), "List of pending permissions"),
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "permission.list",
@@ -29,7 +29,7 @@ export const PermissionApi = HttpApi.make("permission")
           }),
         ),
         HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
-          params: { requestID: PermissionID },
+          params: { requestID: PermissionLegacy.ID },
           query: WorkspaceRoutingQuery,
           payload: ReplyPayload,
           success: described(Schema.Boolean, "Permission processed successfully"),

+ 4 - 4
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts

@@ -1,6 +1,6 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { Permission } from "@/permission"
 import { SessionLegacy } from "@opencode-ai/core/session/legacy"
-import { PermissionID } from "@/permission/schema"
 
 import { Session } from "@/session/session"
 import { MessageV2 } from "@/session/message-v2"
@@ -48,7 +48,7 @@ export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
 export const UpdatePayload = Schema.Struct({
   title: Schema.optional(Schema.String),
   metadata: Schema.optional(Session.Metadata),
-  permission: Schema.optional(Permission.Ruleset),
+  permission: Schema.optional(PermissionLegacy.Ruleset),
   time: Schema.optional(
     Schema.Struct({
       archived: Schema.optional(Session.ArchivedTimestamp),
@@ -71,7 +71,7 @@ export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInp
 export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))
 export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"]))
 export const PermissionResponsePayload = Schema.Struct({
-  response: Permission.Reply,
+  response: PermissionLegacy.Reply,
 })
 
 export const SessionPaths = {
@@ -392,7 +392,7 @@ export const SessionApi = HttpApi.make("session")
           }),
         ),
         HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
-          params: { sessionID: SessionID, permissionID: PermissionID },
+          params: { sessionID: SessionID, permissionID: PermissionLegacy.ID },
           query: WorkspaceRoutingQuery,
           payload: PermissionResponsePayload,
           success: described(Schema.Boolean, "Permission processed successfully"),

+ 4 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts

@@ -3,12 +3,16 @@ import { MessageGroup } from "./v2/message"
 import { ModelGroup } from "./v2/model"
 import { ProviderGroup } from "./v2/provider"
 import { SessionGroup } from "./v2/session"
+import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission"
 
 export const V2Api = HttpApi.make("v2")
   .add(SessionGroup)
   .add(MessageGroup)
   .add(ModelGroup)
   .add(ProviderGroup)
+  .add(PermissionGroup)
+  .add(SessionPermissionGroup)
+  .add(PermissionSavedGroup)
   .annotateMerge(
     OpenApi.annotations({
       title: "opencode experimental HttpApi",

+ 3 - 2
packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts

@@ -1,6 +1,7 @@
 import { Catalog } from "@opencode-ai/core/catalog"
 import { Location } from "@opencode-ai/core/location"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
+import { PermissionV2 } from "@opencode-ai/core/permission"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { PluginBoot } from "@opencode-ai/core/plugin/boot"
 import { Effect, Layer, Schema } from "effect"
@@ -34,7 +35,7 @@ export const locationQueryOpenApi = OpenApi.annotations({
 export class V2LocationMiddleware extends HttpApiMiddleware.Service<
   V2LocationMiddleware,
   {
-    provides: Catalog.Service | PluginBoot.Service
+    provides: Catalog.Service | PluginBoot.Service | PermissionV2.Service
   }
 >()("@opencode/ExperimentalHttpApiV2Location") {}
 
@@ -59,4 +60,4 @@ export const layer = Layer.effect(
       }),
     )
   }),
-).pipe(Layer.provide(LocationServiceMap.layer))
+)

+ 90 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts

@@ -0,0 +1,90 @@
+import { PermissionV2 } from "@opencode-ai/core/permission"
+import { PermissionSaved } from "@opencode-ai/core/permission/saved"
+import { ProjectV2 } from "@opencode-ai/core/project"
+import { SessionV2 } from "@opencode-ai/core/session"
+import { Schema } from "effect"
+import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
+import { V2Authorization } from "../../middleware/authorization"
+import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
+
+export const PermissionGroup = HttpApiGroup.make("v2.permission")
+  .add(
+    HttpApiEndpoint.get("permissionRequests", "/api/permission/request", {
+      query: LocationQuery,
+      success: Schema.Array(PermissionV2.Request),
+    })
+      .annotateMerge(locationQueryOpenApi)
+      .annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.permission.request.list",
+          summary: "List pending permission requests",
+          description: "Retrieve pending permission requests for a location.",
+        }),
+      ),
+  )
+  .annotateMerge(OpenApi.annotations({ title: "v2 permissions", description: "Experimental v2 permission routes." }))
+  .middleware(V2LocationMiddleware)
+  .middleware(V2Authorization)
+
+export const SessionPermissionGroup = HttpApiGroup.make("v2.session.permission")
+  .add(
+    HttpApiEndpoint.get("sessionPermissionRequests", "/api/session/:sessionID/permission/request", {
+      params: { sessionID: SessionV2.ID },
+      success: Schema.Array(PermissionV2.Request),
+      error: SessionNotFoundError,
+    }).annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.session.permission.list",
+        summary: "List session permission requests",
+        description: "Retrieve pending permission requests owned by a session.",
+      }),
+    ),
+  )
+  .add(
+    HttpApiEndpoint.post("permissionRequestReply", "/api/session/:sessionID/permission/request/:requestID/reply", {
+      params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID },
+      payload: Schema.Struct({
+        reply: PermissionV2.Reply,
+        message: Schema.String.pipe(Schema.optional),
+      }),
+      success: HttpApiSchema.NoContent,
+      error: [SessionNotFoundError, PermissionNotFoundError],
+    }).annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.session.permission.reply",
+        summary: "Reply to pending permission request",
+        description: "Respond to a pending permission request owned by a session.",
+      }),
+    ),
+  )
+  .annotateMerge(OpenApi.annotations({ title: "v2 session permissions", description: "Experimental v2 session permission routes." }))
+  .middleware(V2Authorization)
+
+export const PermissionSavedGroup = HttpApiGroup.make("v2.permission.saved")
+  .add(
+    HttpApiEndpoint.get("savedPermissions", "/api/permission/saved", {
+      query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }),
+      success: Schema.Array(PermissionSaved.Info),
+    }).annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.permission.saved.list",
+        summary: "List saved permissions",
+        description: "Retrieve saved permissions, optionally filtered by project.",
+      }),
+    ),
+  )
+  .add(
+    HttpApiEndpoint.delete("removeSavedPermission", "/api/permission/saved/:id", {
+      params: { id: PermissionSaved.ID },
+      success: HttpApiSchema.NoContent,
+    }).annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.permission.saved.remove",
+        summary: "Remove saved permission",
+        description: "Remove a saved permission by ID.",
+      }),
+    ),
+  )
+  .annotateMerge(OpenApi.annotations({ title: "v2 saved permissions", description: "Experimental v2 saved permission routes." }))
+  .middleware(V2Authorization)

+ 3 - 3
packages/opencode/src/server/routes/instance/httpapi/handlers/permission.ts

@@ -1,5 +1,5 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { Permission } from "@/permission"
-import { PermissionID } from "@/permission/schema"
 import { Effect } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../api"
@@ -14,8 +14,8 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss
     })
 
     const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
-      params: { requestID: PermissionID }
-      payload: Permission.ReplyBody
+      params: { requestID: PermissionLegacy.ID }
+      payload: PermissionLegacy.ReplyBody
     }) {
       yield* svc
         .reply({

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

@@ -1,9 +1,9 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { Agent } from "@/agent/agent"
 import { SessionLegacy } from "@opencode-ai/core/session/legacy"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { Command } from "@/command"
 import { Permission } from "@/permission"
-import { PermissionID } from "@/permission/schema"
 import { SessionShare } from "@/share/session"
 import { Session } from "@/session/session"
 import { SessionCompaction } from "@/session/compaction"
@@ -360,7 +360,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
     })
 
     const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
-      params: { sessionID: SessionID; permissionID: PermissionID }
+      params: { sessionID: SessionID; permissionID: PermissionLegacy.ID }
       payload: typeof PermissionResponsePayload.Type
     }) {
       yield* requireSession(ctx.params.sessionID)

+ 14 - 1
packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts

@@ -1,12 +1,25 @@
 import { SessionV2 } from "@opencode-ai/core/session"
+import { LocationServiceMap } from "@opencode-ai/core/location-layer"
+import { PermissionSaved } from "@opencode-ai/core/permission/saved"
 import { Layer } from "effect"
 import { layer as v2LocationLayer } from "../groups/v2/location"
 import { messageHandlers } from "./v2/message"
 import { modelHandlers } from "./v2/model"
 import { providerHandlers } from "./v2/provider"
 import { sessionHandlers } from "./v2/session"
+import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./v2/permission"
 
-export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe(
+export const v2Handlers = Layer.mergeAll(
+  sessionHandlers,
+  messageHandlers,
+  modelHandlers,
+  providerHandlers,
+  permissionHandlers,
+  sessionPermissionHandlers,
+  savedPermissionHandlers,
+).pipe(
   Layer.provide(v2LocationLayer),
+  Layer.provide(LocationServiceMap.layer),
+  Layer.provide(PermissionSaved.layer),
   Layer.provide(SessionV2.defaultLayer),
 )

+ 105 - 0
packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts

@@ -0,0 +1,105 @@
+import { Database } from "@opencode-ai/core/database/database"
+import { LocationServiceMap } from "@opencode-ai/core/location-layer"
+import { PermissionV2 } from "@opencode-ai/core/permission"
+import { PermissionSaved } from "@opencode-ai/core/permission/saved"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { SessionTable } from "@opencode-ai/core/session/sql"
+import { eq } from "drizzle-orm"
+import { Effect } from "effect"
+import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
+import { InstanceHttpApi } from "../../api"
+import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
+
+function missingRequest(id: PermissionV2.ID) {
+  return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
+}
+
+export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission", (handlers) =>
+  Effect.gen(function* () {
+    return handlers.handle(
+      "permissionRequests",
+      Effect.fn(function* () {
+        return yield* (yield* PermissionV2.Service).list()
+      }),
+    )
+  }),
+)
+
+export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session.permission", (handlers) =>
+  Effect.gen(function* () {
+    const { db } = yield* Database.Service
+    const locations = yield* LocationServiceMap
+
+    const withSessionPermission = Effect.fnUntraced(function* <A, E>(
+      sessionID: Parameters<PermissionV2.Interface["forSession"]>[0],
+      use: (permission: PermissionV2.Interface) => Effect.Effect<A, E>,
+    ) {
+      const row = yield* db
+        .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
+        .from(SessionTable)
+        .where(eq(SessionTable.id, sessionID))
+        .get()
+        .pipe(Effect.orDie)
+      if (!row)
+        return yield* new SessionNotFoundError({
+          sessionID,
+          message: `Session not found: ${sessionID}`,
+        })
+
+      return yield* Effect.gen(function* () {
+        return yield* use(yield* PermissionV2.Service)
+      }).pipe(
+        Effect.scoped,
+        Effect.provide(
+          locations.get({ directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }),
+        ),
+      )
+    })
+
+    return handlers
+      .handle(
+        "sessionPermissionRequests",
+        Effect.fn(function* (ctx) {
+          return yield* withSessionPermission(ctx.params.sessionID, (permission) =>
+            permission.forSession(ctx.params.sessionID),
+          )
+        }),
+      )
+      .handle(
+        "permissionRequestReply",
+        Effect.fn(function* (ctx) {
+          yield* withSessionPermission(ctx.params.sessionID, (permission) =>
+            Effect.gen(function* () {
+              const request = yield* permission.get(ctx.params.requestID)
+              if (!request || request.sessionID !== ctx.params.sessionID)
+                return yield* missingRequest(ctx.params.requestID)
+              yield* permission
+                .reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message })
+                .pipe(Effect.catchTag("PermissionV2.NotFoundError", () => missingRequest(ctx.params.requestID)))
+            }),
+          )
+          return HttpApiSchema.NoContent.make()
+        }),
+      )
+  }),
+)
+
+export const savedPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission.saved", (handlers) =>
+  Effect.gen(function* () {
+    const saved = yield* PermissionSaved.Service
+    return handlers
+      .handle(
+        "savedPermissions",
+        Effect.fn(function* (ctx) {
+          return yield* saved.list({ projectID: ctx.query.projectID })
+        }),
+      )
+      .handle(
+        "removeSavedPermission",
+        Effect.fn(function* (ctx) {
+          yield* saved.remove(ctx.params.id)
+          return HttpApiSchema.NoContent.make()
+        }),
+      )
+  }),
+)

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

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { Provider } from "@/provider/provider"
 import { SessionLegacy } from "@opencode-ai/core/session/legacy"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -15,7 +16,6 @@ import type { Agent } from "@/agent/agent"
 import type { MessageV2 } from "./message-v2"
 import { Plugin } from "@/plugin"
 import { Permission } from "@/permission"
-import { PermissionID } from "@/permission/schema"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { EventV2 } from "@opencode-ai/core/event"
 import { Wildcard } from "@/util/wildcard"
@@ -38,7 +38,7 @@ export type StreamInput = {
   parentSessionID?: string
   model: Provider.Model
   agent: Agent.Info
-  permission?: Permission.Ruleset
+  permission?: PermissionLegacy.Ruleset
   system: string[]
   messages: ModelMessage[]
   small?: boolean
@@ -165,7 +165,7 @@ const live: Layer.Layer<
             return { approved: true }
           }
 
-          const id = PermissionID.ascending()
+          const id = PermissionLegacy.ID.ascending()
           let unsub: EventV2.Unsubscribe | undefined
           try {
             unsub = await bridge.promise(

+ 2 - 1
packages/opencode/src/session/llm/request.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import type { Auth } from "@/auth"
 import { SessionLegacy } from "@opencode-ai/core/session/legacy"
 import type { RuntimeFlags } from "@/effect/runtime-flags"
@@ -22,7 +23,7 @@ type PrepareInput = {
   readonly parentSessionID?: string
   readonly model: Provider.Model
   readonly agent: Agent.Info
-  readonly permission?: Permission.Ruleset
+  readonly permission?: PermissionLegacy.Ruleset
   readonly system: string[]
   readonly messages: ModelMessage[]
   readonly small?: boolean

+ 2 - 1
packages/opencode/src/session/processor.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { Image } from "@/image/image"
 import { SessionLegacy } from "@opencode-ai/core/session/legacy"
 import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
@@ -204,7 +205,7 @@ export const layer = Layer.effect(
             time: { start: match.part.state.time.start, end: Date.now() },
           },
         })
-        if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {
+        if (error instanceof PermissionLegacy.RejectedError || error instanceof Question.RejectedError) {
           ctx.blocked = ctx.shouldBreak
         }
         yield* settleToolCall(toolCallID)

+ 2 - 1
packages/opencode/src/session/prompt.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import path from "path"
 import { SessionLegacy } from "@opencode-ai/core/session/legacy"
 import os from "os"
@@ -1220,7 +1221,7 @@ export const layer = Layer.effect(
       const message = yield* createUserMessage(input)
       yield* sessions.touch(input.sessionID)
 
-      const permissions: Permission.Rule[] = []
+      const permissions: PermissionLegacy.Rule[] = []
       for (const [t, enabled] of Object.entries(input.tools ?? {})) {
         permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
       }

+ 10 - 9
packages/opencode/src/session/session.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { Slug } from "@opencode-ai/core/util/slug"
 import { SessionLegacy } from "@opencode-ai/core/session/legacy"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -233,7 +234,7 @@ export const Info = Schema.Struct({
   version: Schema.String,
   metadata: optionalOmitUndefined(Metadata),
   time: Time,
-  permission: optionalOmitUndefined(Permission.Ruleset),
+  permission: optionalOmitUndefined(PermissionLegacy.Ruleset),
   revert: optionalOmitUndefined(Revert),
 }).annotate({ identifier: "Session" })
 export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
@@ -258,7 +259,7 @@ export const CreateInput = Schema.optional(
     agent: Schema.optional(Schema.String),
     model: Schema.optional(Model),
     metadata: Schema.optional(Metadata),
-    permission: Schema.optional(Permission.Ruleset),
+    permission: Schema.optional(PermissionLegacy.Ruleset),
     workspaceID: Schema.optional(WorkspaceV2.ID),
   }),
 )
@@ -282,7 +283,7 @@ export const SetMetadataInput = Schema.Struct({
 })
 export const SetPermissionInput = Schema.Struct({
   sessionID: SessionID,
-  permission: Permission.Ruleset,
+  permission: PermissionLegacy.Ruleset,
 })
 export const SetRevertInput = Schema.Struct({
   sessionID: SessionID,
@@ -348,7 +349,7 @@ const UpdatedInfo = Schema.Struct({
   version: Schema.optional(Schema.NullOr(Schema.String)),
   metadata: Schema.optional(Schema.NullOr(Metadata)),
   time: Schema.optional(UpdatedTime),
-  permission: Schema.optional(Schema.NullOr(Permission.Ruleset)),
+  permission: Schema.optional(Schema.NullOr(PermissionLegacy.Ruleset)),
   revert: Schema.optional(Schema.NullOr(Revert)),
 })
 
@@ -472,7 +473,7 @@ export interface Interface {
     agent?: string
     model?: Schema.Schema.Type<typeof Model>
     metadata?: typeof Metadata.Type
-    permission?: Permission.Ruleset
+    permission?: PermissionLegacy.Ruleset
     workspaceID?: WorkspaceV2.ID
   }) => Effect.Effect<Info>
   readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
@@ -481,7 +482,7 @@ export interface Interface {
   readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
   readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
   readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect<void>
-  readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
+  readonly setPermission: (input: { sessionID: SessionID; permission: PermissionLegacy.Ruleset }) => Effect.Effect<void>
   readonly setRevert: (input: {
     sessionID: SessionID
     revert: Info["revert"]
@@ -570,7 +571,7 @@ export const layer: Layer.Layer<
       directory: string
       path?: string
       metadata?: typeof Metadata.Type
-      permission?: Permission.Ruleset
+      permission?: PermissionLegacy.Ruleset
     }) {
       const ctx = yield* InstanceState.context
       const result: Info = {
@@ -748,7 +749,7 @@ export const layer: Layer.Layer<
       agent?: string
       model?: Schema.Schema.Type<typeof Model>
       metadata?: typeof Metadata.Type
-      permission?: Permission.Ruleset
+      permission?: PermissionLegacy.Ruleset
       workspaceID?: WorkspaceV2.ID
     }) {
       const ctx = yield* InstanceState.context
@@ -842,7 +843,7 @@ export const layer: Layer.Layer<
 
     const setPermission = Effect.fn("Session.setPermission")(function* (input: {
       sessionID: SessionID
-      permission: Permission.Ruleset
+      permission: PermissionLegacy.Ruleset
     }) {
       yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } }).pipe(
         Effect.orDie,

+ 2 - 30
packages/opencode/src/storage/json-migration.ts

@@ -3,7 +3,7 @@ import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"
 import { Global } from "@opencode-ai/core/global"
 import * as Log from "@opencode-ai/core/util/log"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
-import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
+import { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
 import { SessionShareTable } from "@opencode-ai/core/share/sql"
 import path from "path"
 import { existsSync } from "fs"
@@ -108,13 +108,12 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
 
   // Pre-scan all files upfront to avoid repeated glob operations
   log.info("scanning files...")
-  const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, permFiles, shareFiles] = await Promise.all([
+  const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, shareFiles] = await Promise.all([
     list("project/*.json"),
     list("session/*/*.json"),
     list("message/*/*.json"),
     list("part/*/*.json"),
     list("todo/*.json"),
-    list("permission/*.json"),
     list("session_share/*.json"),
   ])
 
@@ -124,7 +123,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
     messages: messageFiles.length,
     parts: partFiles.length,
     todos: todoFiles.length,
-    permissions: permFiles.length,
     shares: shareFiles.length,
   })
 
@@ -135,7 +133,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
       messageFiles.length +
       partFiles.length +
       todoFiles.length +
-      permFiles.length +
       shareFiles.length,
   )
   const progress = options?.progress
@@ -357,31 +354,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
     log.warn("skipped orphaned todos", { count: orphans.todos })
   }
 
-  // Migrate permissions
-  const permProjects = permFiles.map((file) => path.basename(file, ".json"))
-  const permValues: unknown[] = []
-  for (let i = 0; i < permFiles.length; i += batchSize) {
-    const end = Math.min(i + batchSize, permFiles.length)
-    const batch = await read(permFiles, i, end)
-    permValues.length = 0
-    for (let j = 0; j < batch.length; j++) {
-      const data = batch[j]
-      if (!data) continue
-      const projectID = permProjects[i + j]
-      if (!projectIds.has(projectID)) {
-        orphans.permissions++
-        continue
-      }
-      permValues.push({ project_id: projectID, data })
-    }
-    stats.permissions += insert(permValues, PermissionTable, "permission")
-    step("permissions", end - i)
-  }
-  log.info("migrated permissions", { count: stats.permissions })
-  if (orphans.permissions > 0) {
-    log.warn("skipped orphaned permissions", { count: orphans.permissions })
-  }
-
   // Migrate session shares
   const shareSessions = shareFiles.map((file) => path.basename(file, ".json"))
   const shareValues: unknown[] = []

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

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

+ 2 - 1
packages/opencode/src/tool/tool.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { Effect, Schema } from "effect"
 import { SessionLegacy } from "@opencode-ai/core/session/legacy"
 import type { JSONSchema7 } from "@ai-sdk/provider"
@@ -41,7 +42,7 @@ export type Context<M extends Metadata = Metadata> = {
   extra?: { [key: string]: unknown }
   messages: SessionLegacy.WithParts[]
   metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
-  ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
+  ask(input: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
 }
 
 export interface ExecuteResult<M extends Metadata = Metadata> {

+ 2 - 1
packages/opencode/test/agent/agent.test.ts

@@ -9,6 +9,7 @@ import { Config } from "../../src/config/config"
 import { RuntimeFlags } from "../../src/effect/runtime-flags"
 import { Global } from "@opencode-ai/core/global"
 import { Permission } from "../../src/permission"
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { Plugin } from "../../src/plugin"
 import { Provider } from "../../src/provider/provider"
 import { Skill } from "../../src/skill"
@@ -28,7 +29,7 @@ const it = testEffect(agentLayer())
 const scout = testEffect(agentLayer({ experimentalScout: true }))
 
 // Helper to evaluate permission for a tool with wildcard pattern
-function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
+function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionLegacy.Action | undefined {
   if (!agent) return undefined
   return Permission.evaluate(permission, "*", agent.permission).action
 }

+ 4 - 3
packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 /**
  * Reproducer for opencode issue #26514:
  *
@@ -60,7 +61,7 @@ it.instance("[#26514] subagent spawned from plan mode inherits read-only restric
     // session's `permission` field is empty (Plan Mode lives on the agent
     // ruleset, not the session). So we pass [] through as the parent
     // session permission, exactly like the actual code path.
-    const parentSessionPermission: Permission.Ruleset = []
+    const parentSessionPermission: PermissionLegacy.Ruleset = []
 
     const subagentSessionPermission = deriveSubagentSessionPermission({
       parentSessionPermission,
@@ -88,7 +89,7 @@ it.instance("[#26514] explore subagent launched from plan mode also stays read-o
     expect(planAgent).toBeDefined()
     expect(explore).toBeDefined()
 
-    const parentSessionPermission: Permission.Ruleset = []
+    const parentSessionPermission: PermissionLegacy.Ruleset = []
     const subagentSessionPermission = deriveSubagentSessionPermission({
       parentSessionPermission,
       parentAgent: planAgent,
@@ -113,7 +114,7 @@ it.instance(
       expect(planAgent).toBeDefined()
       expect(my).toBeDefined()
 
-      const parentSessionPermission: Permission.Ruleset = []
+      const parentSessionPermission: PermissionLegacy.Ruleset = []
       const subagentSessionPermission = deriveSubagentSessionPermission({
         parentSessionPermission,
         parentAgent: planAgent,

+ 3 - 2
packages/opencode/test/permission-task.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { describe, test, expect } from "bun:test"
 import { Effect } from "effect"
 import { Permission } from "../src/permission"
@@ -9,7 +10,7 @@ const it = testEffect(Config.defaultLayer)
 const load = Config.use.get()
 
 describe("Permission.evaluate for permission.task", () => {
-  const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
+  const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionLegacy.Ruleset =>
     Object.entries(rules).map(([pattern, action]) => ({
       permission: "task",
       pattern,
@@ -75,7 +76,7 @@ describe("Permission.disabled for task tool", () => {
   // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
   // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
   // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
-  const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
+  const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionLegacy.Ruleset =>
     Object.entries(rules).map(([pattern, action]) => ({
       permission: "task",
       pattern,

+ 50 - 50
packages/opencode/test/permission/next.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { test, expect } from "bun:test"
 import os from "os"
 import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
@@ -5,7 +6,6 @@ import { EventV2Bridge } from "../../src/event-v2-bridge"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Database } from "@opencode-ai/core/database/database"
 import { Permission } from "../../src/permission"
-import { PermissionID } from "../../src/permission/schema"
 import { InstanceBootstrap } from "../../src/project/bootstrap-service"
 import { InstanceStore } from "../../src/project/instance-store"
 import { TestInstance, tmpdirScoped } from "../fixture/fixture"
@@ -261,8 +261,8 @@ test("merge - preserves rule order", () => {
 })
 
 test("merge - config permission overrides default ask", () => {
-  const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
-  const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+  const defaults: PermissionLegacy.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
+  const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
   const merged = Permission.merge(defaults, config)
 
   expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
@@ -270,8 +270,8 @@ test("merge - config permission overrides default ask", () => {
 })
 
 test("merge - config ask overrides default allow", () => {
-  const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
-  const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
+  const defaults: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+  const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
   const merged = Permission.merge(defaults, config)
 
   expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
@@ -443,8 +443,8 @@ test("evaluate - later wildcard permission can override earlier specific permiss
 })
 
 test("evaluate - merges multiple rulesets", () => {
-  const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
-  const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
+  const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
+  const approved: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
   const result = Permission.evaluate("bash", "rm", config, approved)
   expect(result.action).toBe("deny")
 })
@@ -588,7 +588,7 @@ it.instance(
           ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
         }),
       )
-      expect(err).toBeInstanceOf(Permission.DeniedError)
+      expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
     }),
   { git: true },
 )
@@ -655,10 +655,10 @@ it.instance(
   () =>
     Effect.gen(function* () {
       const events = yield* EventV2Bridge.Service
-      const seen = yield* Deferred.make<Permission.Request>()
+      const seen = yield* Deferred.make<PermissionLegacy.Request>()
       const unsub = yield* events.listen((event) => {
         if (event.type === Permission.Event.Asked.type)
-          Deferred.doneUnsafe(seen, Effect.succeed(event.data as Permission.Request))
+          Deferred.doneUnsafe(seen, Effect.succeed(event.data as PermissionLegacy.Request))
         return Effect.void
       })
       yield* Effect.addFinalizer(() => unsub)
@@ -703,7 +703,7 @@ it.instance(
   () =>
     Effect.gen(function* () {
       const fiber = yield* ask({
-        id: PermissionID.make("per_test1"),
+        id: PermissionLegacy.ID.make("per_test1"),
         sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
@@ -713,7 +713,7 @@ it.instance(
       }).pipe(Effect.forkScoped)
 
       yield* waitForPending(1)
-      yield* reply({ requestID: PermissionID.make("per_test1"), reply: "once" })
+      yield* reply({ requestID: PermissionLegacy.ID.make("per_test1"), reply: "once" })
       yield* Fiber.join(fiber)
     }),
   { git: true },
@@ -724,7 +724,7 @@ it.instance(
   () =>
     Effect.gen(function* () {
       const fiber = yield* ask({
-        id: PermissionID.make("per_test2"),
+        id: PermissionLegacy.ID.make("per_test2"),
         sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
@@ -734,11 +734,11 @@ it.instance(
       }).pipe(Effect.forkScoped)
 
       yield* waitForPending(1)
-      yield* reply({ requestID: PermissionID.make("per_test2"), reply: "reject" })
+      yield* reply({ requestID: PermissionLegacy.ID.make("per_test2"), reply: "reject" })
 
       const exit = yield* Fiber.await(fiber)
       expect(Exit.isFailure(exit)).toBe(true)
-      if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
+      if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
     }),
   { git: true },
 )
@@ -748,7 +748,7 @@ it.instance(
   () =>
     Effect.gen(function* () {
       const fiber = yield* ask({
-        id: PermissionID.make("per_test2b"),
+        id: PermissionLegacy.ID.make("per_test2b"),
         sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
@@ -759,7 +759,7 @@ it.instance(
 
       yield* waitForPending(1)
       yield* reply({
-        requestID: PermissionID.make("per_test2b"),
+        requestID: PermissionLegacy.ID.make("per_test2b"),
         reply: "reject",
         message: "Use a safer command",
       })
@@ -768,7 +768,7 @@ it.instance(
       expect(Exit.isFailure(exit)).toBe(true)
       if (Exit.isFailure(exit)) {
         const err = Cause.squash(exit.cause)
-        expect(err).toBeInstanceOf(Permission.CorrectedError)
+        expect(err).toBeInstanceOf(PermissionLegacy.CorrectedError)
         expect(String(err)).toContain("Use a safer command")
       }
     }),
@@ -780,7 +780,7 @@ it.instance(
   () =>
     Effect.gen(function* () {
       const fiber = yield* ask({
-        id: PermissionID.make("per_test3"),
+        id: PermissionLegacy.ID.make("per_test3"),
         sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
@@ -790,7 +790,7 @@ it.instance(
       }).pipe(Effect.forkScoped)
 
       yield* waitForPending(1)
-      yield* reply({ requestID: PermissionID.make("per_test3"), reply: "always" })
+      yield* reply({ requestID: PermissionLegacy.ID.make("per_test3"), reply: "always" })
       yield* Fiber.join(fiber)
 
       const result = yield* ask({
@@ -811,7 +811,7 @@ it.instance(
   () =>
     Effect.gen(function* () {
       const a = yield* ask({
-        id: PermissionID.make("per_test4a"),
+        id: PermissionLegacy.ID.make("per_test4a"),
         sessionID: SessionID.make("session_same"),
         permission: "bash",
         patterns: ["ls"],
@@ -821,7 +821,7 @@ it.instance(
       }).pipe(Effect.forkScoped)
 
       const b = yield* ask({
-        id: PermissionID.make("per_test4b"),
+        id: PermissionLegacy.ID.make("per_test4b"),
         sessionID: SessionID.make("session_same"),
         permission: "edit",
         patterns: ["foo.ts"],
@@ -831,13 +831,13 @@ it.instance(
       }).pipe(Effect.forkScoped)
 
       yield* waitForPending(2)
-      yield* reply({ requestID: PermissionID.make("per_test4a"), reply: "reject" })
+      yield* reply({ requestID: PermissionLegacy.ID.make("per_test4a"), reply: "reject" })
 
       const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
       expect(Exit.isFailure(ea)).toBe(true)
       expect(Exit.isFailure(eb)).toBe(true)
-      if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(Permission.RejectedError)
-      if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(Permission.RejectedError)
+      if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
+      if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
     }),
   { git: true },
 )
@@ -847,7 +847,7 @@ it.instance(
   () =>
     Effect.gen(function* () {
       const a = yield* ask({
-        id: PermissionID.make("per_test5a"),
+        id: PermissionLegacy.ID.make("per_test5a"),
         sessionID: SessionID.make("session_same"),
         permission: "bash",
         patterns: ["ls"],
@@ -857,7 +857,7 @@ it.instance(
       }).pipe(Effect.forkScoped)
 
       const b = yield* ask({
-        id: PermissionID.make("per_test5b"),
+        id: PermissionLegacy.ID.make("per_test5b"),
         sessionID: SessionID.make("session_same"),
         permission: "bash",
         patterns: ["ls"],
@@ -867,7 +867,7 @@ it.instance(
       }).pipe(Effect.forkScoped)
 
       yield* waitForPending(2)
-      yield* reply({ requestID: PermissionID.make("per_test5a"), reply: "always" })
+      yield* reply({ requestID: PermissionLegacy.ID.make("per_test5a"), reply: "always" })
 
       yield* Fiber.join(a)
       yield* Fiber.join(b)
@@ -881,7 +881,7 @@ it.instance(
   () =>
     Effect.gen(function* () {
       const a = yield* ask({
-        id: PermissionID.make("per_test6a"),
+        id: PermissionLegacy.ID.make("per_test6a"),
         sessionID: SessionID.make("session_a"),
         permission: "bash",
         patterns: ["ls"],
@@ -891,7 +891,7 @@ it.instance(
       }).pipe(Effect.forkScoped)
 
       const b = yield* ask({
-        id: PermissionID.make("per_test6b"),
+        id: PermissionLegacy.ID.make("per_test6b"),
         sessionID: SessionID.make("session_b"),
         permission: "bash",
         patterns: ["ls"],
@@ -901,10 +901,10 @@ it.instance(
       }).pipe(Effect.forkScoped)
 
       yield* waitForPending(2)
-      yield* reply({ requestID: PermissionID.make("per_test6a"), reply: "always" })
+      yield* reply({ requestID: PermissionLegacy.ID.make("per_test6a"), reply: "always" })
 
       yield* Fiber.join(a)
-      expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test6b")])
+      expect((yield* list()).map((item) => item.id)).toEqual([PermissionLegacy.ID.make("per_test6b")])
 
       yield* rejectAll()
       yield* Fiber.await(b)
@@ -917,10 +917,10 @@ it.instance(
   () =>
     Effect.gen(function* () {
       const events = yield* EventV2Bridge.Service
-      const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }>()
+      const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionLegacy.ID; reply: PermissionLegacy.Reply }>()
 
       const fiber = yield* ask({
-        id: PermissionID.make("per_test7"),
+        id: PermissionLegacy.ID.make("per_test7"),
         sessionID: SessionID.make("session_test"),
         permission: "bash",
         patterns: ["ls"],
@@ -935,13 +935,13 @@ it.instance(
         if (event.type === Permission.Event.Replied.type)
           Deferred.doneUnsafe(
             seen,
-            Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }),
+            Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionLegacy.ID; reply: PermissionLegacy.Reply }),
           )
         return Effect.void
       })
       yield* Effect.addFinalizer(() => unsub)
 
-      yield* reply({ requestID: PermissionID.make("per_test7"), reply: "once" })
+      yield* reply({ requestID: PermissionLegacy.ID.make("per_test7"), reply: "once" })
       yield* Fiber.join(fiber)
       expect(
         yield* Deferred.await(seen).pipe(
@@ -952,7 +952,7 @@ it.instance(
         ),
       ).toEqual({
         sessionID: SessionID.make("session_test"),
-        requestID: PermissionID.make("per_test7"),
+        requestID: PermissionLegacy.ID.make("per_test7"),
         reply: "once",
       })
     }),
@@ -969,7 +969,7 @@ it.live("permission requests stay isolated by directory", () =>
       .provide(
         { directory: one },
         ask({
-          id: PermissionID.make("per_dir_a"),
+          id: PermissionLegacy.ID.make("per_dir_a"),
           sessionID: SessionID.make("session_dir_a"),
           permission: "bash",
           patterns: ["ls"],
@@ -984,7 +984,7 @@ it.live("permission requests stay isolated by directory", () =>
       .provide(
         { directory: two },
         ask({
-          id: PermissionID.make("per_dir_b"),
+          id: PermissionLegacy.ID.make("per_dir_b"),
           sessionID: SessionID.make("session_dir_b"),
           permission: "bash",
           patterns: ["pwd"],
@@ -1000,8 +1000,8 @@ it.live("permission requests stay isolated by directory", () =>
 
     expect(onePending).toHaveLength(1)
     expect(twoPending).toHaveLength(1)
-    expect(onePending[0].id).toBe(PermissionID.make("per_dir_a"))
-    expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b"))
+    expect(onePending[0].id).toBe(PermissionLegacy.ID.make("per_dir_a"))
+    expect(twoPending[0].id).toBe(PermissionLegacy.ID.make("per_dir_b"))
 
     yield* store.provide({ directory: one }, reply({ requestID: onePending[0].id, reply: "reject" }))
     yield* store.provide({ directory: two }, reply({ requestID: twoPending[0].id, reply: "reject" }))
@@ -1018,7 +1018,7 @@ it.instance(
       const test = yield* TestInstance
       const store = yield* InstanceStore.Service
       const fiber = yield* ask({
-        id: PermissionID.make("per_dispose"),
+        id: PermissionLegacy.ID.make("per_dispose"),
         sessionID: SessionID.make("session_dispose"),
         permission: "bash",
         patterns: ["ls"],
@@ -1033,7 +1033,7 @@ it.instance(
 
       const exit = yield* Fiber.await(fiber)
       expect(Exit.isFailure(exit)).toBe(true)
-      if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
+      if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
     }),
   { git: true },
 )
@@ -1045,7 +1045,7 @@ it.instance(
       const test = yield* TestInstance
       const store = yield* InstanceStore.Service
       const fiber = yield* ask({
-        id: PermissionID.make("per_reload"),
+        id: PermissionLegacy.ID.make("per_reload"),
         sessionID: SessionID.make("session_reload"),
         permission: "bash",
         patterns: ["ls"],
@@ -1059,7 +1059,7 @@ it.instance(
 
       const exit = yield* Fiber.await(fiber)
       expect(Exit.isFailure(exit)).toBe(true)
-      if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
+      if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
     }),
   { git: true },
 )
@@ -1068,7 +1068,7 @@ it.instance(
   "reply - fails for unknown requestID",
   () =>
     Effect.gen(function* () {
-      const exit = yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" }).pipe(Effect.exit)
+      const exit = yield* reply({ requestID: PermissionLegacy.ID.make("per_unknown"), reply: "once" }).pipe(Effect.exit)
       expect(Exit.isFailure(exit)).toBe(true)
       if (Exit.isFailure(exit)) {
         expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Permission.NotFoundError", requestID: "per_unknown" })
@@ -1095,7 +1095,7 @@ it.instance(
           ],
         }),
       )
-      expect(err).toBeInstanceOf(Permission.DeniedError)
+      expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
     }),
   { git: true },
 )
@@ -1135,7 +1135,7 @@ it.instance(
         }),
       )
 
-      expect(err).toBeInstanceOf(Permission.DeniedError)
+      expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
       expect(yield* list()).toHaveLength(0)
     }),
   { git: true },
@@ -1149,7 +1149,7 @@ it.instance(
       const store = yield* InstanceStore.Service
 
       const fiber = yield* ask({
-        id: PermissionID.make("per_reload"),
+        id: PermissionLegacy.ID.make("per_reload"),
         sessionID: SessionID.make("session_reload"),
         permission: "bash",
         patterns: ["ls"],
@@ -1164,7 +1164,7 @@ it.instance(
 
       const exit = yield* Fiber.await(fiber)
       expect(Exit.isFailure(exit)).toBe(true)
-      if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError)
+      if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
     }),
   { git: true },
 )

+ 1 - 19
packages/opencode/test/project/project.test.ts

@@ -8,7 +8,7 @@ import { tmpdirScoped } from "../fixture/fixture"
 import { GlobalBus } from "../../src/bus/global"
 import { Database } from "@opencode-ai/core/database/database"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
-import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
+import { SessionTable } from "@opencode-ai/core/session/sql"
 import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
 import { eq } from "drizzle-orm"
 import { Hash } from "@opencode-ai/core/util/hash"
@@ -218,16 +218,6 @@ describe("Project.fromDirectory", () => {
         })
         .run()
         .pipe(Effect.orDie)
-      yield* db
-        .insert(PermissionTable)
-        .values({
-          project_id: rootProject.id,
-          data: [{ permission: "edit", pattern: "*", action: "allow" }],
-          time_created: Date.now(),
-          time_updated: Date.now(),
-        })
-        .run()
-        .pipe(Effect.orDie)
       yield* db
         .insert(WorkspaceTable)
         .values({ id: workspaceID, type: "local", name: "test", project_id: rootProject.id })
@@ -245,14 +235,6 @@ describe("Project.fromDirectory", () => {
         (yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie))
           ?.project_id,
       ).toBe(remoteID)
-      expect(
-        yield* db
-          .select()
-          .from(PermissionTable)
-          .where(eq(PermissionTable.project_id, remoteID))
-          .get()
-          .pipe(Effect.orDie),
-      ).toBeDefined()
       expect(
         (yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie))
           ?.project_id,

+ 26 - 0
packages/opencode/test/server/httpapi-exercise/index.ts

@@ -579,6 +579,32 @@ const scenarios: Scenario[] = [
     .get("/api/provider/{providerID}", "v2.provider.get")
     .at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
     .json(404, object, "status"),
+  http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, array),
+  http.protected
+    .get("/api/session/{sessionID}/permission/request", "v2.session.permission.list")
+    .seeded((ctx) => ctx.session({ title: "Permission list owner" }))
+    .at((ctx) => ({
+      path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }),
+      headers: ctx.headers(),
+    }))
+    .json(200, array),
+  http.protected
+    .post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply")
+    .seeded((ctx) => ctx.session({ title: "Permission owner" }))
+    .at((ctx) => ({
+      path: route("/api/session/{sessionID}/permission/request/{requestID}/reply", {
+        sessionID: ctx.state.id,
+        requestID: "per_httpapi_missing",
+      }),
+      headers: ctx.headers(),
+      body: { reply: "once" },
+    }))
+    .json(404, object, "status"),
+  http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array),
+  http.protected
+    .delete("/api/permission/saved/{id}", "v2.permission.saved.remove")
+    .at((ctx) => ({ path: route("/api/permission/saved/{id}", { id: "psv_httpapi_missing" }), headers: ctx.headers() }))
+    .status(204, undefined, "status"),
   http.protected
     .get("/api/session", "v2.session.list")
     .at((ctx) => ({ path: "/api/session?roots=true", headers: ctx.headers() }))

+ 2 - 2
packages/opencode/test/server/httpapi-instance.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { NodeHttpServer, NodeServices } from "@effect/platform-node"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { describe, expect } from "bun:test"
@@ -8,7 +9,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
 import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
 import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
 import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
-import { PermissionID } from "../../src/permission/schema"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { QuestionID } from "../../src/question/schema"
 import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
@@ -167,7 +167,7 @@ describe("instance HttpApi", () => {
             handlerContext,
           ),
         )
-      const permissionID = PermissionID.ascending()
+      const permissionID = PermissionLegacy.ID.ascending()
       const questionReplyID = QuestionID.ascending()
       const questionRejectID = QuestionID.ascending()
       const [permission, questionReply, questionReject] = yield* Effect.all(

+ 2 - 2
packages/opencode/test/server/httpapi-session.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { afterEach, describe, expect } from "bun:test"
 import { NodeHttpServer, NodeServices } from "@effect/platform-node"
 import { SessionLegacy } from "@opencode-ai/core/session/legacy"
@@ -11,7 +12,6 @@ import { Flag } from "@opencode-ai/core/flag/flag"
 import { registerAdapter } from "../../src/control-plane/adapters"
 import type { WorkspaceAdapter } from "../../src/control-plane/types"
 import { Workspace } from "../../src/control-plane/workspace"
-import { PermissionID } from "../../src/permission/schema"
 
 import { InstanceBootstrap } from "../../src/project/bootstrap"
 import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service"
@@ -913,7 +913,7 @@ describe("session HttpApi", () => {
           }),
         ).toMatchObject({ id: session.id })
 
-        const permissionID = String(PermissionID.ascending())
+        const permissionID = String(PermissionLegacy.ID.ascending())
         const permission = yield* request(
           pathFor(SessionPaths.permissions, {
             sessionID: session.id,

+ 2 - 1
packages/opencode/test/session/llm.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
 import { SessionLegacy } from "@opencode-ai/core/session/legacy"
 import path from "path"
@@ -332,7 +333,7 @@ describe("session.llm.ai-sdk adapter", () => {
   })
 
   test("preserves tool-error cause", async () => {
-    const error = new Permission.RejectedError()
+    const error = new PermissionLegacy.RejectedError()
     const events = await Effect.runPromise(
       LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), {
         type: "tool-error",

+ 6 - 13
packages/opencode/test/storage/json-migration.test.ts

@@ -10,7 +10,7 @@ import { Global } from "@opencode-ai/core/global"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { AbsolutePath } from "@opencode-ai/core/schema"
-import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
+import { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
 import { SessionShareTable } from "@opencode-ai/core/share/sql"
 import { SessionID, MessageID, PartID } from "../../src/session/schema"
 
@@ -574,7 +574,7 @@ describe("JSON to SQLite migration", () => {
     expect(todos[2].position).toBe(2)
   })
 
-  test("migrates permissions", async () => {
+  test("does not migrate legacy permissions", async () => {
     await writeProject(storageDir, {
       id: "proj_test123abc",
       worktree: "/",
@@ -592,12 +592,7 @@ describe("JSON to SQLite migration", () => {
 
     const stats = await JsonMigration.run(db)
 
-    expect(stats?.permissions).toBe(1)
-
-    const permissions = db.select().from(PermissionTable).all()
-    expect(permissions.length).toBe(1)
-    expect(permissions[0].project_id).toBe("proj_test123abc")
-    expect(permissions[0].data).toEqual(permissionData)
+    expect(stats?.permissions).toBe(0)
   })
 
   test("migrates session shares", async () => {
@@ -694,7 +689,7 @@ describe("JSON to SQLite migration", () => {
     expect(todos[1].position).toBe(2)
   })
 
-  test("skips orphaned todos, permissions, and shares", async () => {
+  test("skips orphaned todos and shares", async () => {
     await writeProject(storageDir, {
       id: "proj_test123abc",
       worktree: "/",
@@ -733,11 +728,10 @@ describe("JSON to SQLite migration", () => {
     const stats = await JsonMigration.run(db)
 
     expect(stats.todos).toBe(1)
-    expect(stats.permissions).toBe(1)
+    expect(stats.permissions).toBe(0)
     expect(stats.shares).toBe(1)
 
     expect(db.select().from(TodoTable).all().length).toBe(1)
-    expect(db.select().from(PermissionTable).all().length).toBe(1)
     expect(db.select().from(SessionShareTable).all().length).toBe(1)
   })
 
@@ -848,7 +842,7 @@ describe("JSON to SQLite migration", () => {
     expect(stats.messages).toBe(1)
     expect(stats.parts).toBe(1)
     expect(stats.todos).toBe(1)
-    expect(stats.permissions).toBe(1)
+    expect(stats.permissions).toBe(0)
     expect(stats.shares).toBe(1)
     expect(stats.errors.length).toBeGreaterThanOrEqual(6)
 
@@ -857,7 +851,6 @@ describe("JSON to SQLite migration", () => {
     expect(db.select().from(MessageTable).all().length).toBe(1)
     expect(db.select().from(PartTable).all().length).toBe(1)
     expect(db.select().from(TodoTable).all().length).toBe(1)
-    expect(db.select().from(PermissionTable).all().length).toBe(1)
     expect(db.select().from(SessionShareTable).all().length).toBe(1)
   })
 })

+ 2 - 1
packages/opencode/test/tool/external-directory.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { describe, expect } from "bun:test"
 import path from "path"
 import { Effect } from "effect"
@@ -26,7 +27,7 @@ const glob = (p: string) =>
   process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
 
 function makeCtx() {
-  const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+  const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
   const ctx: Tool.Context = {
     ...baseCtx,
     ask: (req) =>

+ 3 - 2
packages/opencode/test/tool/glob.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { describe, expect } from "bun:test"
 import path from "path"
 import { Cause, Effect, Exit, Layer } from "effect"
@@ -52,12 +53,12 @@ const ctx = {
 }
 
 const asks = () => {
-  const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+  const items: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
   return {
     items,
     next: {
       ...ctx,
-      ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
+      ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
         Effect.sync(() => {
           items.push(req)
         }),

+ 3 - 2
packages/opencode/test/tool/grep.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { describe, expect } from "bun:test"
 import fs from "fs/promises"
 import os from "os"
@@ -186,7 +187,7 @@ describe("tool.grep", () => {
           [path.join(alias, "*")]: "allow",
         },
       })
-      const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+      const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
       const next: Tool.Context = {
         ...ctx,
         ask: (req) =>
@@ -234,7 +235,7 @@ describe("tool.grep", () => {
         yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
         yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
 
-        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
         const next: Tool.Context = {
           ...ctx,
           ask: (req) =>

+ 3 - 2
packages/opencode/test/tool/lsp.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { afterEach, describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import path from "path"
@@ -83,12 +84,12 @@ const put = Effect.fn("LspToolTest.put")(function* (file: string) {
 })
 
 const asks = () => {
-  const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+  const items: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
   return {
     items,
     next: {
       ...ctx,
-      ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
+      ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
         Effect.sync(() => {
           items.push(req)
         }),

+ 5 - 4
packages/opencode/test/tool/read.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { afterEach, describe, expect } from "bun:test"
 import { Cause, Effect, Exit, Layer, Stream } from "effect"
 import path from "path"
@@ -140,12 +141,12 @@ const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
   return yield* fs.readFileString(p)
 })
 const asks = () => {
-  const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+  const items: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
   return {
     items,
     next: {
       ...ctx,
-      ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
+      ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
         Effect.sync(() => {
           items.push(req)
         }),
@@ -328,7 +329,7 @@ describe("tool.read env file permissions", () => {
                 let asked = false
                 const next = {
                   ...ctx,
-                  ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
+                  ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
                     Effect.sync(() => {
                       for (const pattern of req.patterns) {
                         const rule = Permission.evaluate(req.permission, pattern, info.permission)
@@ -336,7 +337,7 @@ describe("tool.read env file permissions", () => {
                           asked = true
                         }
                         if (rule.action === "deny") {
-                          throw new Permission.DeniedError({ ruleset: info.permission })
+                          throw new PermissionLegacy.DeniedError({ ruleset: info.permission })
                         }
                       }
                     }),

+ 33 - 32
packages/opencode/test/tool/shell.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { describe, expect } from "bun:test"
 import { Cause, Effect, Exit, Layer } from "effect"
 import type * as Scope from "effect/Scope"
@@ -155,9 +156,9 @@ const each = (
   }
 }
 
-const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
+const capture = (requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
   ...ctx,
-  ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
+  ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
     Effect.sync(() => {
       requests.push(req)
       if (stop) throw stop
@@ -222,7 +223,7 @@ describe("tool.shell permissions", () => {
       yield* runIn(
         tmp,
         Effect.gen(function* () {
-          const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+          const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
           yield* run(
             {
               command: "echo hello",
@@ -244,7 +245,7 @@ describe("tool.shell permissions", () => {
       yield* runIn(
         tmp,
         Effect.gen(function* () {
-          const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+          const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
           yield* run(
             {
               command: "echo foo && echo bar",
@@ -268,7 +269,7 @@ describe("tool.shell permissions", () => {
         runIn(
           projectRoot,
           Effect.gen(function* () {
-            const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+            const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
             yield* run(
               {
                 command: "Write-Host foo; if ($?) { Write-Host bar }",
@@ -297,7 +298,7 @@ describe("tool.shell permissions", () => {
             tmp,
             Effect.gen(function* () {
               const err = new Error("stop after permission")
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               expect(
                 yield* fail(
                   {
@@ -323,7 +324,7 @@ describe("tool.shell permissions", () => {
       projectRoot,
       Effect.gen(function* () {
         const err = new Error("stop after permission")
-        const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+        const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
         const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
         const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*"
         expect(
@@ -354,7 +355,7 @@ describe("tool.shell permissions", () => {
               projectRoot,
               Effect.gen(function* () {
                 const file = path.join(outerTmp, "outside.txt").replaceAll("\\", "/")
-                const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+                const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
                 yield* run(
                   {
                     command: `echo $(cat "${file}")`,
@@ -383,7 +384,7 @@ describe("tool.shell permissions", () => {
             projectRoot,
             Effect.gen(function* () {
               const err = new Error("stop after permission")
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               expect(
                 yield* fail(
                   {
@@ -409,7 +410,7 @@ describe("tool.shell permissions", () => {
           runIn(
             projectRoot,
             Effect.gen(function* () {
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
               yield* run(
                 {
@@ -440,7 +441,7 @@ describe("tool.shell permissions", () => {
               tmp,
               Effect.gen(function* () {
                 const err = new Error("stop after permission")
-                const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+                const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
                 expect(
                   yield* fail(
                     {
@@ -468,7 +469,7 @@ describe("tool.shell permissions", () => {
             projectRoot,
             Effect.gen(function* () {
               const err = new Error("stop after permission")
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               expect(
                 yield* fail(
                   {
@@ -497,7 +498,7 @@ describe("tool.shell permissions", () => {
               tmp,
               Effect.gen(function* () {
                 const err = new Error("stop after permission")
-                const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+                const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
                 expect(
                   yield* fail(
                     {
@@ -525,7 +526,7 @@ describe("tool.shell permissions", () => {
             projectRoot,
             Effect.gen(function* () {
               const err = new Error("stop after permission")
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               expect(
                 yield* fail(
                   {
@@ -560,7 +561,7 @@ describe("tool.shell permissions", () => {
                 projectRoot,
                 Effect.gen(function* () {
                   const err = new Error("stop after permission")
-                  const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+                  const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
                   const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
                   expect(
                     yield* fail(
@@ -593,7 +594,7 @@ describe("tool.shell permissions", () => {
           runIn(
             projectRoot,
             Effect.gen(function* () {
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               yield* run(
                 {
                   command: "Get-Content $env:WINDIR/win.ini",
@@ -620,7 +621,7 @@ describe("tool.shell permissions", () => {
             projectRoot,
             Effect.gen(function* () {
               const err = new Error("stop after permission")
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               expect(
                 yield* fail(
                   {
@@ -649,7 +650,7 @@ describe("tool.shell permissions", () => {
             projectRoot,
             Effect.gen(function* () {
               const err = new Error("stop after permission")
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               expect(
                 yield* fail(
                   {
@@ -677,7 +678,7 @@ describe("tool.shell permissions", () => {
           runIn(
             projectRoot,
             Effect.gen(function* () {
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               yield* run(
                 {
                   command: "Set-Location C:/Windows",
@@ -705,7 +706,7 @@ describe("tool.shell permissions", () => {
           runIn(
             projectRoot,
             Effect.gen(function* () {
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               yield* run(
                 {
                   command: "Write-Output ('a' * 3)",
@@ -731,7 +732,7 @@ describe("tool.shell permissions", () => {
         runIn(
           projectRoot,
           Effect.gen(function* () {
-            const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+            const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
             yield* run(
               {
                 command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`,
@@ -755,7 +756,7 @@ describe("tool.shell permissions", () => {
         tmp,
         Effect.gen(function* () {
           const err = new Error("stop after permission")
-          const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+          const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
           expect(
             yield* fail(
               {
@@ -779,7 +780,7 @@ describe("tool.shell permissions", () => {
         tmp,
         Effect.gen(function* () {
           const err = new Error("stop after permission")
-          const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+          const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
           expect(
             yield* fail(
               {
@@ -810,7 +811,7 @@ describe("tool.shell permissions", () => {
             const want = Filesystem.normalizePathPattern(path.join(outerTmp, "*"))
 
             for (const dir of forms(outerTmp)) {
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               expect(
                 yield* fail(
                   {
@@ -842,7 +843,7 @@ describe("tool.shell permissions", () => {
             projectRoot,
             Effect.gen(function* () {
               const err = new Error("stop after permission")
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               const want = glob(path.join(os.tmpdir(), "*"))
               expect(
                 yield* fail(
@@ -871,7 +872,7 @@ describe("tool.shell permissions", () => {
             projectRoot,
             Effect.gen(function* () {
               const err = new Error("stop after permission")
-              const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+              const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
               const want = glob(path.join(os.tmpdir(), "*"))
               expect(
                 yield* fail(
@@ -903,7 +904,7 @@ describe("tool.shell permissions", () => {
         tmp,
         Effect.gen(function* () {
           const err = new Error("stop after permission")
-          const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+          const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
           const filepath = path.join(outerTmp, "outside.txt")
           expect(
             yield* fail(
@@ -931,7 +932,7 @@ describe("tool.shell permissions", () => {
       yield* runIn(
         tmp,
         Effect.gen(function* () {
-          const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+          const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
           yield* run(
             {
               command: `rm -rf ${path.join(tmp, "nested")}`,
@@ -952,7 +953,7 @@ describe("tool.shell permissions", () => {
       yield* runIn(
         tmp,
         Effect.gen(function* () {
-          const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+          const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
           yield* run(
             {
               command: "git log --oneline -5",
@@ -974,7 +975,7 @@ describe("tool.shell permissions", () => {
       yield* runIn(
         tmp,
         Effect.gen(function* () {
-          const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+          const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
           yield* run(
             {
               command: "cd .",
@@ -996,7 +997,7 @@ describe("tool.shell permissions", () => {
         tmp,
         Effect.gen(function* () {
           const err = new Error("stop after permission")
-          const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+          const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
           expect(
             yield* fail(
               { command: "echo test > output.txt", description: "Redirect test output" },
@@ -1017,7 +1018,7 @@ describe("tool.shell permissions", () => {
       yield* runIn(
         tmp,
         Effect.gen(function* () {
-          const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+          const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
           yield* run({ command: "ls -la", description: "List" }, capture(requests))
           const bashReq = requests.find((r) => r.permission === "bash")
           expect(bashReq).toBeDefined()

+ 2 - 1
packages/opencode/test/tool/skill.test.ts

@@ -1,3 +1,4 @@
+import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Cause, Effect, Exit, Layer } from "effect"
 import { afterEach, describe, expect } from "bun:test"
@@ -67,7 +68,7 @@ Use this skill.
       })).find((tool) => tool.id === SkillTool.id)
       if (!tool) throw new Error("Skill tool not found")
 
-      const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+      const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
       const ctx: Tool.Context = {
         ...baseCtx,
         ask: (req) =>

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

@@ -117,6 +117,7 @@ import type {
   PermissionRespondErrors,
   PermissionRespondResponses,
   PermissionRuleset,
+  PermissionV2Reply,
   ProjectCurrentErrors,
   ProjectCurrentResponses,
   ProjectInitGitErrors,
@@ -248,6 +249,12 @@ import type {
   TuiSubmitPromptResponses,
   V2ModelListErrors,
   V2ModelListResponses,
+  V2PermissionRequestListErrors,
+  V2PermissionRequestListResponses,
+  V2PermissionSavedListErrors,
+  V2PermissionSavedListResponses,
+  V2PermissionSavedRemoveErrors,
+  V2PermissionSavedRemoveResponses,
   V2ProviderGetErrors,
   V2ProviderGetResponses,
   V2ProviderListErrors,
@@ -260,6 +267,10 @@ import type {
   V2SessionListResponses,
   V2SessionMessagesErrors,
   V2SessionMessagesResponses,
+  V2SessionPermissionListErrors,
+  V2SessionPermissionListResponses,
+  V2SessionPermissionReplyErrors,
+  V2SessionPermissionReplyResponses,
   V2SessionPromptErrors,
   V2SessionPromptResponses,
   V2SessionWaitErrors,
@@ -4255,6 +4266,74 @@ export class Sync extends HeyApiClient {
   }
 }
 
+export class Permission2 extends HeyApiClient {
+  /**
+   * List session permission requests
+   *
+   * Retrieve pending permission requests owned by a session.
+   */
+  public list<ThrowOnError extends boolean = false>(
+    parameters: {
+      sessionID: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
+    return (options?.client ?? this.client).get<
+      V2SessionPermissionListResponses,
+      V2SessionPermissionListErrors,
+      ThrowOnError
+    >({
+      url: "/api/session/{sessionID}/permission/request",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * Reply to pending permission request
+   *
+   * Respond to a pending permission request owned by a session.
+   */
+  public reply<ThrowOnError extends boolean = false>(
+    parameters: {
+      sessionID: string
+      requestID: string
+      reply?: PermissionV2Reply
+      message?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "sessionID" },
+            { in: "path", key: "requestID" },
+            { in: "body", key: "reply" },
+            { in: "body", key: "message" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<
+      V2SessionPermissionReplyResponses,
+      V2SessionPermissionReplyErrors,
+      ThrowOnError
+    >({
+      url: "/api/session/{sessionID}/permission/request/{requestID}/reply",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+}
+
 export class Session3 extends HeyApiClient {
   /**
    * List v2 sessions
@@ -4474,6 +4553,11 @@ export class Session3 extends HeyApiClient {
       ...params,
     })
   }
+
+  private _permission?: Permission2
+  get permission(): Permission2 {
+    return (this._permission ??= new Permission2({ client: this.client }))
+  }
 }
 
 export class Model extends HeyApiClient {
@@ -4557,6 +4641,94 @@ export class Provider2 extends HeyApiClient {
   }
 }
 
+export class Request extends HeyApiClient {
+  /**
+   * List pending permission requests
+   *
+   * Retrieve pending permission requests for a location.
+   */
+  public list<ThrowOnError extends boolean = false>(
+    parameters?: {
+      location?: {
+        directory?: string
+        workspace?: string
+      }
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
+    return (options?.client ?? this.client).get<
+      V2PermissionRequestListResponses,
+      V2PermissionRequestListErrors,
+      ThrowOnError
+    >({
+      url: "/api/permission/request",
+      ...options,
+      ...params,
+    })
+  }
+}
+
+export class Saved extends HeyApiClient {
+  /**
+   * List saved permissions
+   *
+   * Retrieve saved permissions, optionally filtered by project.
+   */
+  public list<ThrowOnError extends boolean = false>(
+    parameters?: {
+      projectID?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "projectID" }] }])
+    return (options?.client ?? this.client).get<
+      V2PermissionSavedListResponses,
+      V2PermissionSavedListErrors,
+      ThrowOnError
+    >({
+      url: "/api/permission/saved",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * Remove saved permission
+   *
+   * Remove a saved permission by ID.
+   */
+  public remove<ThrowOnError extends boolean = false>(
+    parameters: {
+      id: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "id" }] }])
+    return (options?.client ?? this.client).delete<
+      V2PermissionSavedRemoveResponses,
+      V2PermissionSavedRemoveErrors,
+      ThrowOnError
+    >({
+      url: "/api/permission/saved/{id}",
+      ...options,
+      ...params,
+    })
+  }
+}
+
+export class Permission3 extends HeyApiClient {
+  private _request?: Request
+  get request(): Request {
+    return (this._request ??= new Request({ client: this.client }))
+  }
+
+  private _saved?: Saved
+  get saved(): Saved {
+    return (this._saved ??= new Saved({ client: this.client }))
+  }
+}
+
 export class V2 extends HeyApiClient {
   private _session?: Session3
   get session(): Session3 {
@@ -4572,6 +4744,11 @@ export class V2 extends HeyApiClient {
   get provider(): Provider2 {
     return (this._provider ??= new Provider2({ client: this.client }))
   }
+
+  private _permission?: Permission3
+  get permission(): Permission3 {
+    return (this._permission ??= new Permission3({ client: this.client }))
+  }
 }
 
 export class Control extends HeyApiClient {

+ 310 - 60
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -44,10 +44,10 @@ export type Event =
   | EventMessagePartUpdated
   | EventMessagePartRemoved
   | EventMessagePartDelta
-  | EventPermissionAsked
-  | EventPermissionReplied
   | EventSessionDiff
   | EventSessionError
+  | EventPermissionAsked
+  | EventPermissionReplied
   | EventQuestionAsked
   | EventQuestionReplied
   | EventQuestionRejected
@@ -78,6 +78,8 @@ export type Event =
   | EventInstallationUpdateAvailable
   | EventServerConnected
   | EventGlobalDisposed
+  | EventPermissionV2Asked
+  | EventPermissionV2Replied
   | EventAccountAdded
   | EventAccountRemoved
   | EventAccountSwitched
@@ -145,6 +147,16 @@ export type SnapshotFileDiff = {
   status?: "added" | "deleted" | "modified"
 }
 
+export type PermissionAction = "allow" | "deny" | "ask"
+
+export type PermissionRule = {
+  permission: string
+  pattern: string
+  action: PermissionAction
+}
+
+export type PermissionRuleset = Array<PermissionRule>
+
 export type Session = {
   id: string
   slug: string
@@ -1094,6 +1106,29 @@ export type GlobalEvent = {
           delta: string
         }
       }
+    | {
+        id: string
+        type: "session.diff"
+        properties: {
+          sessionID: string
+          diff: Array<SnapshotFileDiff>
+        }
+      }
+    | {
+        id: string
+        type: "session.error"
+        properties: {
+          sessionID?: string
+          error?:
+            | ProviderAuthError
+            | UnknownError
+            | MessageOutputLengthError
+            | MessageAbortedError
+            | StructuredOutputError
+            | ContextOverflowError
+            | ApiError
+        }
+      }
     | {
         id: string
         type: "permission.asked"
@@ -1121,29 +1156,6 @@ export type GlobalEvent = {
           reply: "once" | "always" | "reject"
         }
       }
-    | {
-        id: string
-        type: "session.diff"
-        properties: {
-          sessionID: string
-          diff: Array<SnapshotFileDiff>
-        }
-      }
-    | {
-        id: string
-        type: "session.error"
-        properties: {
-          sessionID?: string
-          error?:
-            | ProviderAuthError
-            | UnknownError
-            | MessageOutputLengthError
-            | MessageAbortedError
-            | StructuredOutputError
-            | ContextOverflowError
-            | ApiError
-        }
-      }
     | {
         id: string
         type: "question.asked"
@@ -1415,6 +1427,30 @@ export type GlobalEvent = {
           [key: string]: unknown
         }
       }
+    | {
+        id: string
+        type: "permission.v2.asked"
+        properties: {
+          id: string
+          sessionID: string
+          action: string
+          resources: Array<string>
+          save?: Array<string>
+          metadata?: {
+            [key: string]: unknown
+          }
+          source?: PermissionV2Source
+        }
+      }
+    | {
+        id: string
+        type: "permission.v2.replied"
+        properties: {
+          sessionID: string
+          requestID: string
+          reply: PermissionV2Reply
+        }
+      }
     | {
         id: string
         type: "account.added"
@@ -2029,16 +2065,6 @@ export type WorktreeResetInput = {
   directory: string
 }
 
-export type PermissionAction = "allow" | "deny" | "ask"
-
-export type PermissionRule = {
-  permission: string
-  pattern: string
-  action: PermissionAction
-}
-
-export type PermissionRuleset = Array<PermissionRule>
-
 export type ProjectSummary = {
   id: string
   name?: string
@@ -2811,6 +2837,14 @@ export type SessionNextRetryError = {
   }
 }
 
+export type PermissionV2Source = {
+  type: "tool"
+  messageID: string
+  callID: string
+}
+
+export type PermissionV2Reply = "once" | "always" | "reject"
+
 export type AuthOAuthCredential = {
   type: "oauth"
   refresh: string
@@ -3637,6 +3671,25 @@ export type ProviderV2Info = {
   }
 }
 
+export type PermissionV2Request = {
+  id: string
+  sessionID: string
+  action: string
+  resources: Array<string>
+  save?: Array<string>
+  metadata?: {
+    [key: string]: unknown
+  }
+  source?: PermissionV2Source
+}
+
+export type PermissionSavedInfo = {
+  id: string
+  projectID: string
+  action: string
+  resource: string
+}
+
 export type EventModelsDevRefreshed = {
   id: string
   type: "models-dev.refreshed"
@@ -4173,6 +4226,31 @@ export type EventMessagePartDelta = {
   }
 }
 
+export type EventSessionDiff = {
+  id: string
+  type: "session.diff"
+  properties: {
+    sessionID: string
+    diff: Array<SnapshotFileDiff>
+  }
+}
+
+export type EventSessionError = {
+  id: string
+  type: "session.error"
+  properties: {
+    sessionID?: string
+    error?:
+      | ProviderAuthError
+      | UnknownError
+      | MessageOutputLengthError
+      | MessageAbortedError
+      | StructuredOutputError
+      | ContextOverflowError
+      | ApiError
+  }
+}
+
 export type EventPermissionAsked = {
   id: string
   type: "permission.asked"
@@ -4202,31 +4280,6 @@ export type EventPermissionReplied = {
   }
 }
 
-export type EventSessionDiff = {
-  id: string
-  type: "session.diff"
-  properties: {
-    sessionID: string
-    diff: Array<SnapshotFileDiff>
-  }
-}
-
-export type EventSessionError = {
-  id: string
-  type: "session.error"
-  properties: {
-    sessionID?: string
-    error?:
-      | ProviderAuthError
-      | UnknownError
-      | MessageOutputLengthError
-      | MessageAbortedError
-      | StructuredOutputError
-      | ContextOverflowError
-      | ApiError
-  }
-}
-
 export type EventQuestionAsked = {
   id: string
   type: "question.asked"
@@ -4473,6 +4526,32 @@ export type EventGlobalDisposed = {
   }
 }
 
+export type EventPermissionV2Asked = {
+  id: string
+  type: "permission.v2.asked"
+  properties: {
+    id: string
+    sessionID: string
+    action: string
+    resources: Array<string>
+    save?: Array<string>
+    metadata?: {
+      [key: string]: unknown
+    }
+    source?: PermissionV2Source
+  }
+}
+
+export type EventPermissionV2Replied = {
+  id: string
+  type: "permission.v2.replied"
+  properties: {
+    sessionID: string
+    requestID: string
+    reply: PermissionV2Reply
+  }
+}
+
 export type EventAccountAdded = {
   id: string
   type: "account.added"
@@ -8262,6 +8341,177 @@ export type V2ProviderGetResponses = {
 
 export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses]
 
+export type V2PermissionRequestListData = {
+  body?: never
+  path?: never
+  query?: {
+    location?: {
+      directory?: string
+      workspace?: string
+    }
+  }
+  url: "/api/permission/request"
+}
+
+export type V2PermissionRequestListErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+}
+
+export type V2PermissionRequestListError = V2PermissionRequestListErrors[keyof V2PermissionRequestListErrors]
+
+export type V2PermissionRequestListResponses = {
+  /**
+   * Success
+   */
+  200: Array<PermissionV2Request>
+}
+
+export type V2PermissionRequestListResponse = V2PermissionRequestListResponses[keyof V2PermissionRequestListResponses]
+
+export type V2SessionPermissionListData = {
+  body?: never
+  path: {
+    sessionID: string
+  }
+  query?: never
+  url: "/api/session/{sessionID}/permission/request"
+}
+
+export type V2SessionPermissionListErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+  /**
+   * SessionNotFoundError
+   */
+  404: SessionNotFoundError
+}
+
+export type V2SessionPermissionListError = V2SessionPermissionListErrors[keyof V2SessionPermissionListErrors]
+
+export type V2SessionPermissionListResponses = {
+  /**
+   * Success
+   */
+  200: Array<PermissionV2Request>
+}
+
+export type V2SessionPermissionListResponse = V2SessionPermissionListResponses[keyof V2SessionPermissionListResponses]
+
+export type V2SessionPermissionReplyData = {
+  body?: {
+    reply: PermissionV2Reply
+    message?: string
+  }
+  path: {
+    sessionID: string
+    requestID: string
+  }
+  query?: never
+  url: "/api/session/{sessionID}/permission/request/{requestID}/reply"
+}
+
+export type V2SessionPermissionReplyErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+  /**
+   * SessionNotFoundError | PermissionNotFoundError
+   */
+  404: SessionNotFoundError | PermissionNotFoundError
+}
+
+export type V2SessionPermissionReplyError = V2SessionPermissionReplyErrors[keyof V2SessionPermissionReplyErrors]
+
+export type V2SessionPermissionReplyResponses = {
+  /**
+   * <No Content>
+   */
+  204: void
+}
+
+export type V2SessionPermissionReplyResponse =
+  V2SessionPermissionReplyResponses[keyof V2SessionPermissionReplyResponses]
+
+export type V2PermissionSavedListData = {
+  body?: never
+  path?: never
+  query?: {
+    projectID?: string
+  }
+  url: "/api/permission/saved"
+}
+
+export type V2PermissionSavedListErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+}
+
+export type V2PermissionSavedListError = V2PermissionSavedListErrors[keyof V2PermissionSavedListErrors]
+
+export type V2PermissionSavedListResponses = {
+  /**
+   * Success
+   */
+  200: Array<PermissionSavedInfo>
+}
+
+export type V2PermissionSavedListResponse = V2PermissionSavedListResponses[keyof V2PermissionSavedListResponses]
+
+export type V2PermissionSavedRemoveData = {
+  body?: never
+  path: {
+    id: string
+  }
+  query?: never
+  url: "/api/permission/saved/{id}"
+}
+
+export type V2PermissionSavedRemoveErrors = {
+  /**
+   * InvalidRequestError
+   */
+  400: InvalidRequestError
+  /**
+   * UnauthorizedError
+   */
+  401: UnauthorizedError
+}
+
+export type V2PermissionSavedRemoveError = V2PermissionSavedRemoveErrors[keyof V2PermissionSavedRemoveErrors]
+
+export type V2PermissionSavedRemoveResponses = {
+  /**
+   * <No Content>
+   */
+  204: void
+}
+
+export type V2PermissionSavedRemoveResponse = V2PermissionSavedRemoveResponses[keyof V2PermissionSavedRemoveResponses]
+
 export type TuiAppendPromptData = {
   body?: {
     text: string