Преглед изворни кода

Merge branch 'dev' into fix/lsp-dead-root-prune

Kit Langton пре 1 недеља
родитељ
комит
e8ba72bef6
45 измењених фајлова са 3282 додато и 1284 уклоњено
  1. 6 1
      packages/app/src/context/global-sdk.tsx
  2. 13 0
      packages/opencode/migration/20260413175956_chief_energizer/migration.sql
  3. 1399 0
      packages/opencode/migration/20260413175956_chief_energizer/snapshot.json
  4. 238 0
      packages/opencode/specs/effect/facades.md
  5. 2 2
      packages/opencode/specs/effect/migration.md
  6. 4 2
      packages/opencode/src/acp/agent.ts
  7. 0 19
      packages/opencode/src/agent/agent.ts
  8. 11 18
      packages/opencode/src/bus/bus-event.ts
  9. 5 2
      packages/opencode/src/cli/cmd/agent.ts
  10. 1 1
      packages/opencode/src/cli/cmd/debug/agent.ts
  11. 6 1
      packages/opencode/src/cli/cmd/mcp.ts
  12. 10 8
      packages/opencode/src/cli/cmd/run.ts
  13. 4 0
      packages/opencode/src/cli/cmd/tui/context/event.ts
  14. 1 0
      packages/opencode/src/id/id.ts
  15. 0 29
      packages/opencode/src/mcp/auth.ts
  16. 2 0
      packages/opencode/src/mcp/index.ts
  17. 35 29
      packages/opencode/src/mcp/oauth-provider.ts
  18. 0 2
      packages/opencode/src/project/instance.ts
  19. 7 1
      packages/opencode/src/server/instance/event.ts
  20. 1 47
      packages/opencode/src/server/instance/global.ts
  21. 1 1
      packages/opencode/src/server/instance/index.ts
  22. 49 30
      packages/opencode/src/server/instance/session.ts
  23. 11 5
      packages/opencode/src/session/processor.ts
  24. 4 3
      packages/opencode/src/session/projectors.ts
  25. 6 1
      packages/opencode/src/session/prompt.ts
  26. 0 15
      packages/opencode/src/session/revert.ts
  27. 21 0
      packages/opencode/src/session/session.sql.ts
  28. 0 10
      packages/opencode/src/session/summary.ts
  29. 29 30
      packages/opencode/src/sync/index.ts
  30. 3 2
      packages/opencode/src/tool/registry.ts
  31. 0 115
      packages/opencode/src/v2/message.ts
  32. 186 0
      packages/opencode/src/v2/session-entry.ts
  33. 4 4
      packages/opencode/src/v2/session.ts
  34. 48 41
      packages/opencode/test/agent/agent.test.ts
  35. 6 3
      packages/opencode/test/config/agent-color.test.ts
  36. 23 4
      packages/opencode/test/mcp/oauth-auto-connect.test.ts
  37. 11 1
      packages/opencode/test/session/compaction.test.ts
  38. 14 1
      packages/opencode/test/session/processor-effect.test.ts
  39. 12 1
      packages/opencode/test/session/prompt-effect.test.ts
  40. 558 540
      packages/opencode/test/session/revert-compact.test.ts
  41. 6 3
      packages/opencode/test/session/snapshot-tool-race.test.ts
  42. 6 2
      packages/opencode/test/session/system.test.ts
  43. 0 20
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  44. 141 88
      packages/sdk/js/src/v2/gen/types.gen.ts
  45. 398 202
      packages/sdk/openapi.json

+ 6 - 1
packages/app/src/context/global-sdk.tsx

@@ -155,7 +155,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
               resetHeartbeat()
               streamErrorLogged = false
               const directory = event.directory ?? "global"
-              const payload = event.payload
+              if (event.payload.type === "sync") {
+                continue
+              }
+
+              const payload = event.payload as Event
+
               const k = key(directory, payload)
               if (k) {
                 const i = coalesced.get(k)

+ 13 - 0
packages/opencode/migration/20260413175956_chief_energizer/migration.sql

@@ -0,0 +1,13 @@
+CREATE TABLE `session_entry` (
+	`id` text PRIMARY KEY,
+	`session_id` text NOT NULL,
+	`type` text NOT NULL,
+	`time_created` integer NOT NULL,
+	`time_updated` integer NOT NULL,
+	`data` text NOT NULL,
+	CONSTRAINT `fk_session_entry_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE INDEX `session_entry_session_idx` ON `session_entry` (`session_id`);--> statement-breakpoint
+CREATE INDEX `session_entry_session_type_idx` ON `session_entry` (`session_id`,`type`);--> statement-breakpoint
+CREATE INDEX `session_entry_time_created_idx` ON `session_entry` (`time_created`);

+ 1399 - 0
packages/opencode/migration/20260413175956_chief_energizer/snapshot.json

@@ -0,0 +1,1399 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "id": "30b928c5-deef-472c-856d-b5b5064bf6d4",
+  "prevIds": ["b61476b8-3b92-49ae-9fa5-6eef586ed64b"],
+  "ddl": [
+    {
+      "name": "account_state",
+      "entityType": "tables"
+    },
+    {
+      "name": "account",
+      "entityType": "tables"
+    },
+    {
+      "name": "control_account",
+      "entityType": "tables"
+    },
+    {
+      "name": "workspace",
+      "entityType": "tables"
+    },
+    {
+      "name": "project",
+      "entityType": "tables"
+    },
+    {
+      "name": "message",
+      "entityType": "tables"
+    },
+    {
+      "name": "part",
+      "entityType": "tables"
+    },
+    {
+      "name": "permission",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_entry",
+      "entityType": "tables"
+    },
+    {
+      "name": "session",
+      "entityType": "tables"
+    },
+    {
+      "name": "todo",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_share",
+      "entityType": "tables"
+    },
+    {
+      "name": "event_sequence",
+      "entityType": "tables"
+    },
+    {
+      "name": "event",
+      "entityType": "tables"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active_account_id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active_org_id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "access_token",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "refresh_token",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "token_expiry",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "access_token",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "refresh_token",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "token_expiry",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "''",
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "branch",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "extra",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "worktree",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "vcs",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_color",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_initialized",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "sandboxes",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "commands",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "message_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_entry"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_entry"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "session_entry"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_entry"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_entry"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "session_entry"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "parent_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "slug",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "title",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "version",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "share_url",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_additions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_deletions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_files",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_diffs",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "revert",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "permission",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_compacting",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_archived",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "content",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "status",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "priority",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "position",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "secret",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "aggregate_id",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "aggregate_id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "columns": ["active_account_id"],
+      "tableTo": "account",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "SET NULL",
+      "nameExplicit": false,
+      "name": "fk_account_state_active_account_id_account_id_fk",
+      "entityType": "fks",
+      "table": "account_state"
+    },
+    {
+      "columns": ["project_id"],
+      "tableTo": "project",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_workspace_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "workspace"
+    },
+    {
+      "columns": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_message_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "message"
+    },
+    {
+      "columns": ["message_id"],
+      "tableTo": "message",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_part_message_id_message_id_fk",
+      "entityType": "fks",
+      "table": "part"
+    },
+    {
+      "columns": ["project_id"],
+      "tableTo": "project",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_permission_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "permission"
+    },
+    {
+      "columns": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_entry_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_entry"
+    },
+    {
+      "columns": ["project_id"],
+      "tableTo": "project",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "session"
+    },
+    {
+      "columns": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_todo_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "todo"
+    },
+    {
+      "columns": ["session_id"],
+      "tableTo": "session",
+      "columnsTo": ["id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_share_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_share"
+    },
+    {
+      "columns": ["aggregate_id"],
+      "tableTo": "event_sequence",
+      "columnsTo": ["aggregate_id"],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk",
+      "entityType": "fks",
+      "table": "event"
+    },
+    {
+      "columns": ["email", "url"],
+      "nameExplicit": false,
+      "name": "control_account_pk",
+      "entityType": "pks",
+      "table": "control_account"
+    },
+    {
+      "columns": ["session_id", "position"],
+      "nameExplicit": false,
+      "name": "todo_pk",
+      "entityType": "pks",
+      "table": "todo"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "account_state_pk",
+      "table": "account_state",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "account_pk",
+      "table": "account",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "workspace_pk",
+      "table": "workspace",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "project_pk",
+      "table": "project",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "message_pk",
+      "table": "message",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "part_pk",
+      "table": "part",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["project_id"],
+      "nameExplicit": false,
+      "name": "permission_pk",
+      "table": "permission",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "session_entry_pk",
+      "table": "session_entry",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "session_pk",
+      "table": "session",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["session_id"],
+      "nameExplicit": false,
+      "name": "session_share_pk",
+      "table": "session_share",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["aggregate_id"],
+      "nameExplicit": false,
+      "name": "event_sequence_pk",
+      "table": "event_sequence",
+      "entityType": "pks"
+    },
+    {
+      "columns": ["id"],
+      "nameExplicit": false,
+      "name": "event_pk",
+      "table": "event",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "time_created",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "message_session_time_created_id_idx",
+      "entityType": "indexes",
+      "table": "message"
+    },
+    {
+      "columns": [
+        {
+          "value": "message_id",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_message_id_id_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_session_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_entry_session_idx",
+      "entityType": "indexes",
+      "table": "session_entry"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "type",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_entry_session_type_idx",
+      "entityType": "indexes",
+      "table": "session_entry"
+    },
+    {
+      "columns": [
+        {
+          "value": "time_created",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_entry_time_created_idx",
+      "entityType": "indexes",
+      "table": "session_entry"
+    },
+    {
+      "columns": [
+        {
+          "value": "project_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_project_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_workspace_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "parent_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_parent_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "todo_session_idx",
+      "entityType": "indexes",
+      "table": "todo"
+    }
+  ],
+  "renames": []
+}

+ 238 - 0
packages/opencode/specs/effect/facades.md

@@ -0,0 +1,238 @@
+# Facade removal checklist
+
+Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`.
+
+As of 2026-04-13, latest `origin/dev`:
+
+- `src/` still has 15 `makeRuntime(...)` call sites.
+- 13 of those are still in scope for facade removal.
+- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`.
+
+Recent progress:
+
+- Wave 1 is merged: `Pty`, `Skill`, `Vcs`, `ToolRegistry`, `Auth`.
+- Wave 2 is merged: `Config`, `Provider`, `File`, `LSP`, `MCP`.
+
+## Priority hotspots
+
+- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades.
+- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion.
+
+## Completed Batches
+
+Low-risk batch, all merged:
+
+1. `src/pty/index.ts`
+2. `src/skill/index.ts`
+3. `src/project/vcs.ts`
+4. `src/tool/registry.ts`
+5. `src/auth/index.ts`
+
+Caller-heavy batch, all merged:
+
+1. `src/config/config.ts`
+2. `src/provider/provider.ts`
+3. `src/file/index.ts`
+4. `src/lsp/index.ts`
+5. `src/mcp/index.ts`
+
+Shared pattern:
+
+- one service file still exports `makeRuntime(...)` + async facades
+- one or two route or CLI entrypoints call those facades directly
+- tests call the facade directly and need to switch to `yield* svc.method(...)`
+- once callers are gone, delete `makeRuntime(...)`, remove async facade exports, and drop the `makeRuntime` import
+
+## Done means
+
+For each service in the low-risk batch, the work is complete only when all of these are true:
+
+1. all production callers stop using `Namespace.method(...)` facade calls
+2. all direct test callers stop using the facade and instead yield the service from context
+3. the service file no longer has `makeRuntime(...)`
+4. the service file no longer exports runtime-backed facade helpers
+5. `grep` for the migrated facade methods only finds the service implementation itself or unrelated names
+
+## Caller templates
+
+### Route handlers
+
+Use one `AppRuntime.runPromise(Effect.gen(...))` body and yield the service inside it.
+
+```ts
+const value = await AppRuntime.runPromise(
+  Effect.gen(function* () {
+    const pty = yield* Pty.Service
+    return yield* pty.list()
+  }),
+)
+```
+
+If two service calls are independent, keep them in the same effect body and use `Effect.all(...)`.
+
+### Plain async CLI or script entrypoints
+
+If the caller is not itself an Effect service yet, still prefer one contiguous `AppRuntime.runPromise(Effect.gen(...))` block for the whole unit of work.
+
+```ts
+const skills = await AppRuntime.runPromise(
+  Effect.gen(function* () {
+    const auth = yield* Auth.Service
+    const skill = yield* Skill.Service
+    yield* auth.set(key, info)
+    return yield* skill.all()
+  }),
+)
+```
+
+Only fall back to `AppRuntime.runPromise(Service.use(...))` for truly isolated one-off calls or awkward callback boundaries. Do not stack multiple tiny `runPromise(...)` calls in the same contiguous workflow.
+
+This is the right intermediate state. Do not block facade removal on effectifying the whole CLI file.
+
+### Bootstrap or fire-and-forget startup code
+
+If the old facade call existed only to kick off initialization, call the service through the existing runtime for that file.
+
+```ts
+void BootstrapRuntime.runPromise(Vcs.Service.use((svc) => svc.init()))
+```
+
+Do not reintroduce a dedicated runtime in the service just for bootstrap.
+
+### Tests
+
+Convert facade tests to full effect style.
+
+```ts
+it.effect("does the thing", () =>
+  Effect.gen(function* () {
+    const svc = yield* Pty.Service
+    const info = yield* svc.create({ command: "cat", title: "a" })
+    yield* svc.remove(info.id)
+  }).pipe(Effect.provide(Pty.defaultLayer)),
+)
+```
+
+If the repo test already uses `testEffect(...)`, prefer `testEffect(Service.defaultLayer)` and `yield* Service.Service` inside the test body.
+
+Do not route tests through `AppRuntime` unless the test is explicitly exercising the app runtime. For facade removal, tests should usually provide the specific service layer they need.
+
+If the test uses `provideTmpdirInstance(...)`, remember that fixture needs a live `ChildProcessSpawner` layer. For services whose `defaultLayer` does not already provide that infra, prefer the repo-standard cross-spawn layer:
+
+```ts
+const infra = CrossSpawnSpawner.defaultLayer
+
+const it = testEffect(Layer.mergeAll(MyService.defaultLayer, infra))
+```
+
+Without that extra layer, tests fail at runtime with `Service not found: effect/process/ChildProcessSpawner`.
+
+## Questions already answered
+
+### Do we need to effectify the whole caller first?
+
+No.
+
+- route files: compose the handler with `AppRuntime.runPromise(Effect.gen(...))`
+- CLI and scripts: use `AppRuntime.runPromise(Service.use(...))`
+- bootstrap: use the existing bootstrap runtime
+
+Facade removal does not require a bigger refactor than that.
+
+### Should tests keep calling the namespace from async test bodies?
+
+No. Convert them now.
+
+The end state is `yield* svc.method(...)`, not `await Namespace.method(...)` inside `async` tests.
+
+### Should we keep `runPromise` exported for convenience?
+
+No. For this batch the goal is to delete the service-local runtime entirely.
+
+### What if a route has websocket callbacks or nested async handlers?
+
+Keep the route shape, but replace each facade call with `AppRuntime.runPromise(Service.use(...))` or wrap the surrounding async section in one `Effect.gen(...)` when practical. Do not keep the service facade just because the route has callback-shaped code.
+
+### Should we use one `runPromise` per service call?
+
+No.
+
+Default to one contiguous `AppRuntime.runPromise(Effect.gen(...))` block per handler, command, or workflow. Yield every service you need inside that block.
+
+Multiple tiny `runPromise(...)` calls are only acceptable when the caller structure forces it, such as websocket lifecycle callbacks, external callback APIs, or genuinely unrelated one-off operations.
+
+### Should we wrap a single service expression in `Effect.gen(...)`?
+
+Usually no.
+
+Prefer the direct form when there is only one expression:
+
+```ts
+await AppRuntime.runPromise(File.Service.use((svc) => svc.read(path)))
+```
+
+Use `Effect.gen(...)` when the workflow actually needs multiple yielded values or branching.
+
+## Learnings
+
+These were the recurring mistakes and useful corrections from the first two batches:
+
+1. Tests should usually provide the specific service layer, not `AppRuntime`.
+2. If a test uses `provideTmpdirInstance(...)` and needs child processes, prefer `CrossSpawnSpawner.defaultLayer`.
+3. Instance-scoped services may need both the service layer and the right instance fixture. `File` tests, for example, needed `provideInstance(...)` plus `File.defaultLayer`.
+4. Do not wrap a single `Service.use(...)` call in `Effect.gen(...)` just to return it. Use the direct form.
+5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
+6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
+
+## Next batch
+
+Recommended next five, in order:
+
+1. `src/permission/index.ts`
+2. `src/agent/agent.ts`
+3. `src/session/summary.ts`
+4. `src/session/revert.ts`
+5. `src/mcp/auth.ts`
+
+Why this batch:
+
+- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`.
+- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`.
+- `McpAuth` is small and closely related to the just-landed `MCP` cleanup.
+
+After that batch, the expected follow-up is the main session cluster:
+
+1. `src/session/index.ts`
+2. `src/session/prompt.ts`
+3. `src/session/compaction.ts`
+
+## Checklist
+
+- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts`
+- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts`
+- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts`
+- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts`
+- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts`
+- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`
+- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts`
+- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts`
+- [x] `src/file/index.ts` (`File`) - facades removed and merged.
+- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged.
+- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged.
+- [x] `src/config/config.ts` (`Config`) - facades removed and merged.
+- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged.
+- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged.
+- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged.
+- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged.
+- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged.
+- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts`
+- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged.
+- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts`
+- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts`
+- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts`
+- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts`
+
+## Excluded `makeRuntime(...)` sites
+
+- `src/bus/index.ts` - core bus plumbing, not a normal facade-removal target.
+- `src/effect/cross-spawn-spawner.ts` - runtime helper for `ChildProcessSpawner`, not a service namespace facade.

+ 2 - 2
packages/opencode/specs/effect/migration.md

@@ -180,7 +180,7 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
 
 Service-shape migrated (single namespace, traced methods, `InstanceState` where needed).
 
-This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in [Destroying the facades](#destroying-the-facades).
+This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in `facades.md`.
 
 - [x] `Account` — `account/index.ts`
 - [x] `Agent` — `agent/agent.ts`
@@ -263,7 +263,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
 
 ## Destroying the facades
 
-This phase is still broadly open. As of 2026-04-11 there are still 31 `makeRuntime(...)` call sites under `src/`, and many service namespaces still export async facade helpers like `export async function read(...) { return runPromise(...) }`.
+This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
 
 These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
 

+ 4 - 2
packages/opencode/src/acp/agent.ts

@@ -40,6 +40,7 @@ import type { ACPConfig } from "./types"
 import { Provider } from "../provider/provider"
 import { ModelID, ProviderID } from "../provider/schema"
 import { Agent as AgentModule } from "../agent/agent"
+import { AppRuntime } from "@/effect/app-runtime"
 import { Installation } from "@/installation"
 import { MessageV2 } from "@/session/message-v2"
 import { Config } from "@/config/config"
@@ -1166,7 +1167,7 @@ export namespace ACP {
         this.sessionManager.get(sessionId).modeId ||
         (await (async () => {
           if (!availableModes.length) return undefined
-          const defaultAgentName = await AgentModule.defaultAgent()
+          const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
           const resolvedModeId =
             availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
           this.sessionManager.setMode(sessionId, resolvedModeId)
@@ -1367,7 +1368,8 @@ export namespace ACP {
       if (!current) {
         this.sessionManager.setModel(session.id, model)
       }
-      const agent = session.modeId ?? (await AgentModule.defaultAgent())
+      const agent =
+        session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())))
 
       const parts: Array<
         | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }

+ 0 - 19
packages/opencode/src/agent/agent.ts

@@ -21,7 +21,6 @@ import { Plugin } from "@/plugin"
 import { Skill } from "../skill"
 import { Effect, Context, Layer } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
 
 export namespace Agent {
   export const Info = z
@@ -404,22 +403,4 @@ export namespace Agent {
     Layer.provide(Config.defaultLayer),
     Layer.provide(Skill.defaultLayer),
   )
-
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export async function get(agent: string) {
-    return runPromise((svc) => svc.get(agent))
-  }
-
-  export async function list() {
-    return runPromise((svc) => svc.list())
-  }
-
-  export async function defaultAgent() {
-    return runPromise((svc) => svc.defaultAgent())
-  }
-
-  export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
-    return runPromise((svc) => svc.generate(input))
-  }
 }

+ 11 - 18
packages/opencode/src/bus/bus-event.ts

@@ -16,25 +16,18 @@ export namespace BusEvent {
   }
 
   export function payloads() {
-    return z
-      .discriminatedUnion(
-        "type",
-        registry
-          .entries()
-          .map(([type, def]) => {
-            return z
-              .object({
-                type: z.literal(type),
-                properties: def.properties,
-              })
-              .meta({
-                ref: "Event" + "." + def.type,
-              })
+    return registry
+      .entries()
+      .map(([type, def]) => {
+        return z
+          .object({
+            type: z.literal(type),
+            properties: def.properties,
+          })
+          .meta({
+            ref: "Event" + "." + def.type,
           })
-          .toArray() as any,
-      )
-      .meta({
-        ref: "Event",
       })
+      .toArray()
   }
 }

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

@@ -1,5 +1,6 @@
 import { cmd } from "./cmd"
 import * as prompts from "@clack/prompts"
+import { AppRuntime } from "@/effect/app-runtime"
 import { UI } from "../ui"
 import { Global } from "../../global"
 import { Agent } from "../../agent/agent"
@@ -110,7 +111,9 @@ const AgentCreateCommand = cmd({
         const spinner = prompts.spinner()
         spinner.start("Generating agent configuration...")
         const model = args.model ? Provider.parseModel(args.model) : undefined
-        const generated = await Agent.generate({ description, model }).catch((error) => {
+        const generated = await AppRuntime.runPromise(
+          Agent.Service.use((svc) => svc.generate({ description, model })),
+        ).catch((error) => {
           spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
           if (isFullyNonInteractive) process.exit(1)
           throw new UI.CancelledError()
@@ -220,7 +223,7 @@ const AgentListCommand = cmd({
     await Instance.provide({
       directory: process.cwd(),
       async fn() {
-        const agents = await Agent.list()
+        const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
         const sortedAgents = agents.sort((a, b) => {
           if (a.native !== b.native) {
             return a.native ? -1 : 1

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

@@ -35,7 +35,7 @@ export const AgentCommand = cmd({
   async handler(args) {
     await bootstrap(process.cwd(), async () => {
       const agentName = args.name as string
-      const agent = await Agent.get(agentName)
+      const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName)))
       if (!agent) {
         process.stderr.write(
           `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,

+ 6 - 1
packages/opencode/src/cli/cmd/mcp.ts

@@ -361,7 +361,6 @@ export const McpLogoutCommand = cmd({
         UI.empty()
         prompts.intro("MCP OAuth Logout")
 
-        const authPath = path.join(Global.Path.data, "mcp-auth.json")
         const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all()))
         const serverNames = Object.keys(credentials)
 
@@ -717,6 +716,11 @@ export const McpDebugCommand = cmd({
 
             // Try to discover OAuth metadata
             const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
+            const auth = await AppRuntime.runPromise(
+              Effect.gen(function* () {
+                return yield* McpAuth.Service
+              }),
+            )
             const authProvider = new McpOAuthProvider(
               serverName,
               serverConfig.url,
@@ -729,6 +733,7 @@ export const McpDebugCommand = cmd({
               {
                 onRedirect: async () => {},
               },
+              auth,
             )
 
             prompts.log.info("Testing OAuth flow (without completing authorization)...")

+ 10 - 8
packages/opencode/src/cli/cmd/run.ts

@@ -27,6 +27,7 @@ import { SkillTool } from "../../tool/skill"
 import { BashTool } from "../../tool/bash"
 import { TodoWriteTool } from "../../tool/todo"
 import { Locale } from "../../util/locale"
+import { AppRuntime } from "@/effect/app-runtime"
 
 type ToolProps<T> = {
   input: Tool.InferParameters<T>
@@ -573,6 +574,7 @@ export const RunCommand = cmd({
       // Validate agent if specified
       const agent = await (async () => {
         if (!args.agent) return undefined
+        const name = args.agent
 
         // When attaching, validate against the running server instead of local Instance state.
         if (args.attach) {
@@ -590,12 +592,12 @@ export const RunCommand = cmd({
             return undefined
           }
 
-          const agent = modes.find((a) => a.name === args.agent)
+          const agent = modes.find((a) => a.name === name)
           if (!agent) {
             UI.println(
               UI.Style.TEXT_WARNING_BOLD + "!",
               UI.Style.TEXT_NORMAL,
-              `agent "${args.agent}" not found. Falling back to default agent`,
+              `agent "${name}" not found. Falling back to default agent`,
             )
             return undefined
           }
@@ -604,20 +606,20 @@ export const RunCommand = cmd({
             UI.println(
               UI.Style.TEXT_WARNING_BOLD + "!",
               UI.Style.TEXT_NORMAL,
-              `agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
+              `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
             )
             return undefined
           }
 
-          return args.agent
+          return name
         }
 
-        const entry = await Agent.get(args.agent)
+        const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
         if (!entry) {
           UI.println(
             UI.Style.TEXT_WARNING_BOLD + "!",
             UI.Style.TEXT_NORMAL,
-            `agent "${args.agent}" not found. Falling back to default agent`,
+            `agent "${name}" not found. Falling back to default agent`,
           )
           return undefined
         }
@@ -625,11 +627,11 @@ export const RunCommand = cmd({
           UI.println(
             UI.Style.TEXT_WARNING_BOLD + "!",
             UI.Style.TEXT_NORMAL,
-            `agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
+            `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
           )
           return undefined
         }
-        return args.agent
+        return name
       })()
 
       const sessionID = await session(sdk)

+ 4 - 0
packages/opencode/src/cli/cmd/tui/context/event.ts

@@ -8,6 +8,10 @@ export function useEvent() {
 
   function subscribe(handler: (event: Event) => void) {
     return sdk.event.on("event", (event) => {
+      if (event.payload.type === "sync") {
+        return
+      }
+
       // Special hack for truly global events
       if (event.directory === "global") {
         handler(event.payload)

+ 1 - 0
packages/opencode/src/id/id.ts

@@ -13,6 +13,7 @@ export namespace Identifier {
     pty: "pty",
     tool: "tool",
     workspace: "wrk",
+    entry: "ent",
   } as const
 
   export function schema(prefix: keyof typeof prefixes) {

+ 0 - 29
packages/opencode/src/mcp/auth.ts

@@ -3,7 +3,6 @@ import z from "zod"
 import { Global } from "../global"
 import { Effect, Layer, Context } from "effect"
 import { AppFileSystem } from "@/filesystem"
-import { makeRuntime } from "@/effect/run-service"
 
 export namespace McpAuth {
   export const Tokens = z.object({
@@ -142,32 +141,4 @@ export namespace McpAuth {
   )
 
   export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
-
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  // Async facades for backward compat (used by McpOAuthProvider, CLI)
-
-  export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
-
-  export const getForUrl = async (mcpName: string, serverUrl: string) =>
-    runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
-
-  export const all = async () => runPromise((svc) => svc.all())
-
-  export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
-    runPromise((svc) => svc.set(mcpName, entry, serverUrl))
-
-  export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
-
-  export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
-    runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
-
-  export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
-    runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
-
-  export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
-    runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
-
-  export const updateOAuthState = async (mcpName: string, oauthState: string) =>
-    runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
 }

+ 2 - 0
packages/opencode/src/mcp/index.ts

@@ -293,6 +293,7 @@ export namespace MCP {
                 log.info("oauth redirect requested", { key, url: url.toString() })
               },
             },
+            auth,
           )
         }
 
@@ -744,6 +745,7 @@ export namespace MCP {
               capturedUrl = url
             },
           },
+          auth,
         )
 
         const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })

+ 35 - 29
packages/opencode/src/mcp/oauth-provider.ts

@@ -5,6 +5,7 @@ import type {
   OAuthClientInformation,
   OAuthClientInformationFull,
 } from "@modelcontextprotocol/sdk/shared/auth.js"
+import { Effect } from "effect"
 import { McpAuth } from "./auth"
 import { Log } from "../util/log"
 
@@ -30,6 +31,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
     private serverUrl: string,
     private config: McpOAuthConfig,
     private callbacks: McpOAuthCallbacks,
+    private auth: McpAuth.Interface,
   ) {}
 
   get redirectUrl(): string {
@@ -61,7 +63,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
 
     // Check stored client info (from dynamic registration)
     // Use getForUrl to validate credentials are for the current server URL
-    const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl)
+    const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
     if (entry?.clientInfo) {
       // Check if client secret has expired
       if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
@@ -79,15 +81,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
   }
 
   async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
-    await McpAuth.updateClientInfo(
-      this.mcpName,
-      {
-        clientId: info.client_id,
-        clientSecret: info.client_secret,
-        clientIdIssuedAt: info.client_id_issued_at,
-        clientSecretExpiresAt: info.client_secret_expires_at,
-      },
-      this.serverUrl,
+    await Effect.runPromise(
+      this.auth.updateClientInfo(
+        this.mcpName,
+        {
+          clientId: info.client_id,
+          clientSecret: info.client_secret,
+          clientIdIssuedAt: info.client_id_issued_at,
+          clientSecretExpiresAt: info.client_secret_expires_at,
+        },
+        this.serverUrl,
+      ),
     )
     log.info("saved dynamically registered client", {
       mcpName: this.mcpName,
@@ -97,7 +101,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
 
   async tokens(): Promise<OAuthTokens | undefined> {
     // Use getForUrl to validate tokens are for the current server URL
-    const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl)
+    const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
     if (!entry?.tokens) return undefined
 
     return {
@@ -112,15 +116,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
   }
 
   async saveTokens(tokens: OAuthTokens): Promise<void> {
-    await McpAuth.updateTokens(
-      this.mcpName,
-      {
-        accessToken: tokens.access_token,
-        refreshToken: tokens.refresh_token,
-        expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
-        scope: tokens.scope,
-      },
-      this.serverUrl,
+    await Effect.runPromise(
+      this.auth.updateTokens(
+        this.mcpName,
+        {
+          accessToken: tokens.access_token,
+          refreshToken: tokens.refresh_token,
+          expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
+          scope: tokens.scope,
+        },
+        this.serverUrl,
+      ),
     )
     log.info("saved oauth tokens", { mcpName: this.mcpName })
   }
@@ -131,11 +137,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
   }
 
   async saveCodeVerifier(codeVerifier: string): Promise<void> {
-    await McpAuth.updateCodeVerifier(this.mcpName, codeVerifier)
+    await Effect.runPromise(this.auth.updateCodeVerifier(this.mcpName, codeVerifier))
   }
 
   async codeVerifier(): Promise<string> {
-    const entry = await McpAuth.get(this.mcpName)
+    const entry = await Effect.runPromise(this.auth.get(this.mcpName))
     if (!entry?.codeVerifier) {
       throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`)
     }
@@ -143,11 +149,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
   }
 
   async saveState(state: string): Promise<void> {
-    await McpAuth.updateOAuthState(this.mcpName, state)
+    await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, state))
   }
 
   async state(): Promise<string> {
-    const entry = await McpAuth.get(this.mcpName)
+    const entry = await Effect.runPromise(this.auth.get(this.mcpName))
     if (entry?.oauthState) {
       return entry.oauthState
     }
@@ -159,28 +165,28 @@ export class McpOAuthProvider implements OAuthClientProvider {
     const newState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
       .map((b) => b.toString(16).padStart(2, "0"))
       .join("")
-    await McpAuth.updateOAuthState(this.mcpName, newState)
+    await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, newState))
     return newState
   }
 
   async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
     log.info("invalidating credentials", { mcpName: this.mcpName, type })
-    const entry = await McpAuth.get(this.mcpName)
+    const entry = await Effect.runPromise(this.auth.get(this.mcpName))
     if (!entry) {
       return
     }
 
     switch (type) {
       case "all":
-        await McpAuth.remove(this.mcpName)
+        await Effect.runPromise(this.auth.remove(this.mcpName))
         break
       case "client":
         delete entry.clientInfo
-        await McpAuth.set(this.mcpName, entry)
+        await Effect.runPromise(this.auth.set(this.mcpName, entry))
         break
       case "tokens":
         delete entry.tokens
-        await McpAuth.set(this.mcpName, entry)
+        await Effect.runPromise(this.auth.set(this.mcpName, entry))
         break
     }
   }

+ 0 - 2
packages/opencode/src/project/instance.ts

@@ -21,8 +21,6 @@ const disposal = {
   all: undefined as Promise<void> | undefined,
 }
 
-function emitDisposed(directory: string) {}
-
 function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
   return iife(async () => {
     const ctx =

+ 7 - 1
packages/opencode/src/server/instance/event.ts

@@ -1,8 +1,10 @@
+import z from "zod"
 import { Hono } from "hono"
 import { describeRoute, resolver } from "hono-openapi"
 import { streamSSE } from "hono/streaming"
 import { Log } from "@/util/log"
 import { BusEvent } from "@/bus/bus-event"
+import { SyncEvent } from "@/sync"
 import { Bus } from "@/bus"
 import { AsyncQueue } from "../../util/queue"
 
@@ -20,7 +22,11 @@ export const EventRoutes = () =>
           description: "Event stream",
           content: {
             "text/event-stream": {
-              schema: resolver(BusEvent.payloads()),
+              schema: resolver(
+                z.union(BusEvent.payloads()).meta({
+                  ref: "Event",
+                }),
+              ),
             },
           },
         },

+ 1 - 47
packages/opencode/src/server/instance/global.ts

@@ -109,7 +109,7 @@ export const GlobalRoutes = lazy(() =>
                       directory: z.string(),
                       project: z.string().optional(),
                       workspace: z.string().optional(),
-                      payload: BusEvent.payloads(),
+                      payload: z.union([...BusEvent.payloads(), ...SyncEvent.payloads()]),
                     })
                     .meta({
                       ref: "GlobalEvent",
@@ -135,52 +135,6 @@ export const GlobalRoutes = lazy(() =>
         })
       },
     )
-    .get(
-      "/sync-event",
-      describeRoute({
-        summary: "Subscribe to global sync events",
-        description: "Get global sync events",
-        operationId: "global.sync-event.subscribe",
-        responses: {
-          200: {
-            description: "Event stream",
-            content: {
-              "text/event-stream": {
-                schema: resolver(
-                  z
-                    .object({
-                      payload: SyncEvent.payloads(),
-                    })
-                    .meta({
-                      ref: "SyncEvent",
-                    }),
-                ),
-              },
-            },
-          },
-        },
-      }),
-      async (c) => {
-        log.info("global sync event connected")
-        c.header("Cache-Control", "no-cache, no-transform")
-        c.header("X-Accel-Buffering", "no")
-        c.header("X-Content-Type-Options", "nosniff")
-        return streamEvents(c, (q) => {
-          return SyncEvent.subscribeAll(({ def, event }) => {
-            // TODO: don't pass def, just pass the type (and it should
-            // be versioned)
-            q.push(
-              JSON.stringify({
-                payload: {
-                  ...event,
-                  type: SyncEvent.versionedType(def.type, def.version),
-                },
-              }),
-            )
-          })
-        })
-      },
-    )
     .get(
       "/config",
       describeRoute({

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

@@ -207,7 +207,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
         },
       }),
       async (c) => {
-        const modes = await Agent.list()
+        const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
         return c.json(modes)
       },
     )

+ 49 - 30
packages/opencode/src/server/instance/session.ts

@@ -474,10 +474,14 @@ export const SessionRoutes = lazy(() =>
       async (c) => {
         const query = c.req.valid("query")
         const params = c.req.valid("param")
-        const result = await SessionSummary.diff({
-          sessionID: params.sessionID,
-          messageID: query.messageID,
-        })
+        const result = await AppRuntime.runPromise(
+          SessionSummary.Service.use((summary) =>
+            summary.diff({
+              sessionID: params.sessionID,
+              messageID: query.messageID,
+            }),
+          ),
+        )
         return c.json(result)
       },
     )
@@ -547,27 +551,38 @@ export const SessionRoutes = lazy(() =>
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
         const body = c.req.valid("json")
-        const session = await Session.get(sessionID)
-        await SessionRevert.cleanup(session)
-        const msgs = await Session.messages({ sessionID })
-        let currentAgent = await Agent.defaultAgent()
-        for (let i = msgs.length - 1; i >= 0; i--) {
-          const info = msgs[i].info
-          if (info.role === "user") {
-            currentAgent = info.agent || (await Agent.defaultAgent())
-            break
-          }
-        }
-        await SessionCompaction.create({
-          sessionID,
-          agent: currentAgent,
-          model: {
-            providerID: body.providerID,
-            modelID: body.modelID,
-          },
-          auto: body.auto,
-        })
-        await SessionPrompt.loop({ sessionID })
+        await AppRuntime.runPromise(
+          Effect.gen(function* () {
+            const session = yield* Session.Service
+            const revert = yield* SessionRevert.Service
+            const compact = yield* SessionCompaction.Service
+            const prompt = yield* SessionPrompt.Service
+            const agent = yield* Agent.Service
+
+            yield* revert.cleanup(yield* session.get(sessionID))
+            const msgs = yield* session.messages({ sessionID })
+            const defaultAgent = yield* agent.defaultAgent()
+            let currentAgent = defaultAgent
+            for (let i = msgs.length - 1; i >= 0; i--) {
+              const info = msgs[i].info
+              if (info.role === "user") {
+                currentAgent = info.agent || defaultAgent
+                break
+              }
+            }
+
+            yield* compact.create({
+              sessionID,
+              agent: currentAgent,
+              model: {
+                providerID: body.providerID,
+                modelID: body.modelID,
+              },
+              auto: body.auto,
+            })
+            yield* prompt.loop({ sessionID })
+          }),
+        )
         return c.json(true)
       },
     )
@@ -985,10 +1000,14 @@ export const SessionRoutes = lazy(() =>
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
         log.info("revert", c.req.valid("json"))
-        const session = await SessionRevert.revert({
-          sessionID,
-          ...c.req.valid("json"),
-        })
+        const session = await AppRuntime.runPromise(
+          SessionRevert.Service.use((svc) =>
+            svc.revert({
+              sessionID,
+              ...c.req.valid("json"),
+            }),
+          ),
+        )
         return c.json(session)
       },
     )
@@ -1018,7 +1037,7 @@ export const SessionRoutes = lazy(() =>
       ),
       async (c) => {
         const sessionID = c.req.valid("param").sessionID
-        const session = await SessionRevert.unrevert({ sessionID })
+        const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID })))
         return c.json(session)
       },
     )

+ 11 - 5
packages/opencode/src/session/processor.ts

@@ -1,4 +1,4 @@
-import { Cause, Deferred, Effect, Layer, Context } from "effect"
+import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect"
 import * as Stream from "effect/Stream"
 import { Agent } from "@/agent/agent"
 import { Bus } from "@/bus"
@@ -89,6 +89,7 @@ export namespace SessionProcessor {
     | LLM.Service
     | Permission.Service
     | Plugin.Service
+    | SessionSummary.Service
     | SessionStatus.Service
   > = Layer.effect(
     Service,
@@ -101,6 +102,8 @@ export namespace SessionProcessor {
       const llm = yield* LLM.Service
       const permission = yield* Permission.Service
       const plugin = yield* Plugin.Service
+      const summary = yield* SessionSummary.Service
+      const scope = yield* Scope.Scope
       const status = yield* SessionStatus.Service
 
       const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
@@ -385,10 +388,12 @@ export namespace SessionProcessor {
                 }
                 ctx.snapshot = undefined
               }
-              SessionSummary.summarize({
-                sessionID: ctx.sessionID,
-                messageID: ctx.assistantMessage.parentID,
-              })
+              yield* summary
+                .summarize({
+                  sessionID: ctx.sessionID,
+                  messageID: ctx.assistantMessage.parentID,
+                })
+                .pipe(Effect.ignore, Effect.forkIn(scope))
               if (
                 !ctx.assistantMessage.summary &&
                 isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
@@ -603,6 +608,7 @@ export namespace SessionProcessor {
       Layer.provide(LLM.defaultLayer),
       Layer.provide(Permission.defaultLayer),
       Layer.provide(Plugin.defaultLayer),
+      Layer.provide(SessionSummary.defaultLayer),
       Layer.provide(SessionStatus.defaultLayer),
       Layer.provide(Bus.layer),
       Layer.provide(Config.defaultLayer),

+ 4 - 3
packages/opencode/src/session/projectors.ts

@@ -1,10 +1,11 @@
-import { NotFoundError, eq, and } from "../storage/db"
+import { NotFoundError, eq, and, sql } from "../storage/db"
 import { SyncEvent } from "@/sync"
 import { Session } from "./index"
 import { MessageV2 } from "./message-v2"
-import { SessionTable, MessageTable, PartTable } from "./session.sql"
-import { ProjectTable } from "../project/project.sql"
+import { SessionTable, MessageTable, PartTable, SessionEntryTable } from "./session.sql"
 import { Log } from "../util/log"
+import { DateTime } from "effect"
+import { SessionEntry } from "@/v2/session-entry"
 
 const log = Log.create({ service: "session.projector" })
 

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

@@ -102,6 +102,7 @@ export namespace SessionPrompt {
       const instruction = yield* Instruction.Service
       const state = yield* SessionRunState.Service
       const revert = yield* SessionRevert.Service
+      const summary = yield* SessionSummary.Service
       const sys = yield* SystemPrompt.Service
       const llm = yield* LLM.Service
 
@@ -1444,7 +1445,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
                 })
               }
 
-              if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser.id })
+              if (step === 1)
+                yield* summary
+                  .summarize({ sessionID, messageID: lastUser.id })
+                  .pipe(Effect.ignore, Effect.forkIn(scope))
 
               if (step > 1 && lastFinished) {
                 for (const m of msgs) {
@@ -1692,6 +1696,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       Layer.provide(Plugin.defaultLayer),
       Layer.provide(Session.defaultLayer),
       Layer.provide(SessionRevert.defaultLayer),
+      Layer.provide(SessionSummary.defaultLayer),
       Layer.provide(
         Layer.mergeAll(
           Agent.defaultLayer,

+ 0 - 15
packages/opencode/src/session/revert.ts

@@ -1,6 +1,5 @@
 import z from "zod"
 import { Effect, Layer, Context } from "effect"
-import { makeRuntime } from "@/effect/run-service"
 import { Bus } from "../bus"
 import { Snapshot } from "../snapshot"
 import { Storage } from "@/storage/storage"
@@ -160,18 +159,4 @@ export namespace SessionRevert {
       Layer.provide(SessionSummary.defaultLayer),
     ),
   )
-
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export async function revert(input: RevertInput) {
-    return runPromise((svc) => svc.revert(input))
-  }
-
-  export async function unrevert(input: { sessionID: SessionID }) {
-    return runPromise((svc) => svc.unrevert(input))
-  }
-
-  export async function cleanup(session: Session.Info) {
-    return runPromise((svc) => svc.cleanup(session))
-  }
 }

+ 21 - 0
packages/opencode/src/session/session.sql.ts

@@ -1,6 +1,7 @@
 import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
 import { ProjectTable } from "../project/project.sql"
 import type { MessageV2 } from "./message-v2"
+import type { SessionEntry } from "../v2/session-entry"
 import type { Snapshot } from "../snapshot"
 import type { Permission } from "../permission"
 import type { ProjectID } from "../project/schema"
@@ -10,6 +11,7 @@ import { Timestamps } from "../storage/schema.sql"
 
 type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
 type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
+type EntryData = Omit<SessionEntry.Entry, "id" | "type">
 
 export const SessionTable = sqliteTable(
   "session",
@@ -94,6 +96,25 @@ export const TodoTable = sqliteTable(
   ],
 )
 
+export const SessionEntryTable = sqliteTable(
+  "session_entry",
+  {
+    id: text().$type<SessionEntry.ID>().primaryKey(),
+    session_id: text()
+      .$type<SessionID>()
+      .notNull()
+      .references(() => SessionTable.id, { onDelete: "cascade" }),
+    type: text().notNull(),
+    ...Timestamps,
+    data: text({ mode: "json" }).notNull().$type<Omit<SessionEntry.Entry, "type" | "id">>(),
+  },
+  (table) => [
+    index("session_entry_session_idx").on(table.session_id),
+    index("session_entry_session_type_idx").on(table.session_id, table.type),
+    index("session_entry_time_created_idx").on(table.time_created),
+  ],
+)
+
 export const PermissionTable = sqliteTable("permission", {
   project_id: text()
     .primaryKey()

+ 0 - 10
packages/opencode/src/session/summary.ts

@@ -1,6 +1,5 @@
 import z from "zod"
 import { Effect, Layer, Context } from "effect"
-import { makeRuntime } from "@/effect/run-service"
 import { Bus } from "@/bus"
 import { Snapshot } from "@/snapshot"
 import { Storage } from "@/storage/storage"
@@ -159,17 +158,8 @@ export namespace SessionSummary {
     ),
   )
 
-  const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export const summarize = (input: { sessionID: SessionID; messageID: MessageID }) =>
-    void runPromise((svc) => svc.summarize(input)).catch(() => {})
-
   export const DiffInput = z.object({
     sessionID: SessionID.zod,
     messageID: MessageID.zod.optional(),
   })
-
-  export async function diff(input: z.infer<typeof DiffInput>) {
-    return runPromise((svc) => svc.diff(input))
-  }
 }

+ 29 - 30
packages/opencode/src/sync/index.ts

@@ -2,9 +2,12 @@ import z from "zod"
 import type { ZodObject } from "zod"
 import { EventEmitter } from "events"
 import { Database, eq } from "@/storage/db"
+import { GlobalBus } from "@/bus/global"
 import { Bus as ProjectBus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
+import { Instance } from "@/project/instance"
 import { EventSequenceTable, EventTable } from "./event.sql"
+import { WorkspaceContext } from "@/control-plane/workspace-context"
 import { EventID } from "./schema"
 import { Flag } from "@/flag/flag"
 
@@ -37,8 +40,6 @@ export namespace SyncEvent {
   let frozen = false
   let convertEvent: (type: string, event: Event["data"]) => Promise<Record<string, unknown>> | Record<string, unknown>
 
-  const Bus = new EventEmitter<{ event: [{ def: Definition; event: Event }] }>()
-
   export function reset() {
     frozen = false
     projectors = undefined
@@ -140,11 +141,6 @@ export namespace SyncEvent {
       }
 
       Database.effect(() => {
-        Bus.emit("event", {
-          def,
-          event,
-        })
-
         if (options?.publish) {
           const result = convertEvent(def.type, event.data)
           if (result instanceof Promise) {
@@ -154,6 +150,17 @@ export namespace SyncEvent {
           } else {
             ProjectBus.publish({ type: def.type, properties: def.schema }, result)
           }
+
+          GlobalBus.emit("event", {
+            directory: Instance.directory,
+            project: Instance.project.id,
+            workspace: WorkspaceContext.workspaceID,
+            payload: {
+              type: "sync",
+              name: versionedType(def.type, def.version),
+              ...event,
+            },
+          })
         }
       })
     })
@@ -235,31 +242,23 @@ export namespace SyncEvent {
     })
   }
 
-  export function subscribeAll(handler: (event: { def: Definition; event: Event }) => void) {
-    Bus.on("event", handler)
-    return () => Bus.off("event", handler)
-  }
-
   export function payloads() {
-    return z
-      .union(
-        registry
-          .entries()
-          .map(([type, def]) => {
-            return z
-              .object({
-                type: z.literal(type),
-                aggregate: z.literal(def.aggregate),
-                data: def.schema,
-              })
-              .meta({
-                ref: "SyncEvent" + "." + def.type,
-              })
+    return registry
+      .entries()
+      .map(([type, def]) => {
+        return z
+          .object({
+            type: z.literal("sync"),
+            name: z.literal(type),
+            id: z.string(),
+            seq: z.number(),
+            aggregateID: z.literal(def.aggregate),
+            data: def.schema,
+          })
+          .meta({
+            ref: "SyncEvent" + "." + def.type,
           })
-          .toArray() as any,
-      )
-      .meta({
-        ref: "SyncEvent",
       })
+      .toArray()
   }
 }

+ 3 - 2
packages/opencode/src/tool/registry.ts

@@ -121,6 +121,7 @@ export namespace ToolRegistry {
       const greptool = yield* GrepTool
       const patchtool = yield* ApplyPatchTool
       const skilltool = yield* SkillTool
+      const agent = yield* Agent.Service
 
       const state = yield* InstanceState.make<State>(
         Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -140,8 +141,8 @@ export namespace ToolRegistry {
                     worktree: ctx.worktree,
                   }
                   const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
-                  const agent = yield* Effect.promise(() => Agent.get(toolCtx.agent))
-                  const out = yield* truncate.output(result, {}, agent)
+                  const info = yield* agent.get(toolCtx.agent)
+                  const out = yield* truncate.output(result, {}, info)
                   return {
                     title: "",
                     output: out.truncated ? out.content : result,

+ 0 - 115
packages/opencode/src/v2/message.ts

@@ -1,115 +0,0 @@
-import { Identifier } from "@/id/id"
-import { withStatics } from "@/util/schema"
-import { DateTime, Effect, Schema } from "effect"
-
-export namespace Message {
-  export const ID = Schema.String.pipe(Schema.brand("Message.ID")).pipe(
-    withStatics((s) => ({
-      create: () => s.make(Identifier.ascending("message")),
-      prefix: "msg",
-    })),
-  )
-
-  export class Source extends Schema.Class<Source>("Message.Source")({
-    start: Schema.Number,
-    end: Schema.Number,
-    text: Schema.String,
-  }) {}
-
-  export class FileAttachment extends Schema.Class<FileAttachment>("Message.File.Attachment")({
-    uri: Schema.String,
-    mime: Schema.String,
-    name: Schema.String.pipe(Schema.optional),
-    description: Schema.String.pipe(Schema.optional),
-    source: Source.pipe(Schema.optional),
-  }) {
-    static create(url: string) {
-      return new FileAttachment({
-        uri: url,
-        mime: "text/plain",
-      })
-    }
-  }
-
-  export class AgentAttachment extends Schema.Class<AgentAttachment>("Message.Agent.Attachment")({
-    name: Schema.String,
-    source: Source.pipe(Schema.optional),
-  }) {}
-
-  export class User extends Schema.Class<User>("Message.User")({
-    id: ID,
-    type: Schema.Literal("user"),
-    text: Schema.String,
-    files: Schema.Array(FileAttachment).pipe(Schema.optional),
-    agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
-    time: Schema.Struct({
-      created: Schema.DateTimeUtc,
-    }),
-  }) {
-    static create(input: { text: User["text"]; files?: User["files"]; agents?: User["agents"] }) {
-      const msg = new User({
-        id: ID.create(),
-        type: "user",
-        ...input,
-        time: {
-          created: Effect.runSync(DateTime.now),
-        },
-      })
-      return msg
-    }
-  }
-
-  export class Synthetic extends Schema.Class<Synthetic>("Message.Synthetic")({
-    id: ID,
-    type: Schema.Literal("synthetic"),
-    text: Schema.String,
-    time: Schema.Struct({
-      created: Schema.DateTimeUtc,
-    }),
-  }) {}
-
-  export class Request extends Schema.Class<Request>("Message.Request")({
-    id: ID,
-    type: Schema.Literal("start"),
-    model: Schema.Struct({
-      id: Schema.String,
-      providerID: Schema.String,
-      variant: Schema.String.pipe(Schema.optional),
-    }),
-    time: Schema.Struct({
-      created: Schema.DateTimeUtc,
-    }),
-  }) {}
-
-  export class Text extends Schema.Class<Text>("Message.Text")({
-    id: ID,
-    type: Schema.Literal("text"),
-    text: Schema.String,
-    time: Schema.Struct({
-      created: Schema.DateTimeUtc,
-      completed: Schema.DateTimeUtc.pipe(Schema.optional),
-    }),
-  }) {}
-
-  export class Complete extends Schema.Class<Complete>("Message.Complete")({
-    id: ID,
-    type: Schema.Literal("complete"),
-    time: Schema.Struct({
-      created: Schema.DateTimeUtc,
-    }),
-    cost: Schema.Number,
-    tokens: Schema.Struct({
-      total: Schema.Number,
-      input: Schema.Number,
-      output: Schema.Number,
-      reasoning: Schema.Number,
-      cache: Schema.Struct({
-        read: Schema.Number,
-        write: Schema.Number,
-      }),
-    }),
-  }) {}
-
-  export const Info = Schema.Union([User, Text])
-  export type Info = Schema.Schema.Type<typeof Info>
-}

+ 186 - 0
packages/opencode/src/v2/session-entry.ts

@@ -0,0 +1,186 @@
+import { Identifier } from "@/id/id"
+import { withStatics } from "@/util/schema"
+import { DateTime, Effect, Schema } from "effect"
+
+export namespace SessionEntry {
+  export const ID = Schema.String.pipe(Schema.brand("Session.Entry.ID")).pipe(
+    withStatics((s) => ({
+      create: () => s.make(Identifier.ascending("entry")),
+      prefix: "ent",
+    })),
+  )
+  export type ID = Schema.Schema.Type<typeof ID>
+
+  const Base = {
+    id: ID,
+    metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
+    time: Schema.Struct({
+      created: Schema.DateTimeUtc,
+    }),
+  }
+
+  export class Source extends Schema.Class<Source>("Session.Entry.Source")({
+    start: Schema.Number,
+    end: Schema.Number,
+    text: Schema.String,
+  }) {}
+
+  export class FileAttachment extends Schema.Class<FileAttachment>("Session.Entry.File.Attachment")({
+    uri: Schema.String,
+    mime: Schema.String,
+    name: Schema.String.pipe(Schema.optional),
+    description: Schema.String.pipe(Schema.optional),
+    source: Source.pipe(Schema.optional),
+  }) {
+    static create(url: string) {
+      return new FileAttachment({
+        uri: url,
+        mime: "text/plain",
+      })
+    }
+  }
+
+  export class AgentAttachment extends Schema.Class<AgentAttachment>("Session.Entry.Agent.Attachment")({
+    name: Schema.String,
+    source: Source.pipe(Schema.optional),
+  }) {}
+
+  export class User extends Schema.Class<User>("Session.Entry.User")({
+    ...Base,
+    type: Schema.Literal("user"),
+    text: Schema.String,
+    files: Schema.Array(FileAttachment).pipe(Schema.optional),
+    agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
+  }) {
+    static create(input: { text: User["text"]; files?: User["files"]; agents?: User["agents"] }) {
+      const msg = new User({
+        id: ID.create(),
+        type: "user",
+        ...input,
+        time: {
+          created: Effect.runSync(DateTime.now),
+        },
+      })
+      return msg
+    }
+  }
+
+  export class Synthetic extends Schema.Class<Synthetic>("Session.Entry.Synthetic")({
+    ...Base,
+    type: Schema.Literal("synthetic"),
+    text: Schema.String,
+  }) {}
+
+  export class Request extends Schema.Class<Request>("Session.Entry.Request")({
+    ...Base,
+    type: Schema.Literal("start"),
+    model: Schema.Struct({
+      id: Schema.String,
+      providerID: Schema.String,
+      variant: Schema.String.pipe(Schema.optional),
+    }),
+  }) {}
+
+  export class Text extends Schema.Class<Text>("Session.Entry.Text")({
+    ...Base,
+    type: Schema.Literal("text"),
+    text: Schema.String,
+    time: Schema.Struct({
+      ...Base.time.fields,
+      completed: Schema.DateTimeUtc.pipe(Schema.optional),
+    }),
+  }) {}
+
+  export class Reasoning extends Schema.Class<Reasoning>("Session.Entry.Reasoning")({
+    ...Base,
+    type: Schema.Literal("reasoning"),
+    text: Schema.String,
+    time: Schema.Struct({
+      ...Base.time.fields,
+      completed: Schema.DateTimeUtc.pipe(Schema.optional),
+    }),
+  }) {}
+
+  export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
+    status: Schema.Literal("pending"),
+    input: Schema.Record(Schema.String, Schema.Unknown),
+    raw: Schema.String,
+  }) {}
+
+  export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Entry.ToolState.Running")({
+    status: Schema.Literal("running"),
+    input: Schema.Record(Schema.String, Schema.Unknown),
+    title: Schema.String.pipe(Schema.optional),
+    metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
+  }) {}
+
+  export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Session.Entry.ToolState.Completed")({
+    status: Schema.Literal("completed"),
+    input: Schema.Record(Schema.String, Schema.Unknown),
+    output: Schema.String,
+    title: Schema.String,
+    metadata: Schema.Record(Schema.String, Schema.Unknown),
+    attachments: Schema.Array(FileAttachment).pipe(Schema.optional),
+  }) {}
+
+  export class ToolStateError extends Schema.Class<ToolStateError>("Session.Entry.ToolState.Error")({
+    status: Schema.Literal("error"),
+    input: Schema.Record(Schema.String, Schema.Unknown),
+    error: Schema.String,
+    metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
+    time: Schema.Struct({
+      start: Schema.Number,
+      end: Schema.Number,
+    }),
+  }) {}
+
+  export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
+  export type ToolState = Schema.Schema.Type<typeof ToolState>
+
+  export class Tool extends Schema.Class<Tool>("Session.Entry.Tool")({
+    ...Base,
+    type: Schema.Literal("tool"),
+    callID: Schema.String,
+    name: Schema.String,
+    state: ToolState,
+    time: Schema.Struct({
+      ...Base.time.fields,
+      ran: Schema.DateTimeUtc.pipe(Schema.optional),
+      completed: Schema.DateTimeUtc.pipe(Schema.optional),
+      pruned: Schema.DateTimeUtc.pipe(Schema.optional),
+    }),
+  }) {}
+
+  export class Complete extends Schema.Class<Complete>("Session.Entry.Complete")({
+    ...Base,
+    type: Schema.Literal("complete"),
+    cost: Schema.Number,
+    reason: Schema.String,
+    tokens: Schema.Struct({
+      input: Schema.Number,
+      output: Schema.Number,
+      reasoning: Schema.Number,
+      cache: Schema.Struct({
+        read: Schema.Number,
+        write: Schema.Number,
+      }),
+    }),
+  }) {}
+
+  export class Retry extends Schema.Class<Retry>("Session.Entry.Retry")({
+    ...Base,
+    type: Schema.Literal("retry"),
+    attempt: Schema.Number,
+    error: Schema.String,
+  }) {}
+
+  export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
+    ...Base,
+    type: Schema.Literal("compaction"),
+    auto: Schema.Boolean,
+    overflow: Schema.Boolean.pipe(Schema.optional),
+  }) {}
+
+  export const Entry = Schema.Union([User, Synthetic, Request, Tool, Text, Reasoning, Complete, Retry, Compaction])
+  export type Entry = Schema.Schema.Type<typeof Entry>
+}

+ 4 - 4
packages/opencode/src/v2/session.ts

@@ -1,5 +1,5 @@
 import { Context, Layer, Schema, Effect } from "effect"
-import { Message } from "./message"
+import { SessionEntry } from "./session-entry"
 import { Struct } from "effect"
 import { Identifier } from "@/id/id"
 import { withStatics } from "@/util/schema"
@@ -12,8 +12,8 @@ export namespace SessionV2 {
   export type ID = Schema.Schema.Type<typeof ID>
 
   export class PromptInput extends Schema.Class<PromptInput>("Session.PromptInput")({
-    ...Struct.omit(Message.User.fields, ["time", "type"]),
-    id: Schema.optionalKey(Message.ID),
+    ...Struct.omit(SessionEntry.User.fields, ["time", "type"]),
+    id: Schema.optionalKey(SessionEntry.ID),
     sessionID: SessionV2.ID,
   }) {}
 
@@ -33,7 +33,7 @@ export namespace SessionV2 {
   export interface Interface {
     fromID: (id: SessionV2.ID) => Effect.Effect<Info>
     create: (input: CreateInput) => Effect.Effect<Info>
-    prompt: (input: PromptInput) => Effect.Effect<Message.User>
+    prompt: (input: PromptInput) => Effect.Effect<SessionEntry.User>
   }
 
   export class Service extends Context.Service<Service, Interface>()("Session.Service") {}

+ 48 - 41
packages/opencode/test/agent/agent.test.ts

@@ -1,6 +1,7 @@
 import { afterEach, test, expect } from "bun:test"
+import { Effect } from "effect"
 import path from "path"
-import { tmpdir } from "../fixture/fixture"
+import { provideInstance, tmpdir } from "../fixture/fixture"
 import { Instance } from "../../src/project/instance"
 import { Agent } from "../../src/agent/agent"
 import { Permission } from "../../src/permission"
@@ -11,6 +12,10 @@ function evalPerm(agent: Agent.Info | undefined, permission: string): Permission
   return Permission.evaluate(permission, "*", agent.permission).action
 }
 
+function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
+  return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
+}
+
 afterEach(async () => {
   await Instance.disposeAll()
 })
@@ -20,7 +25,7 @@ test("returns default native agents when no config", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const agents = await Agent.list()
+      const agents = await load(tmp.path, (svc) => svc.list())
       const names = agents.map((a) => a.name)
       expect(names).toContain("build")
       expect(names).toContain("plan")
@@ -38,7 +43,7 @@ test("build agent has correct default properties", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(build).toBeDefined()
       expect(build?.mode).toBe("primary")
       expect(build?.native).toBe(true)
@@ -53,7 +58,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const plan = await Agent.get("plan")
+      const plan = await load(tmp.path, (svc) => svc.get("plan"))
       expect(plan).toBeDefined()
       // Wildcard is denied
       expect(evalPerm(plan, "edit")).toBe("deny")
@@ -68,7 +73,7 @@ test("explore agent denies edit and write", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const explore = await Agent.get("explore")
+      const explore = await load(tmp.path, (svc) => svc.get("explore"))
       expect(explore).toBeDefined()
       expect(explore?.mode).toBe("subagent")
       expect(evalPerm(explore, "edit")).toBe("deny")
@@ -84,7 +89,7 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const explore = await Agent.get("explore")
+      const explore = await load(tmp.path, (svc) => svc.get("explore"))
       expect(explore).toBeDefined()
       expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
       expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
@@ -97,7 +102,7 @@ test("general agent denies todo tools", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const general = await Agent.get("general")
+      const general = await load(tmp.path, (svc) => svc.get("general"))
       expect(general).toBeDefined()
       expect(general?.mode).toBe("subagent")
       expect(general?.hidden).toBeUndefined()
@@ -111,7 +116,7 @@ test("compaction agent denies all permissions", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const compaction = await Agent.get("compaction")
+      const compaction = await load(tmp.path, (svc) => svc.get("compaction"))
       expect(compaction).toBeDefined()
       expect(compaction?.hidden).toBe(true)
       expect(evalPerm(compaction, "bash")).toBe("deny")
@@ -137,7 +142,7 @@ test("custom agent from config creates new agent", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const custom = await Agent.get("my_custom_agent")
+      const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent"))
       expect(custom).toBeDefined()
       expect(String(custom?.model?.providerID)).toBe("openai")
       expect(String(custom?.model?.modelID)).toBe("gpt-4")
@@ -166,7 +171,7 @@ test("custom agent config overrides native agent properties", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(build).toBeDefined()
       expect(String(build?.model?.providerID)).toBe("anthropic")
       expect(String(build?.model?.modelID)).toBe("claude-3")
@@ -189,9 +194,9 @@ test("agent disable removes agent from list", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const explore = await Agent.get("explore")
+      const explore = await load(tmp.path, (svc) => svc.get("explore"))
       expect(explore).toBeUndefined()
-      const agents = await Agent.list()
+      const agents = await load(tmp.path, (svc) => svc.list())
       const names = agents.map((a) => a.name)
       expect(names).not.toContain("explore")
     },
@@ -215,7 +220,7 @@ test("agent permission config merges with defaults", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(build).toBeDefined()
       // Specific pattern is denied
       expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
@@ -236,7 +241,7 @@ test("global permission config applies to all agents", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(build).toBeDefined()
       expect(evalPerm(build, "bash")).toBe("deny")
     },
@@ -255,8 +260,8 @@ test("agent steps/maxSteps config sets steps property", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
-      const plan = await Agent.get("plan")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
+      const plan = await load(tmp.path, (svc) => svc.get("plan"))
       expect(build?.steps).toBe(50)
       expect(plan?.steps).toBe(100)
     },
@@ -274,7 +279,7 @@ test("agent mode can be overridden", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const explore = await Agent.get("explore")
+      const explore = await load(tmp.path, (svc) => svc.get("explore"))
       expect(explore?.mode).toBe("primary")
     },
   })
@@ -291,7 +296,7 @@ test("agent name can be overridden", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(build?.name).toBe("Builder")
     },
   })
@@ -308,7 +313,7 @@ test("agent prompt can be set from config", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(build?.prompt).toBe("Custom system prompt")
     },
   })
@@ -328,7 +333,7 @@ test("unknown agent properties are placed into options", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(build?.options.random_property).toBe("hello")
       expect(build?.options.another_random).toBe(123)
     },
@@ -351,7 +356,7 @@ test("agent options merge correctly", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(build?.options.custom_option).toBe(true)
       expect(build?.options.another_option).toBe("value")
     },
@@ -376,8 +381,8 @@ test("multiple custom agents can be defined", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const agentA = await Agent.get("agent_a")
-      const agentB = await Agent.get("agent_b")
+      const agentA = await load(tmp.path, (svc) => svc.get("agent_a"))
+      const agentB = await load(tmp.path, (svc) => svc.get("agent_b"))
       expect(agentA?.description).toBe("Agent A")
       expect(agentA?.mode).toBe("subagent")
       expect(agentB?.description).toBe("Agent B")
@@ -405,7 +410,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const names = (await Agent.list()).map((a) => a.name)
+      const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name)
       expect(names[0]).toBe("plan")
       expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b)))
     },
@@ -417,7 +422,7 @@ test("Agent.get returns undefined for non-existent agent", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const nonExistent = await Agent.get("does_not_exist")
+      const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist"))
       expect(nonExistent).toBeUndefined()
     },
   })
@@ -428,7 +433,7 @@ test("default permission includes doom_loop and external_directory as ask", asyn
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(evalPerm(build, "doom_loop")).toBe("ask")
       expect(evalPerm(build, "external_directory")).toBe("ask")
     },
@@ -440,7 +445,7 @@ test("webfetch is allowed by default", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(evalPerm(build, "webfetch")).toBe("allow")
     },
   })
@@ -462,7 +467,7 @@ test("legacy tools config converts to permissions", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(evalPerm(build, "bash")).toBe("deny")
       expect(evalPerm(build, "read")).toBe("deny")
     },
@@ -484,7 +489,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(evalPerm(build, "edit")).toBe("deny")
     },
   })
@@ -502,7 +507,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
       expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
       expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
@@ -526,7 +531,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
       expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
       expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
@@ -549,7 +554,7 @@ test("explicit Truncate.GLOB deny is respected", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const build = await Agent.get("build")
+      const build = await load(tmp.path, (svc) => svc.get("build"))
       expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
       expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
     },
@@ -581,7 +586,7 @@ description: Permission skill.
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const build = await Agent.get("build")
+        const build = await load(tmp.path, (svc) => svc.get("build"))
         const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
         const target = path.join(skillDir, "reference", "notes.md")
         expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
@@ -597,7 +602,7 @@ test("defaultAgent returns build when no default_agent config", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const agent = await Agent.defaultAgent()
+      const agent = await load(tmp.path, (svc) => svc.defaultAgent())
       expect(agent).toBe("build")
     },
   })
@@ -612,7 +617,7 @@ test("defaultAgent respects default_agent config set to plan", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const agent = await Agent.defaultAgent()
+      const agent = await load(tmp.path, (svc) => svc.defaultAgent())
       expect(agent).toBe("plan")
     },
   })
@@ -632,7 +637,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const agent = await Agent.defaultAgent()
+      const agent = await load(tmp.path, (svc) => svc.defaultAgent())
       expect(agent).toBe("my_custom")
     },
   })
@@ -647,7 +652,7 @@ test("defaultAgent throws when default_agent points to subagent", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      await expect(Agent.defaultAgent()).rejects.toThrow('default agent "explore" is a subagent')
+      await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent')
     },
   })
 })
@@ -661,7 +666,7 @@ test("defaultAgent throws when default_agent points to hidden agent", async () =
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      await expect(Agent.defaultAgent()).rejects.toThrow('default agent "compaction" is hidden')
+      await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden')
     },
   })
 })
@@ -675,7 +680,9 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      await expect(Agent.defaultAgent()).rejects.toThrow('default agent "does_not_exist" not found')
+      await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow(
+        'default agent "does_not_exist" not found',
+      )
     },
   })
 })
@@ -691,7 +698,7 @@ test("defaultAgent returns plan when build is disabled and default_agent not set
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const agent = await Agent.defaultAgent()
+      const agent = await load(tmp.path, (svc) => svc.defaultAgent())
       // build is disabled, so it should return plan (next primary agent)
       expect(agent).toBe("plan")
     },
@@ -711,7 +718,7 @@ test("defaultAgent throws when all primary agents are disabled", async () => {
     directory: tmp.path,
     fn: async () => {
       // build and plan are disabled, no primary-capable agents remain
-      await expect(Agent.defaultAgent()).rejects.toThrow("no primary visible agent found")
+      await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow("no primary visible agent found")
     },
   })
 })

+ 6 - 3
packages/opencode/test/config/agent-color.test.ts

@@ -1,6 +1,7 @@
 import { test, expect } from "bun:test"
+import { Effect } from "effect"
 import path from "path"
-import { tmpdir } from "../fixture/fixture"
+import { provideInstance, tmpdir } from "../fixture/fixture"
 import { Instance } from "../../src/project/instance"
 import { Config } from "../../src/config/config"
 import { Agent as AgentSvc } from "../../src/agent/agent"
@@ -8,6 +9,8 @@ import { Color } from "../../src/util/color"
 import { AppRuntime } from "../../src/effect/app-runtime"
 
 const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
+const agent = <A>(dir: string, fn: (svc: AgentSvc.Interface) => Effect.Effect<A>) =>
+  Effect.runPromise(provideInstance(dir)(AgentSvc.Service.use(fn)).pipe(Effect.provide(AgentSvc.defaultLayer)))
 
 test("agent color parsed from project config", async () => {
   await using tmp = await tmpdir({
@@ -52,9 +55,9 @@ test("Agent.get includes color from config", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
-      const plan = await AgentSvc.get("plan")
+      const plan = await agent(tmp.path, (svc) => svc.get("plan"))
       expect(plan?.color).toBe("#A855F7")
-      const build = await AgentSvc.get("build")
+      const build = await agent(tmp.path, (svc) => svc.get("build"))
       expect(build?.color).toBe("accent")
     },
   })

+ 23 - 4
packages/opencode/test/mcp/oauth-auto-connect.test.ts

@@ -154,15 +154,22 @@ test("state() generates a new state when none is saved", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
+      const auth = await Effect.runPromise(
+        Effect.gen(function* () {
+          return yield* McpAuth.Service
+        }).pipe(Effect.provide(McpAuth.defaultLayer)),
+      )
       const provider = new McpOAuthProvider(
         "test-state-gen",
         "https://example.com/mcp",
         {},
         { onRedirect: async () => {} },
+        auth,
       )
 
-      // Ensure no state exists
-      const entryBefore = await McpAuth.get("test-state-gen")
+      const entryBefore = await Effect.runPromise(
+        McpAuth.Service.use((auth) => auth.get("test-state-gen")).pipe(Effect.provide(McpAuth.defaultLayer)),
+      )
       expect(entryBefore?.oauthState).toBeUndefined()
 
       // state() should generate and return a new state, not throw
@@ -171,7 +178,9 @@ test("state() generates a new state when none is saved", async () => {
       expect(state.length).toBe(64) // 32 bytes as hex
 
       // The generated state should be persisted
-      const entryAfter = await McpAuth.get("test-state-gen")
+      const entryAfter = await Effect.runPromise(
+        McpAuth.Service.use((auth) => auth.get("test-state-gen")).pipe(Effect.provide(McpAuth.defaultLayer)),
+      )
       expect(entryAfter?.oauthState).toBe(state)
     },
   })
@@ -186,16 +195,26 @@ test("state() returns existing state when one is saved", async () => {
   await Instance.provide({
     directory: tmp.path,
     fn: async () => {
+      const auth = await Effect.runPromise(
+        Effect.gen(function* () {
+          return yield* McpAuth.Service
+        }).pipe(Effect.provide(McpAuth.defaultLayer)),
+      )
       const provider = new McpOAuthProvider(
         "test-state-existing",
         "https://example.com/mcp",
         {},
         { onRedirect: async () => {} },
+        auth,
       )
 
       // Pre-save a state
       const existingState = "pre-saved-state-value"
-      await McpAuth.updateOAuthState("test-state-existing", existingState)
+      await Effect.runPromise(
+        McpAuth.Service.use((auth) => auth.updateOAuthState("test-state-existing", existingState)).pipe(
+          Effect.provide(McpAuth.defaultLayer),
+        ),
+      )
 
       // state() should return the existing state
       const state = await provider.state()

+ 11 - 1
packages/opencode/test/session/compaction.test.ts

@@ -18,6 +18,7 @@ import { Session } from "../../src/session"
 import { MessageV2 } from "../../src/session/message-v2"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
 import { SessionStatus } from "../../src/session/status"
+import { SessionSummary } from "../../src/session/summary"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import type { Provider } from "../../src/provider/provider"
 import * as SessionProcessorModule from "../../src/session/processor"
@@ -26,6 +27,15 @@ import { ProviderTest } from "../fake/provider"
 
 Log.init({ print: false })
 
+const summary = Layer.succeed(
+  SessionSummary.Service,
+  SessionSummary.Service.of({
+    summarize: () => Effect.void,
+    diff: () => Effect.succeed([]),
+    computeDiff: () => Effect.succeed([]),
+  }),
+)
+
 const ref = {
   providerID: ProviderID.make("test"),
   modelID: ModelID.make("test-model"),
@@ -194,7 +204,7 @@ function llm() {
 function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fake()) {
   const bus = Bus.layer
   const status = SessionStatus.layer.pipe(Layer.provide(bus))
-  const processor = SessionProcessorModule.SessionProcessor.layer
+  const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary))
   return ManagedRuntime.make(
     Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
       Layer.provide(provider.layer),

+ 14 - 1
packages/opencode/test/session/processor-effect.test.ts

@@ -16,6 +16,7 @@ import { MessageV2 } from "../../src/session/message-v2"
 import { SessionProcessor } from "../../src/session/processor"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
 import { SessionStatus } from "../../src/session/status"
+import { SessionSummary } from "../../src/session/summary"
 import { Snapshot } from "../../src/snapshot"
 import { Log } from "../../src/util/log"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -25,6 +26,15 @@ import { raw, reply, TestLLMServer } from "../lib/llm-server"
 
 Log.init({ print: false })
 
+const summary = Layer.succeed(
+  SessionSummary.Service,
+  SessionSummary.Service.of({
+    summarize: () => Effect.void,
+    diff: () => Effect.succeed([]),
+    computeDiff: () => Effect.succeed([]),
+  }),
+)
+
 const ref = {
   providerID: ProviderID.make("test"),
   modelID: ModelID.make("test-model"),
@@ -156,7 +166,10 @@ const deps = Layer.mergeAll(
   Provider.defaultLayer,
   status,
 ).pipe(Layer.provideMerge(infra))
-const env = Layer.mergeAll(TestLLMServer.layer, SessionProcessor.layer.pipe(Layer.provideMerge(deps)))
+const env = Layer.mergeAll(
+  TestLLMServer.layer,
+  SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)),
+)
 
 const it = testEffect(env)
 

+ 12 - 1
packages/opencode/test/session/prompt-effect.test.ts

@@ -23,6 +23,7 @@ import { LLM } from "../../src/session/llm"
 import { MessageV2 } from "../../src/session/message-v2"
 import { AppFileSystem } from "../../src/filesystem"
 import { SessionCompaction } from "../../src/session/compaction"
+import { SessionSummary } from "../../src/session/summary"
 import { Instruction } from "../../src/session/instruction"
 import { SessionProcessor } from "../../src/session/processor"
 import { SessionPrompt } from "../../src/session/prompt"
@@ -46,6 +47,15 @@ import { reply, TestLLMServer } from "../lib/llm-server"
 
 Log.init({ print: false })
 
+const summary = Layer.succeed(
+  SessionSummary.Service,
+  SessionSummary.Service.of({
+    summarize: () => Effect.void,
+    diff: () => Effect.succeed([]),
+    computeDiff: () => Effect.succeed([]),
+  }),
+)
+
 const ref = {
   providerID: ProviderID.make("test"),
   modelID: ModelID.make("test-model"),
@@ -182,12 +192,13 @@ function makeHttp() {
     Layer.provideMerge(deps),
   )
   const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
-  const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
+  const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps))
   const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
   return Layer.mergeAll(
     TestLLMServer.layer,
     SessionPrompt.layer.pipe(
       Layer.provide(SessionRevert.defaultLayer),
+      Layer.provide(summary),
       Layer.provideMerge(run),
       Layer.provideMerge(compact),
       Layer.provideMerge(proc),

+ 558 - 540
packages/opencode/test/session/revert-compact.test.ts

@@ -1,35 +1,47 @@
-import { describe, expect, test, beforeEach, afterEach } from "bun:test"
+import { describe, expect } from "bun:test"
 import fs from "fs/promises"
 import path from "path"
+import { Effect, Layer } from "effect"
 import { Session } from "../../src/session"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { SessionRevert } from "../../src/session/revert"
-import { SessionCompaction } from "../../src/session/compaction"
 import { MessageV2 } from "../../src/session/message-v2"
 import { Snapshot } from "../../src/snapshot"
 import { Log } from "../../src/util/log"
-import { Instance } from "../../src/project/instance"
-import { MessageID, PartID } from "../../src/session/schema"
-import { tmpdir } from "../fixture/fixture"
+import { MessageID, PartID, SessionID } from "../../src/session/schema"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { provideTmpdirInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
 
 Log.init({ print: false })
 
-function user(sessionID: string, agent = "default") {
-  return Session.updateMessage({
+const env = Layer.mergeAll(
+  Session.defaultLayer,
+  SessionRevert.defaultLayer,
+  Snapshot.defaultLayer,
+  CrossSpawnSpawner.defaultLayer,
+)
+
+const it = testEffect(env)
+
+const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "default") {
+  const session = yield* Session.Service
+  return yield* session.updateMessage({
     id: MessageID.ascending(),
     role: "user" as const,
-    sessionID: sessionID as any,
+    sessionID,
     agent,
     model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4") },
     time: { created: Date.now() },
   })
-}
+})
 
-function assistant(sessionID: string, parentID: string, dir: string) {
-  return Session.updateMessage({
+const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, parentID: MessageID, dir: string) {
+  const session = yield* Session.Service
+  return yield* session.updateMessage({
     id: MessageID.ascending(),
     role: "assistant" as const,
-    sessionID: sessionID as any,
+    sessionID,
     mode: "default",
     agent: "default",
     path: { cwd: dir, root: dir },
@@ -37,27 +49,29 @@ function assistant(sessionID: string, parentID: string, dir: string) {
     tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
     modelID: ModelID.make("gpt-4"),
     providerID: ProviderID.make("openai"),
-    parentID: parentID as any,
+    parentID,
     time: { created: Date.now() },
     finish: "end_turn",
   })
-}
+})
 
-function text(sessionID: string, messageID: string, content: string) {
-  return Session.updatePart({
+const text = Effect.fn("test.text")(function* (sessionID: SessionID, messageID: MessageID, content: string) {
+  const session = yield* Session.Service
+  return yield* session.updatePart({
     id: PartID.ascending(),
-    messageID: messageID as any,
-    sessionID: sessionID as any,
+    messageID,
+    sessionID,
     type: "text" as const,
     text: content,
   })
-}
+})
 
-function tool(sessionID: string, messageID: string) {
-  return Session.updatePart({
+const tool = Effect.fn("test.tool")(function* (sessionID: SessionID, messageID: MessageID) {
+  const session = yield* Session.Service
+  return yield* session.updatePart({
     id: PartID.ascending(),
-    messageID: messageID as any,
-    sessionID: sessionID as any,
+    messageID,
+    sessionID,
     type: "tool" as const,
     tool: "bash",
     callID: "call-1",
@@ -70,7 +84,10 @@ function tool(sessionID: string, messageID: string) {
       time: { start: 0, end: 1 },
     },
   })
-}
+})
+
+const read = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8"))
+const write = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text))
 
 const tokens = {
   input: 0,
@@ -80,542 +97,543 @@ const tokens = {
 }
 
 describe("revert + compact workflow", () => {
-  test("should properly handle compact command after revert", async () => {
-    await using tmp = await tmpdir({ git: true })
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        // Create a session
-        const session = await Session.create({})
-        const sessionID = session.id
-
-        // Create a user message
-        const userMsg1 = await Session.updateMessage({
-          id: MessageID.ascending(),
-          role: "user",
-          sessionID,
-          agent: "default",
-          model: {
-            providerID: ProviderID.make("openai"),
+  it.live(
+    "should properly handle compact command after revert",
+    provideTmpdirInstance(
+      (dir) =>
+        Effect.gen(function* () {
+          const session = yield* Session.Service
+          const revert = yield* SessionRevert.Service
+
+          const info = yield* session.create({})
+          const sessionID = info.id
+
+          const userMsg1 = yield* session.updateMessage({
+            id: MessageID.ascending(),
+            role: "user",
+            sessionID,
+            agent: "default",
+            model: {
+              providerID: ProviderID.make("openai"),
+              modelID: ModelID.make("gpt-4"),
+            },
+            time: {
+              created: Date.now(),
+            },
+          })
+
+          yield* session.updatePart({
+            id: PartID.ascending(),
+            messageID: userMsg1.id,
+            sessionID,
+            type: "text",
+            text: "Hello, please help me",
+          })
+
+          const assistantMsg1: MessageV2.Assistant = {
+            id: MessageID.ascending(),
+            role: "assistant",
+            sessionID,
+            mode: "default",
+            agent: "default",
+            path: {
+              cwd: dir,
+              root: dir,
+            },
+            cost: 0,
+            tokens: {
+              output: 0,
+              input: 0,
+              reasoning: 0,
+              cache: { read: 0, write: 0 },
+            },
             modelID: ModelID.make("gpt-4"),
-          },
-          time: {
-            created: Date.now(),
-          },
-        })
-
-        // Add a text part to the user message
-        await Session.updatePart({
-          id: PartID.ascending(),
-          messageID: userMsg1.id,
-          sessionID,
-          type: "text",
-          text: "Hello, please help me",
-        })
-
-        // Create an assistant response message
-        const assistantMsg1: MessageV2.Assistant = {
-          id: MessageID.ascending(),
-          role: "assistant",
-          sessionID,
-          mode: "default",
-          agent: "default",
-          path: {
-            cwd: tmp.path,
-            root: tmp.path,
-          },
-          cost: 0,
-          tokens: {
-            output: 0,
-            input: 0,
-            reasoning: 0,
-            cache: { read: 0, write: 0 },
-          },
-          modelID: ModelID.make("gpt-4"),
-          providerID: ProviderID.make("openai"),
-          parentID: userMsg1.id,
-          time: {
-            created: Date.now(),
-          },
-          finish: "end_turn",
-        }
-        await Session.updateMessage(assistantMsg1)
-
-        // Add a text part to the assistant message
-        await Session.updatePart({
-          id: PartID.ascending(),
-          messageID: assistantMsg1.id,
-          sessionID,
-          type: "text",
-          text: "Sure, I'll help you!",
-        })
-
-        // Create another user message
-        const userMsg2 = await Session.updateMessage({
-          id: MessageID.ascending(),
-          role: "user",
-          sessionID,
-          agent: "default",
-          model: {
             providerID: ProviderID.make("openai"),
-            modelID: ModelID.make("gpt-4"),
-          },
-          time: {
-            created: Date.now(),
-          },
-        })
-
-        await Session.updatePart({
-          id: PartID.ascending(),
-          messageID: userMsg2.id,
-          sessionID,
-          type: "text",
-          text: "What's the capital of France?",
-        })
-
-        // Create another assistant response
-        const assistantMsg2: MessageV2.Assistant = {
-          id: MessageID.ascending(),
-          role: "assistant",
-          sessionID,
-          mode: "default",
-          agent: "default",
-          path: {
-            cwd: tmp.path,
-            root: tmp.path,
-          },
-          cost: 0,
-          tokens: {
-            output: 0,
-            input: 0,
-            reasoning: 0,
-            cache: { read: 0, write: 0 },
-          },
-          modelID: ModelID.make("gpt-4"),
-          providerID: ProviderID.make("openai"),
-          parentID: userMsg2.id,
-          time: {
-            created: Date.now(),
-          },
-          finish: "end_turn",
-        }
-        await Session.updateMessage(assistantMsg2)
-
-        await Session.updatePart({
-          id: PartID.ascending(),
-          messageID: assistantMsg2.id,
-          sessionID,
-          type: "text",
-          text: "The capital of France is Paris.",
-        })
-
-        // Verify messages before revert
-        let messages = await Session.messages({ sessionID })
-        expect(messages.length).toBe(4) // 2 user + 2 assistant messages
-        const messageIds = messages.map((m) => m.info.id)
-        expect(messageIds).toContain(userMsg1.id)
-        expect(messageIds).toContain(userMsg2.id)
-        expect(messageIds).toContain(assistantMsg1.id)
-        expect(messageIds).toContain(assistantMsg2.id)
-
-        // Revert the last user message (userMsg2)
-        await SessionRevert.revert({
-          sessionID,
-          messageID: userMsg2.id,
-        })
-
-        // Check that revert state is set
-        let sessionInfo = await Session.get(sessionID)
-        expect(sessionInfo.revert).toBeDefined()
-        const revertMessageID = sessionInfo.revert?.messageID
-        expect(revertMessageID).toBeDefined()
-
-        // Messages should still be in the list (not removed yet, just marked for revert)
-        messages = await Session.messages({ sessionID })
-        expect(messages.length).toBe(4)
-
-        // Now clean up the revert state (this is what the compact endpoint should do)
-        await SessionRevert.cleanup(sessionInfo)
-
-        // After cleanup, the reverted messages (those after the revert point) should be removed
-        messages = await Session.messages({ sessionID })
-        const remainingIds = messages.map((m) => m.info.id)
-        // The revert point is somewhere in the message chain, so we should have fewer messages
-        expect(messages.length).toBeLessThan(4)
-        // userMsg2 and assistantMsg2 should be removed (they come after the revert point)
-        expect(remainingIds).not.toContain(userMsg2.id)
-        expect(remainingIds).not.toContain(assistantMsg2.id)
-
-        // Revert state should be cleared
-        sessionInfo = await Session.get(sessionID)
-        expect(sessionInfo.revert).toBeUndefined()
-
-        // Clean up
-        await Session.remove(sessionID)
-      },
-    })
-  })
+            parentID: userMsg1.id,
+            time: {
+              created: Date.now(),
+            },
+            finish: "end_turn",
+          }
+          yield* session.updateMessage(assistantMsg1)
+
+          yield* session.updatePart({
+            id: PartID.ascending(),
+            messageID: assistantMsg1.id,
+            sessionID,
+            type: "text",
+            text: "Sure, I'll help you!",
+          })
 
-  test("should properly clean up revert state before creating compaction message", async () => {
-    await using tmp = await tmpdir({ git: true })
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        // Create a session
-        const session = await Session.create({})
-        const sessionID = session.id
-
-        // Create initial messages
-        const userMsg = await Session.updateMessage({
-          id: MessageID.ascending(),
-          role: "user",
-          sessionID,
-          agent: "default",
-          model: {
-            providerID: ProviderID.make("openai"),
+          const userMsg2 = yield* session.updateMessage({
+            id: MessageID.ascending(),
+            role: "user",
+            sessionID,
+            agent: "default",
+            model: {
+              providerID: ProviderID.make("openai"),
+              modelID: ModelID.make("gpt-4"),
+            },
+            time: {
+              created: Date.now(),
+            },
+          })
+
+          yield* session.updatePart({
+            id: PartID.ascending(),
+            messageID: userMsg2.id,
+            sessionID,
+            type: "text",
+            text: "What's the capital of France?",
+          })
+
+          const assistantMsg2: MessageV2.Assistant = {
+            id: MessageID.ascending(),
+            role: "assistant",
+            sessionID,
+            mode: "default",
+            agent: "default",
+            path: {
+              cwd: dir,
+              root: dir,
+            },
+            cost: 0,
+            tokens: {
+              output: 0,
+              input: 0,
+              reasoning: 0,
+              cache: { read: 0, write: 0 },
+            },
             modelID: ModelID.make("gpt-4"),
-          },
-          time: {
-            created: Date.now(),
-          },
-        })
-
-        await Session.updatePart({
-          id: PartID.ascending(),
-          messageID: userMsg.id,
-          sessionID,
-          type: "text",
-          text: "Hello",
-        })
-
-        const assistantMsg: MessageV2.Assistant = {
-          id: MessageID.ascending(),
-          role: "assistant",
-          sessionID,
-          mode: "default",
-          agent: "default",
-          path: {
-            cwd: tmp.path,
-            root: tmp.path,
-          },
-          cost: 0,
-          tokens: {
-            output: 0,
-            input: 0,
-            reasoning: 0,
-            cache: { read: 0, write: 0 },
-          },
-          modelID: ModelID.make("gpt-4"),
-          providerID: ProviderID.make("openai"),
-          parentID: userMsg.id,
-          time: {
-            created: Date.now(),
-          },
-          finish: "end_turn",
-        }
-        await Session.updateMessage(assistantMsg)
-
-        await Session.updatePart({
-          id: PartID.ascending(),
-          messageID: assistantMsg.id,
-          sessionID,
-          type: "text",
-          text: "Hi there!",
-        })
-
-        // Revert the user message
-        await SessionRevert.revert({
-          sessionID,
-          messageID: userMsg.id,
-        })
-
-        // Check that revert state is set
-        let sessionInfo = await Session.get(sessionID)
-        expect(sessionInfo.revert).toBeDefined()
-
-        // Simulate what the compact endpoint does: cleanup revert before creating compaction
-        await SessionRevert.cleanup(sessionInfo)
-
-        // Verify revert state is cleared
-        sessionInfo = await Session.get(sessionID)
-        expect(sessionInfo.revert).toBeUndefined()
-
-        // Verify messages are properly cleaned up
-        const messages = await Session.messages({ sessionID })
-        expect(messages.length).toBe(0) // All messages should be reverted
-
-        // Clean up
-        await Session.remove(sessionID)
-      },
-    })
-  })
+            providerID: ProviderID.make("openai"),
+            parentID: userMsg2.id,
+            time: {
+              created: Date.now(),
+            },
+            finish: "end_turn",
+          }
+          yield* session.updateMessage(assistantMsg2)
+
+          yield* session.updatePart({
+            id: PartID.ascending(),
+            messageID: assistantMsg2.id,
+            sessionID,
+            type: "text",
+            text: "The capital of France is Paris.",
+          })
 
-  test("cleanup with partID removes parts from the revert point onward", async () => {
-    await using tmp = await tmpdir({ git: true })
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const session = await Session.create({})
-        const sid = session.id
-
-        const u1 = await user(sid)
-        const p1 = await text(sid, u1.id, "first part")
-        const p2 = await tool(sid, u1.id)
-        const p3 = await text(sid, u1.id, "third part")
-
-        // Set revert state pointing at a specific part
-        await Session.setRevert({
-          sessionID: sid,
-          revert: { messageID: u1.id, partID: p2.id },
-          summary: { additions: 0, deletions: 0, files: 0 },
-        })
-
-        const info = await Session.get(sid)
-        await SessionRevert.cleanup(info)
-
-        const msgs = await Session.messages({ sessionID: sid })
-        expect(msgs.length).toBe(1)
-        // Only the first part should remain (before the revert partID)
-        expect(msgs[0].parts.length).toBe(1)
-        expect(msgs[0].parts[0].id).toBe(p1.id)
-
-        const cleared = await Session.get(sid)
-        expect(cleared.revert).toBeUndefined()
-      },
-    })
-  })
+          let messages = yield* session.messages({ sessionID })
+          expect(messages.length).toBe(4)
+          const messageIds = messages.map((m) => m.info.id)
+          expect(messageIds).toContain(userMsg1.id)
+          expect(messageIds).toContain(userMsg2.id)
+          expect(messageIds).toContain(assistantMsg1.id)
+          expect(messageIds).toContain(assistantMsg2.id)
+
+          yield* revert.revert({
+            sessionID,
+            messageID: userMsg2.id,
+          })
 
-  test("cleanup removes messages after revert point but keeps earlier ones", async () => {
-    await using tmp = await tmpdir({ git: true })
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const session = await Session.create({})
-        const sid = session.id
-
-        const u1 = await user(sid)
-        await text(sid, u1.id, "hello")
-        const a1 = await assistant(sid, u1.id, tmp.path)
-        await text(sid, a1.id, "hi back")
-
-        const u2 = await user(sid)
-        await text(sid, u2.id, "second question")
-        const a2 = await assistant(sid, u2.id, tmp.path)
-        await text(sid, a2.id, "second answer")
-
-        // Revert from u2 onward
-        await Session.setRevert({
-          sessionID: sid,
-          revert: { messageID: u2.id },
-          summary: { additions: 0, deletions: 0, files: 0 },
-        })
-
-        const info = await Session.get(sid)
-        await SessionRevert.cleanup(info)
-
-        const msgs = await Session.messages({ sessionID: sid })
-        const ids = msgs.map((m) => m.info.id)
-        expect(ids).toContain(u1.id)
-        expect(ids).toContain(a1.id)
-        expect(ids).not.toContain(u2.id)
-        expect(ids).not.toContain(a2.id)
-      },
-    })
-  })
+          let sessionInfo = yield* session.get(sessionID)
+          expect(sessionInfo.revert).toBeDefined()
+          expect(sessionInfo.revert?.messageID).toBeDefined()
+
+          messages = yield* session.messages({ sessionID })
+          expect(messages.length).toBe(4)
+
+          yield* revert.cleanup(sessionInfo)
+
+          messages = yield* session.messages({ sessionID })
+          const remainingIds = messages.map((m) => m.info.id)
+          expect(messages.length).toBeLessThan(4)
+          expect(remainingIds).not.toContain(userMsg2.id)
+          expect(remainingIds).not.toContain(assistantMsg2.id)
+
+          sessionInfo = yield* session.get(sessionID)
+          expect(sessionInfo.revert).toBeUndefined()
+
+          yield* session.remove(sessionID)
+        }),
+      { git: true },
+    ),
+  )
+
+  it.live(
+    "should properly clean up revert state before creating compaction message",
+    provideTmpdirInstance(
+      (dir) =>
+        Effect.gen(function* () {
+          const session = yield* Session.Service
+          const revert = yield* SessionRevert.Service
+
+          const info = yield* session.create({})
+          const sessionID = info.id
+
+          const userMsg = yield* session.updateMessage({
+            id: MessageID.ascending(),
+            role: "user",
+            sessionID,
+            agent: "default",
+            model: {
+              providerID: ProviderID.make("openai"),
+              modelID: ModelID.make("gpt-4"),
+            },
+            time: {
+              created: Date.now(),
+            },
+          })
 
-  test("cleanup is a no-op when session has no revert state", async () => {
-    await using tmp = await tmpdir({ git: true })
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const session = await Session.create({})
-        const sid = session.id
-
-        const u1 = await user(sid)
-        await text(sid, u1.id, "hello")
-
-        const info = await Session.get(sid)
-        expect(info.revert).toBeUndefined()
-        await SessionRevert.cleanup(info)
-
-        const msgs = await Session.messages({ sessionID: sid })
-        expect(msgs.length).toBe(1)
-      },
-    })
-  })
+          yield* session.updatePart({
+            id: PartID.ascending(),
+            messageID: userMsg.id,
+            sessionID,
+            type: "text",
+            text: "Hello",
+          })
 
-  test("restore messages in sequential order", async () => {
-    await using tmp = await tmpdir({ git: true })
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        await fs.writeFile(path.join(tmp.path, "a.txt"), "a0")
-        await fs.writeFile(path.join(tmp.path, "b.txt"), "b0")
-        await fs.writeFile(path.join(tmp.path, "c.txt"), "c0")
-
-        const session = await Session.create({})
-        const sid = session.id
-
-        const turn = async (file: string, next: string) => {
-          const u = await user(sid)
-          await text(sid, u.id, `${file}:${next}`)
-          const a = await assistant(sid, u.id, tmp.path)
-          const before = await Snapshot.track()
-          if (!before) throw new Error("expected snapshot")
-          await fs.writeFile(path.join(tmp.path, file), next)
-          const after = await Snapshot.track()
-          if (!after) throw new Error("expected snapshot")
-          const patch = await Snapshot.patch(before)
-          await Session.updatePart({
+          const assistantMsg: MessageV2.Assistant = {
+            id: MessageID.ascending(),
+            role: "assistant",
+            sessionID,
+            mode: "default",
+            agent: "default",
+            path: {
+              cwd: dir,
+              root: dir,
+            },
+            cost: 0,
+            tokens: {
+              output: 0,
+              input: 0,
+              reasoning: 0,
+              cache: { read: 0, write: 0 },
+            },
+            modelID: ModelID.make("gpt-4"),
+            providerID: ProviderID.make("openai"),
+            parentID: userMsg.id,
+            time: {
+              created: Date.now(),
+            },
+            finish: "end_turn",
+          }
+          yield* session.updateMessage(assistantMsg)
+
+          yield* session.updatePart({
             id: PartID.ascending(),
-            messageID: a.id,
+            messageID: assistantMsg.id,
+            sessionID,
+            type: "text",
+            text: "Hi there!",
+          })
+
+          yield* revert.revert({
+            sessionID,
+            messageID: userMsg.id,
+          })
+
+          let sessionInfo = yield* session.get(sessionID)
+          expect(sessionInfo.revert).toBeDefined()
+
+          yield* revert.cleanup(sessionInfo)
+
+          sessionInfo = yield* session.get(sessionID)
+          expect(sessionInfo.revert).toBeUndefined()
+
+          const messages = yield* session.messages({ sessionID })
+          expect(messages.length).toBe(0)
+
+          yield* session.remove(sessionID)
+        }),
+      { git: true },
+    ),
+  )
+
+  it.live(
+    "cleanup with partID removes parts from the revert point onward",
+    provideTmpdirInstance(
+      () =>
+        Effect.gen(function* () {
+          const session = yield* Session.Service
+          const revert = yield* SessionRevert.Service
+
+          const info = yield* session.create({})
+          const sid = info.id
+
+          const u1 = yield* user(sid)
+          const p1 = yield* text(sid, u1.id, "first part")
+          const p2 = yield* tool(sid, u1.id)
+          yield* text(sid, u1.id, "third part")
+
+          yield* session.setRevert({
             sessionID: sid,
-            type: "step-start",
-            snapshot: before,
+            revert: { messageID: u1.id, partID: p2.id },
+            summary: { additions: 0, deletions: 0, files: 0 },
           })
-          await Session.updatePart({
-            id: PartID.ascending(),
-            messageID: a.id,
+
+          const state = yield* session.get(sid)
+          yield* revert.cleanup(state)
+
+          const msgs = yield* session.messages({ sessionID: sid })
+          expect(msgs.length).toBe(1)
+          expect(msgs[0].parts.length).toBe(1)
+          expect(msgs[0].parts[0].id).toBe(p1.id)
+
+          const cleared = yield* session.get(sid)
+          expect(cleared.revert).toBeUndefined()
+        }),
+      { git: true },
+    ),
+  )
+
+  it.live(
+    "cleanup removes messages after revert point but keeps earlier ones",
+    provideTmpdirInstance(
+      (dir) =>
+        Effect.gen(function* () {
+          const session = yield* Session.Service
+          const revert = yield* SessionRevert.Service
+
+          const info = yield* session.create({})
+          const sid = info.id
+
+          const u1 = yield* user(sid)
+          yield* text(sid, u1.id, "hello")
+          const a1 = yield* assistant(sid, u1.id, dir)
+          yield* text(sid, a1.id, "hi back")
+
+          const u2 = yield* user(sid)
+          yield* text(sid, u2.id, "second question")
+          const a2 = yield* assistant(sid, u2.id, dir)
+          yield* text(sid, a2.id, "second answer")
+
+          yield* session.setRevert({
             sessionID: sid,
-            type: "step-finish",
-            reason: "stop",
-            snapshot: after,
-            cost: 0,
-            tokens,
+            revert: { messageID: u2.id },
+            summary: { additions: 0, deletions: 0, files: 0 },
           })
-          await Session.updatePart({
-            id: PartID.ascending(),
-            messageID: a.id,
+
+          const state = yield* session.get(sid)
+          yield* revert.cleanup(state)
+
+          const msgs = yield* session.messages({ sessionID: sid })
+          const ids = msgs.map((m) => m.info.id)
+          expect(ids).toContain(u1.id)
+          expect(ids).toContain(a1.id)
+          expect(ids).not.toContain(u2.id)
+          expect(ids).not.toContain(a2.id)
+        }),
+      { git: true },
+    ),
+  )
+
+  it.live(
+    "cleanup is a no-op when session has no revert state",
+    provideTmpdirInstance(
+      () =>
+        Effect.gen(function* () {
+          const session = yield* Session.Service
+          const revert = yield* SessionRevert.Service
+
+          const info = yield* session.create({})
+          const sid = info.id
+
+          const u1 = yield* user(sid)
+          yield* text(sid, u1.id, "hello")
+
+          const state = yield* session.get(sid)
+          expect(state.revert).toBeUndefined()
+          yield* revert.cleanup(state)
+
+          const msgs = yield* session.messages({ sessionID: sid })
+          expect(msgs.length).toBe(1)
+        }),
+      { git: true },
+    ),
+  )
+
+  it.live(
+    "restore messages in sequential order",
+    provideTmpdirInstance(
+      (dir) =>
+        Effect.gen(function* () {
+          const session = yield* Session.Service
+          const revert = yield* SessionRevert.Service
+          const snapshot = yield* Snapshot.Service
+
+          yield* write(path.join(dir, "a.txt"), "a0")
+          yield* write(path.join(dir, "b.txt"), "b0")
+          yield* write(path.join(dir, "c.txt"), "c0")
+
+          const info = yield* session.create({})
+          const sid = info.id
+
+          const turn = Effect.fn("test.turn")(function* (file: string, next: string) {
+            const u = yield* user(sid)
+            yield* text(sid, u.id, `${file}:${next}`)
+            const a = yield* assistant(sid, u.id, dir)
+            const before = yield* snapshot.track()
+            if (!before) throw new Error("expected snapshot")
+            yield* write(path.join(dir, file), next)
+            const after = yield* snapshot.track()
+            if (!after) throw new Error("expected snapshot")
+            const patch = yield* snapshot.patch(before)
+            yield* session.updatePart({
+              id: PartID.ascending(),
+              messageID: a.id,
+              sessionID: sid,
+              type: "step-start",
+              snapshot: before,
+            })
+            yield* session.updatePart({
+              id: PartID.ascending(),
+              messageID: a.id,
+              sessionID: sid,
+              type: "step-finish",
+              reason: "stop",
+              snapshot: after,
+              cost: 0,
+              tokens,
+            })
+            yield* session.updatePart({
+              id: PartID.ascending(),
+              messageID: a.id,
+              sessionID: sid,
+              type: "patch",
+              hash: patch.hash,
+              files: patch.files,
+            })
+            return u.id
+          })
+
+          const first = yield* turn("a.txt", "a1")
+          const second = yield* turn("b.txt", "b2")
+          const third = yield* turn("c.txt", "c3")
+
+          yield* revert.revert({
             sessionID: sid,
-            type: "patch",
-            hash: patch.hash,
-            files: patch.files,
+            messageID: first,
           })
-          return u.id
-        }
-
-        const first = await turn("a.txt", "a1")
-        const second = await turn("b.txt", "b2")
-        const third = await turn("c.txt", "c3")
-
-        await SessionRevert.revert({
-          sessionID: sid,
-          messageID: first,
-        })
-        expect((await Session.get(sid)).revert?.messageID).toBe(first)
-        expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a0")
-        expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b0")
-        expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0")
-
-        await SessionRevert.revert({
-          sessionID: sid,
-          messageID: second,
-        })
-        expect((await Session.get(sid)).revert?.messageID).toBe(second)
-        expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1")
-        expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b0")
-        expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0")
-
-        await SessionRevert.revert({
-          sessionID: sid,
-          messageID: third,
-        })
-        expect((await Session.get(sid)).revert?.messageID).toBe(third)
-        expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1")
-        expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b2")
-        expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c0")
-
-        await SessionRevert.unrevert({
-          sessionID: sid,
-        })
-        expect((await Session.get(sid)).revert).toBeUndefined()
-        expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1")
-        expect(await fs.readFile(path.join(tmp.path, "b.txt"), "utf-8")).toBe("b2")
-        expect(await fs.readFile(path.join(tmp.path, "c.txt"), "utf-8")).toBe("c3")
-      },
-    })
-  })
+          expect((yield* session.get(sid)).revert?.messageID).toBe(first)
+          expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
+          expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
+          expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
 
-  test("restore same file in sequential order", async () => {
-    await using tmp = await tmpdir({ git: true })
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        await fs.writeFile(path.join(tmp.path, "a.txt"), "a0")
-
-        const session = await Session.create({})
-        const sid = session.id
-
-        const turn = async (next: string) => {
-          const u = await user(sid)
-          await text(sid, u.id, `a.txt:${next}`)
-          const a = await assistant(sid, u.id, tmp.path)
-          const before = await Snapshot.track()
-          if (!before) throw new Error("expected snapshot")
-          await fs.writeFile(path.join(tmp.path, "a.txt"), next)
-          const after = await Snapshot.track()
-          if (!after) throw new Error("expected snapshot")
-          const patch = await Snapshot.patch(before)
-          await Session.updatePart({
-            id: PartID.ascending(),
-            messageID: a.id,
+          yield* revert.revert({
             sessionID: sid,
-            type: "step-start",
-            snapshot: before,
+            messageID: second,
           })
-          await Session.updatePart({
-            id: PartID.ascending(),
-            messageID: a.id,
+          expect((yield* session.get(sid)).revert?.messageID).toBe(second)
+          expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
+          expect(yield* read(path.join(dir, "b.txt"))).toBe("b0")
+          expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
+
+          yield* revert.revert({
             sessionID: sid,
-            type: "step-finish",
-            reason: "stop",
-            snapshot: after,
-            cost: 0,
-            tokens,
+            messageID: third,
           })
-          await Session.updatePart({
-            id: PartID.ascending(),
-            messageID: a.id,
+          expect((yield* session.get(sid)).revert?.messageID).toBe(third)
+          expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
+          expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
+          expect(yield* read(path.join(dir, "c.txt"))).toBe("c0")
+
+          yield* revert.unrevert({
             sessionID: sid,
-            type: "patch",
-            hash: patch.hash,
-            files: patch.files,
           })
-          return u.id
-        }
-
-        const first = await turn("a1")
-        const second = await turn("a2")
-        const third = await turn("a3")
-        expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a3")
-
-        await SessionRevert.revert({
-          sessionID: sid,
-          messageID: first,
-        })
-        expect((await Session.get(sid)).revert?.messageID).toBe(first)
-        expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a0")
-
-        await SessionRevert.revert({
-          sessionID: sid,
-          messageID: second,
-        })
-        expect((await Session.get(sid)).revert?.messageID).toBe(second)
-        expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a1")
-
-        await SessionRevert.revert({
-          sessionID: sid,
-          messageID: third,
-        })
-        expect((await Session.get(sid)).revert?.messageID).toBe(third)
-        expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a2")
-
-        await SessionRevert.unrevert({
-          sessionID: sid,
-        })
-        expect((await Session.get(sid)).revert).toBeUndefined()
-        expect(await fs.readFile(path.join(tmp.path, "a.txt"), "utf-8")).toBe("a3")
-      },
-    })
-  })
+          expect((yield* session.get(sid)).revert).toBeUndefined()
+          expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
+          expect(yield* read(path.join(dir, "b.txt"))).toBe("b2")
+          expect(yield* read(path.join(dir, "c.txt"))).toBe("c3")
+        }),
+      { git: true },
+    ),
+  )
+
+  it.live(
+    "restore same file in sequential order",
+    provideTmpdirInstance(
+      (dir) =>
+        Effect.gen(function* () {
+          const session = yield* Session.Service
+          const revert = yield* SessionRevert.Service
+          const snapshot = yield* Snapshot.Service
+
+          yield* write(path.join(dir, "a.txt"), "a0")
+
+          const info = yield* session.create({})
+          const sid = info.id
+
+          const turn = Effect.fn("test.turnSame")(function* (next: string) {
+            const u = yield* user(sid)
+            yield* text(sid, u.id, `a.txt:${next}`)
+            const a = yield* assistant(sid, u.id, dir)
+            const before = yield* snapshot.track()
+            if (!before) throw new Error("expected snapshot")
+            yield* write(path.join(dir, "a.txt"), next)
+            const after = yield* snapshot.track()
+            if (!after) throw new Error("expected snapshot")
+            const patch = yield* snapshot.patch(before)
+            yield* session.updatePart({
+              id: PartID.ascending(),
+              messageID: a.id,
+              sessionID: sid,
+              type: "step-start",
+              snapshot: before,
+            })
+            yield* session.updatePart({
+              id: PartID.ascending(),
+              messageID: a.id,
+              sessionID: sid,
+              type: "step-finish",
+              reason: "stop",
+              snapshot: after,
+              cost: 0,
+              tokens,
+            })
+            yield* session.updatePart({
+              id: PartID.ascending(),
+              messageID: a.id,
+              sessionID: sid,
+              type: "patch",
+              hash: patch.hash,
+              files: patch.files,
+            })
+            return u.id
+          })
+
+          const first = yield* turn("a1")
+          const second = yield* turn("a2")
+          const third = yield* turn("a3")
+          expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
+
+          yield* revert.revert({
+            sessionID: sid,
+            messageID: first,
+          })
+          expect((yield* session.get(sid)).revert?.messageID).toBe(first)
+          expect(yield* read(path.join(dir, "a.txt"))).toBe("a0")
+
+          yield* revert.revert({
+            sessionID: sid,
+            messageID: second,
+          })
+          expect((yield* session.get(sid)).revert?.messageID).toBe(second)
+          expect(yield* read(path.join(dir, "a.txt"))).toBe("a1")
+
+          yield* revert.revert({
+            sessionID: sid,
+            messageID: third,
+          })
+          expect((yield* session.get(sid)).revert?.messageID).toBe(third)
+          expect(yield* read(path.join(dir, "a.txt"))).toBe("a2")
+
+          yield* revert.unrevert({
+            sessionID: sid,
+          })
+          expect((yield* session.get(sid)).revert).toBeUndefined()
+          expect(yield* read(path.join(dir, "a.txt"))).toBe("a3")
+        }),
+      { git: true },
+    ),
+  )
 })

+ 6 - 3
packages/opencode/test/session/snapshot-tool-race.test.ts

@@ -146,12 +146,14 @@ function makeHttp() {
     Layer.provideMerge(deps),
   )
   const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
-  const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
+  const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(deps))
   const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))
   return Layer.mergeAll(
     TestLLMServer.layer,
+    SessionSummary.defaultLayer,
     SessionPrompt.layer.pipe(
       Layer.provide(SessionRevert.defaultLayer),
+      Layer.provide(SessionSummary.defaultLayer),
       Layer.provideMerge(run),
       Layer.provideMerge(compact),
       Layer.provideMerge(proc),
@@ -200,6 +202,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
     Effect.fnUntraced(function* ({ dir, llm }) {
       const prompt = yield* SessionPrompt.Service
       const sessions = yield* Session.Service
+      const summary = yield* SessionSummary.Service
 
       const session = yield* sessions.create({
         title: "snapshot race test",
@@ -244,9 +247,9 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () =>
       expect(tool?.state.status).toBe("completed")
 
       // Poll for diff — summarize() is fire-and-forget
-      let diff: Awaited<ReturnType<typeof SessionSummary.diff>> = []
+      let diff: Array<{ file: string }> = []
       for (let i = 0; i < 50; i++) {
-        diff = yield* Effect.promise(() => SessionSummary.diff({ sessionID: session.id }))
+        diff = yield* summary.diff({ sessionID: session.id })
         if (diff.length > 0) break
         yield* Effect.sleep("100 millis")
       }

+ 6 - 2
packages/opencode/test/session/system.test.ts

@@ -4,7 +4,11 @@ import { Effect } from "effect"
 import { Agent } from "../../src/agent/agent"
 import { Instance } from "../../src/project/instance"
 import { SystemPrompt } from "../../src/session/system"
-import { tmpdir } from "../fixture/fixture"
+import { provideInstance, tmpdir } from "../fixture/fixture"
+
+function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
+  return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
+}
 
 describe("session.system", () => {
   test("skills output is sorted by name and stable across calls", async () => {
@@ -38,7 +42,7 @@ description: ${description}
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const build = await Agent.get("build")
+          const build = await load(tmp.path, (svc) => svc.get("build"))
           const runSkills = Effect.gen(function* () {
             const svc = yield* SystemPrompt.Service
             return yield* svc.skills(build!)

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

@@ -51,7 +51,6 @@ import type {
   GlobalDisposeResponses,
   GlobalEventResponses,
   GlobalHealthResponses,
-  GlobalSyncEventSubscribeResponses,
   GlobalUpgradeErrors,
   GlobalUpgradeResponses,
   InstanceDisposeResponses,
@@ -237,20 +236,6 @@ class HeyApiRegistry<T> {
   }
 }
 
-export class SyncEvent extends HeyApiClient {
-  /**
-   * Subscribe to global sync events
-   *
-   * Get global sync events
-   */
-  public subscribe<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
-    return (options?.client ?? this.client).sse.get<GlobalSyncEventSubscribeResponses, unknown, ThrowOnError>({
-      url: "/global/sync-event",
-      ...options,
-    })
-  }
-}
-
 export class Config extends HeyApiClient {
   /**
    * Get global configuration
@@ -350,11 +335,6 @@ export class Global extends HeyApiClient {
     })
   }
 
-  private _syncEvent?: SyncEvent
-  get syncEvent(): SyncEvent {
-    return (this._syncEvent ??= new SyncEvent({ client: this.client }))
-  }
-
   private _config?: Config
   get config(): Config {
     return (this._config ??= new Config({ client: this.client }))

+ 141 - 88
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -971,64 +971,12 @@ export type EventSessionDeleted = {
   }
 }
 
-export type Event =
-  | EventProjectUpdated
-  | EventServerInstanceDisposed
-  | EventInstallationUpdated
-  | EventInstallationUpdateAvailable
-  | EventServerConnected
-  | EventGlobalDisposed
-  | EventFileEdited
-  | EventFileWatcherUpdated
-  | EventLspClientDiagnostics
-  | EventLspUpdated
-  | EventMessagePartDelta
-  | EventPermissionAsked
-  | EventPermissionReplied
-  | EventSessionDiff
-  | EventSessionError
-  | EventQuestionAsked
-  | EventQuestionReplied
-  | EventQuestionRejected
-  | EventTodoUpdated
-  | EventSessionStatus
-  | EventSessionIdle
-  | EventSessionCompacted
-  | EventTuiPromptAppend
-  | EventTuiCommandExecute
-  | EventTuiToastShow
-  | EventTuiSessionSelect
-  | EventMcpToolsChanged
-  | EventMcpBrowserOpenFailed
-  | EventCommandExecuted
-  | EventVcsBranchUpdated
-  | EventWorktreeReady
-  | EventWorktreeFailed
-  | EventPtyCreated
-  | EventPtyUpdated
-  | EventPtyExited
-  | EventPtyDeleted
-  | EventWorkspaceReady
-  | EventWorkspaceFailed
-  | EventWorkspaceStatus
-  | EventMessageUpdated
-  | EventMessageRemoved
-  | EventMessagePartUpdated
-  | EventMessagePartRemoved
-  | EventSessionCreated
-  | EventSessionUpdated
-  | EventSessionDeleted
-
-export type GlobalEvent = {
-  directory: string
-  project?: string
-  workspace?: string
-  payload: Event
-}
-
 export type SyncEventMessageUpdated = {
-  type: "message.updated.1"
-  aggregate: "sessionID"
+  type: "sync"
+  name: "message.updated.1"
+  id: string
+  seq: number
+  aggregateID: "sessionID"
   data: {
     sessionID: string
     info: Message
@@ -1036,8 +984,11 @@ export type SyncEventMessageUpdated = {
 }
 
 export type SyncEventMessageRemoved = {
-  type: "message.removed.1"
-  aggregate: "sessionID"
+  type: "sync"
+  name: "message.removed.1"
+  id: string
+  seq: number
+  aggregateID: "sessionID"
   data: {
     sessionID: string
     messageID: string
@@ -1045,8 +996,11 @@ export type SyncEventMessageRemoved = {
 }
 
 export type SyncEventMessagePartUpdated = {
-  type: "message.part.updated.1"
-  aggregate: "sessionID"
+  type: "sync"
+  name: "message.part.updated.1"
+  id: string
+  seq: number
+  aggregateID: "sessionID"
   data: {
     sessionID: string
     part: Part
@@ -1055,8 +1009,11 @@ export type SyncEventMessagePartUpdated = {
 }
 
 export type SyncEventMessagePartRemoved = {
-  type: "message.part.removed.1"
-  aggregate: "sessionID"
+  type: "sync"
+  name: "message.part.removed.1"
+  id: string
+  seq: number
+  aggregateID: "sessionID"
   data: {
     sessionID: string
     messageID: string
@@ -1065,8 +1022,11 @@ export type SyncEventMessagePartRemoved = {
 }
 
 export type SyncEventSessionCreated = {
-  type: "session.created.1"
-  aggregate: "sessionID"
+  type: "sync"
+  name: "session.created.1"
+  id: string
+  seq: number
+  aggregateID: "sessionID"
   data: {
     sessionID: string
     info: Session
@@ -1074,8 +1034,11 @@ export type SyncEventSessionCreated = {
 }
 
 export type SyncEventSessionUpdated = {
-  type: "session.updated.1"
-  aggregate: "sessionID"
+  type: "sync"
+  name: "session.updated.1"
+  id: string
+  seq: number
+  aggregateID: "sessionID"
   data: {
     sessionID: string
     info: {
@@ -1114,16 +1077,75 @@ export type SyncEventSessionUpdated = {
 }
 
 export type SyncEventSessionDeleted = {
-  type: "session.deleted.1"
-  aggregate: "sessionID"
+  type: "sync"
+  name: "session.deleted.1"
+  id: string
+  seq: number
+  aggregateID: "sessionID"
   data: {
     sessionID: string
     info: Session
   }
 }
 
-export type SyncEvent = {
-  payload: SyncEvent
+export type GlobalEvent = {
+  directory: string
+  project?: string
+  workspace?: string
+  payload:
+    | EventProjectUpdated
+    | EventServerInstanceDisposed
+    | EventInstallationUpdated
+    | EventInstallationUpdateAvailable
+    | EventServerConnected
+    | EventGlobalDisposed
+    | EventFileEdited
+    | EventFileWatcherUpdated
+    | EventLspClientDiagnostics
+    | EventLspUpdated
+    | EventMessagePartDelta
+    | EventPermissionAsked
+    | EventPermissionReplied
+    | EventSessionDiff
+    | EventSessionError
+    | EventQuestionAsked
+    | EventQuestionReplied
+    | EventQuestionRejected
+    | EventTodoUpdated
+    | EventSessionStatus
+    | EventSessionIdle
+    | EventSessionCompacted
+    | EventTuiPromptAppend
+    | EventTuiCommandExecute
+    | EventTuiToastShow
+    | EventTuiSessionSelect
+    | EventMcpToolsChanged
+    | EventMcpBrowserOpenFailed
+    | EventCommandExecuted
+    | EventVcsBranchUpdated
+    | EventWorktreeReady
+    | EventWorktreeFailed
+    | EventPtyCreated
+    | EventPtyUpdated
+    | EventPtyExited
+    | EventPtyDeleted
+    | EventWorkspaceReady
+    | EventWorkspaceFailed
+    | EventWorkspaceStatus
+    | EventMessageUpdated
+    | EventMessageRemoved
+    | EventMessagePartUpdated
+    | EventMessagePartRemoved
+    | EventSessionCreated
+    | EventSessionUpdated
+    | EventSessionDeleted
+    | SyncEventMessageUpdated
+    | SyncEventMessageRemoved
+    | SyncEventMessagePartUpdated
+    | SyncEventMessagePartRemoved
+    | SyncEventSessionCreated
+    | SyncEventSessionUpdated
+    | SyncEventSessionDeleted
 }
 
 /**
@@ -1982,6 +2004,54 @@ export type File = {
   status: "added" | "deleted" | "modified"
 }
 
+export type Event =
+  | EventProjectUpdated
+  | EventServerInstanceDisposed
+  | EventInstallationUpdated
+  | EventInstallationUpdateAvailable
+  | EventServerConnected
+  | EventGlobalDisposed
+  | EventFileEdited
+  | EventFileWatcherUpdated
+  | EventLspClientDiagnostics
+  | EventLspUpdated
+  | EventMessagePartDelta
+  | EventPermissionAsked
+  | EventPermissionReplied
+  | EventSessionDiff
+  | EventSessionError
+  | EventQuestionAsked
+  | EventQuestionReplied
+  | EventQuestionRejected
+  | EventTodoUpdated
+  | EventSessionStatus
+  | EventSessionIdle
+  | EventSessionCompacted
+  | EventTuiPromptAppend
+  | EventTuiCommandExecute
+  | EventTuiToastShow
+  | EventTuiSessionSelect
+  | EventMcpToolsChanged
+  | EventMcpBrowserOpenFailed
+  | EventCommandExecuted
+  | EventVcsBranchUpdated
+  | EventWorktreeReady
+  | EventWorktreeFailed
+  | EventPtyCreated
+  | EventPtyUpdated
+  | EventPtyExited
+  | EventPtyDeleted
+  | EventWorkspaceReady
+  | EventWorkspaceFailed
+  | EventWorkspaceStatus
+  | EventMessageUpdated
+  | EventMessageRemoved
+  | EventMessagePartUpdated
+  | EventMessagePartRemoved
+  | EventSessionCreated
+  | EventSessionUpdated
+  | EventSessionDeleted
+
 export type McpStatusConnected = {
   status: "connected"
 }
@@ -2113,23 +2183,6 @@ export type GlobalEventResponses = {
 
 export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses]
 
-export type GlobalSyncEventSubscribeData = {
-  body?: never
-  path?: never
-  query?: never
-  url: "/global/sync-event"
-}
-
-export type GlobalSyncEventSubscribeResponses = {
-  /**
-   * Event stream
-   */
-  200: SyncEvent
-}
-
-export type GlobalSyncEventSubscribeResponse =
-  GlobalSyncEventSubscribeResponses[keyof GlobalSyncEventSubscribeResponses]
-
 export type GlobalConfigGetData = {
   body?: never
   path?: never

+ 398 - 202
packages/sdk/openapi.json

@@ -66,31 +66,6 @@
         ]
       }
     },
-    "/global/sync-event": {
-      "get": {
-        "operationId": "global.sync-event.subscribe",
-        "summary": "Subscribe to global sync events",
-        "description": "Get global sync events",
-        "responses": {
-          "200": {
-            "description": "Event stream",
-            "content": {
-              "text/event-stream": {
-                "schema": {
-                  "$ref": "#/components/schemas/SyncEvent"
-                }
-              }
-            }
-          }
-        },
-        "x-codeSamples": [
-          {
-            "lang": "js",
-            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.sync-event.subscribe({\n  ...\n})"
-          }
-        ]
-      }
-    },
     "/global/config": {
       "get": {
         "operationId": "global.config.get",
@@ -9925,174 +9900,24 @@
         },
         "required": ["type", "properties"]
       },
-      "Event": {
-        "anyOf": [
-          {
-            "$ref": "#/components/schemas/Event.project.updated"
-          },
-          {
-            "$ref": "#/components/schemas/Event.server.instance.disposed"
-          },
-          {
-            "$ref": "#/components/schemas/Event.installation.updated"
-          },
-          {
-            "$ref": "#/components/schemas/Event.installation.update-available"
-          },
-          {
-            "$ref": "#/components/schemas/Event.server.connected"
-          },
-          {
-            "$ref": "#/components/schemas/Event.global.disposed"
-          },
-          {
-            "$ref": "#/components/schemas/Event.file.edited"
-          },
-          {
-            "$ref": "#/components/schemas/Event.file.watcher.updated"
-          },
-          {
-            "$ref": "#/components/schemas/Event.lsp.client.diagnostics"
-          },
-          {
-            "$ref": "#/components/schemas/Event.lsp.updated"
-          },
-          {
-            "$ref": "#/components/schemas/Event.message.part.delta"
-          },
-          {
-            "$ref": "#/components/schemas/Event.permission.asked"
-          },
-          {
-            "$ref": "#/components/schemas/Event.permission.replied"
-          },
-          {
-            "$ref": "#/components/schemas/Event.session.diff"
-          },
-          {
-            "$ref": "#/components/schemas/Event.session.error"
-          },
-          {
-            "$ref": "#/components/schemas/Event.question.asked"
-          },
-          {
-            "$ref": "#/components/schemas/Event.question.replied"
-          },
-          {
-            "$ref": "#/components/schemas/Event.question.rejected"
-          },
-          {
-            "$ref": "#/components/schemas/Event.todo.updated"
-          },
-          {
-            "$ref": "#/components/schemas/Event.session.status"
-          },
-          {
-            "$ref": "#/components/schemas/Event.session.idle"
-          },
-          {
-            "$ref": "#/components/schemas/Event.session.compacted"
-          },
-          {
-            "$ref": "#/components/schemas/Event.tui.prompt.append"
-          },
-          {
-            "$ref": "#/components/schemas/Event.tui.command.execute"
-          },
-          {
-            "$ref": "#/components/schemas/Event.tui.toast.show"
-          },
-          {
-            "$ref": "#/components/schemas/Event.tui.session.select"
-          },
-          {
-            "$ref": "#/components/schemas/Event.mcp.tools.changed"
-          },
-          {
-            "$ref": "#/components/schemas/Event.mcp.browser.open.failed"
-          },
-          {
-            "$ref": "#/components/schemas/Event.command.executed"
-          },
-          {
-            "$ref": "#/components/schemas/Event.vcs.branch.updated"
-          },
-          {
-            "$ref": "#/components/schemas/Event.worktree.ready"
-          },
-          {
-            "$ref": "#/components/schemas/Event.worktree.failed"
-          },
-          {
-            "$ref": "#/components/schemas/Event.pty.created"
-          },
-          {
-            "$ref": "#/components/schemas/Event.pty.updated"
-          },
-          {
-            "$ref": "#/components/schemas/Event.pty.exited"
-          },
-          {
-            "$ref": "#/components/schemas/Event.pty.deleted"
-          },
-          {
-            "$ref": "#/components/schemas/Event.workspace.ready"
-          },
-          {
-            "$ref": "#/components/schemas/Event.workspace.failed"
-          },
-          {
-            "$ref": "#/components/schemas/Event.workspace.status"
-          },
-          {
-            "$ref": "#/components/schemas/Event.message.updated"
-          },
-          {
-            "$ref": "#/components/schemas/Event.message.removed"
-          },
-          {
-            "$ref": "#/components/schemas/Event.message.part.updated"
-          },
-          {
-            "$ref": "#/components/schemas/Event.message.part.removed"
-          },
-          {
-            "$ref": "#/components/schemas/Event.session.created"
-          },
-          {
-            "$ref": "#/components/schemas/Event.session.updated"
-          },
-          {
-            "$ref": "#/components/schemas/Event.session.deleted"
-          }
-        ]
-      },
-      "GlobalEvent": {
-        "type": "object",
-        "properties": {
-          "directory": {
-            "type": "string"
-          },
-          "project": {
-            "type": "string"
-          },
-          "workspace": {
-            "type": "string"
-          },
-          "payload": {
-            "$ref": "#/components/schemas/Event"
-          }
-        },
-        "required": ["directory", "payload"]
-      },
       "SyncEvent.message.updated": {
         "type": "object",
         "properties": {
           "type": {
+            "type": "string",
+            "const": "sync"
+          },
+          "name": {
             "type": "string",
             "const": "message.updated.1"
           },
-          "aggregate": {
+          "id": {
+            "type": "string"
+          },
+          "seq": {
+            "type": "number"
+          },
+          "aggregateID": {
             "type": "string",
             "const": "sessionID"
           },
@@ -10110,16 +9935,26 @@
             "required": ["sessionID", "info"]
           }
         },
-        "required": ["type", "aggregate", "data"]
+        "required": ["type", "name", "id", "seq", "aggregateID", "data"]
       },
       "SyncEvent.message.removed": {
         "type": "object",
         "properties": {
           "type": {
+            "type": "string",
+            "const": "sync"
+          },
+          "name": {
             "type": "string",
             "const": "message.removed.1"
           },
-          "aggregate": {
+          "id": {
+            "type": "string"
+          },
+          "seq": {
+            "type": "number"
+          },
+          "aggregateID": {
             "type": "string",
             "const": "sessionID"
           },
@@ -10138,16 +9973,26 @@
             "required": ["sessionID", "messageID"]
           }
         },
-        "required": ["type", "aggregate", "data"]
+        "required": ["type", "name", "id", "seq", "aggregateID", "data"]
       },
       "SyncEvent.message.part.updated": {
         "type": "object",
         "properties": {
           "type": {
+            "type": "string",
+            "const": "sync"
+          },
+          "name": {
             "type": "string",
             "const": "message.part.updated.1"
           },
-          "aggregate": {
+          "id": {
+            "type": "string"
+          },
+          "seq": {
+            "type": "number"
+          },
+          "aggregateID": {
             "type": "string",
             "const": "sessionID"
           },
@@ -10168,16 +10013,26 @@
             "required": ["sessionID", "part", "time"]
           }
         },
-        "required": ["type", "aggregate", "data"]
+        "required": ["type", "name", "id", "seq", "aggregateID", "data"]
       },
       "SyncEvent.message.part.removed": {
         "type": "object",
         "properties": {
           "type": {
+            "type": "string",
+            "const": "sync"
+          },
+          "name": {
             "type": "string",
             "const": "message.part.removed.1"
           },
-          "aggregate": {
+          "id": {
+            "type": "string"
+          },
+          "seq": {
+            "type": "number"
+          },
+          "aggregateID": {
             "type": "string",
             "const": "sessionID"
           },
@@ -10200,16 +10055,26 @@
             "required": ["sessionID", "messageID", "partID"]
           }
         },
-        "required": ["type", "aggregate", "data"]
+        "required": ["type", "name", "id", "seq", "aggregateID", "data"]
       },
       "SyncEvent.session.created": {
         "type": "object",
         "properties": {
           "type": {
+            "type": "string",
+            "const": "sync"
+          },
+          "name": {
             "type": "string",
             "const": "session.created.1"
           },
-          "aggregate": {
+          "id": {
+            "type": "string"
+          },
+          "seq": {
+            "type": "number"
+          },
+          "aggregateID": {
             "type": "string",
             "const": "sessionID"
           },
@@ -10227,16 +10092,26 @@
             "required": ["sessionID", "info"]
           }
         },
-        "required": ["type", "aggregate", "data"]
+        "required": ["type", "name", "id", "seq", "aggregateID", "data"]
       },
       "SyncEvent.session.updated": {
         "type": "object",
         "properties": {
           "type": {
+            "type": "string",
+            "const": "sync"
+          },
+          "name": {
             "type": "string",
             "const": "session.updated.1"
           },
-          "aggregate": {
+          "id": {
+            "type": "string"
+          },
+          "seq": {
+            "type": "number"
+          },
+          "aggregateID": {
             "type": "string",
             "const": "sessionID"
           },
@@ -10479,16 +10354,26 @@
             "required": ["sessionID", "info"]
           }
         },
-        "required": ["type", "aggregate", "data"]
+        "required": ["type", "name", "id", "seq", "aggregateID", "data"]
       },
       "SyncEvent.session.deleted": {
         "type": "object",
         "properties": {
           "type": {
+            "type": "string",
+            "const": "sync"
+          },
+          "name": {
             "type": "string",
             "const": "session.deleted.1"
           },
-          "aggregate": {
+          "id": {
+            "type": "string"
+          },
+          "seq": {
+            "type": "number"
+          },
+          "aggregateID": {
             "type": "string",
             "const": "sessionID"
           },
@@ -10506,16 +10391,185 @@
             "required": ["sessionID", "info"]
           }
         },
-        "required": ["type", "aggregate", "data"]
+        "required": ["type", "name", "id", "seq", "aggregateID", "data"]
       },
-      "SyncEvent": {
+      "GlobalEvent": {
         "type": "object",
         "properties": {
+          "directory": {
+            "type": "string"
+          },
+          "project": {
+            "type": "string"
+          },
+          "workspace": {
+            "type": "string"
+          },
           "payload": {
-            "$ref": "#/components/schemas/SyncEvent"
+            "anyOf": [
+              {
+                "$ref": "#/components/schemas/Event.project.updated"
+              },
+              {
+                "$ref": "#/components/schemas/Event.server.instance.disposed"
+              },
+              {
+                "$ref": "#/components/schemas/Event.installation.updated"
+              },
+              {
+                "$ref": "#/components/schemas/Event.installation.update-available"
+              },
+              {
+                "$ref": "#/components/schemas/Event.server.connected"
+              },
+              {
+                "$ref": "#/components/schemas/Event.global.disposed"
+              },
+              {
+                "$ref": "#/components/schemas/Event.file.edited"
+              },
+              {
+                "$ref": "#/components/schemas/Event.file.watcher.updated"
+              },
+              {
+                "$ref": "#/components/schemas/Event.lsp.client.diagnostics"
+              },
+              {
+                "$ref": "#/components/schemas/Event.lsp.updated"
+              },
+              {
+                "$ref": "#/components/schemas/Event.message.part.delta"
+              },
+              {
+                "$ref": "#/components/schemas/Event.permission.asked"
+              },
+              {
+                "$ref": "#/components/schemas/Event.permission.replied"
+              },
+              {
+                "$ref": "#/components/schemas/Event.session.diff"
+              },
+              {
+                "$ref": "#/components/schemas/Event.session.error"
+              },
+              {
+                "$ref": "#/components/schemas/Event.question.asked"
+              },
+              {
+                "$ref": "#/components/schemas/Event.question.replied"
+              },
+              {
+                "$ref": "#/components/schemas/Event.question.rejected"
+              },
+              {
+                "$ref": "#/components/schemas/Event.todo.updated"
+              },
+              {
+                "$ref": "#/components/schemas/Event.session.status"
+              },
+              {
+                "$ref": "#/components/schemas/Event.session.idle"
+              },
+              {
+                "$ref": "#/components/schemas/Event.session.compacted"
+              },
+              {
+                "$ref": "#/components/schemas/Event.tui.prompt.append"
+              },
+              {
+                "$ref": "#/components/schemas/Event.tui.command.execute"
+              },
+              {
+                "$ref": "#/components/schemas/Event.tui.toast.show"
+              },
+              {
+                "$ref": "#/components/schemas/Event.tui.session.select"
+              },
+              {
+                "$ref": "#/components/schemas/Event.mcp.tools.changed"
+              },
+              {
+                "$ref": "#/components/schemas/Event.mcp.browser.open.failed"
+              },
+              {
+                "$ref": "#/components/schemas/Event.command.executed"
+              },
+              {
+                "$ref": "#/components/schemas/Event.vcs.branch.updated"
+              },
+              {
+                "$ref": "#/components/schemas/Event.worktree.ready"
+              },
+              {
+                "$ref": "#/components/schemas/Event.worktree.failed"
+              },
+              {
+                "$ref": "#/components/schemas/Event.pty.created"
+              },
+              {
+                "$ref": "#/components/schemas/Event.pty.updated"
+              },
+              {
+                "$ref": "#/components/schemas/Event.pty.exited"
+              },
+              {
+                "$ref": "#/components/schemas/Event.pty.deleted"
+              },
+              {
+                "$ref": "#/components/schemas/Event.workspace.ready"
+              },
+              {
+                "$ref": "#/components/schemas/Event.workspace.failed"
+              },
+              {
+                "$ref": "#/components/schemas/Event.workspace.status"
+              },
+              {
+                "$ref": "#/components/schemas/Event.message.updated"
+              },
+              {
+                "$ref": "#/components/schemas/Event.message.removed"
+              },
+              {
+                "$ref": "#/components/schemas/Event.message.part.updated"
+              },
+              {
+                "$ref": "#/components/schemas/Event.message.part.removed"
+              },
+              {
+                "$ref": "#/components/schemas/Event.session.created"
+              },
+              {
+                "$ref": "#/components/schemas/Event.session.updated"
+              },
+              {
+                "$ref": "#/components/schemas/Event.session.deleted"
+              },
+              {
+                "$ref": "#/components/schemas/SyncEvent.message.updated"
+              },
+              {
+                "$ref": "#/components/schemas/SyncEvent.message.removed"
+              },
+              {
+                "$ref": "#/components/schemas/SyncEvent.message.part.updated"
+              },
+              {
+                "$ref": "#/components/schemas/SyncEvent.message.part.removed"
+              },
+              {
+                "$ref": "#/components/schemas/SyncEvent.session.created"
+              },
+              {
+                "$ref": "#/components/schemas/SyncEvent.session.updated"
+              },
+              {
+                "$ref": "#/components/schemas/SyncEvent.session.deleted"
+              }
+            ]
           }
         },
-        "required": ["payload"]
+        "required": ["directory", "payload"]
       },
       "LogLevel": {
         "description": "Log level",
@@ -12608,6 +12662,148 @@
         },
         "required": ["path", "added", "removed", "status"]
       },
+      "Event": {
+        "anyOf": [
+          {
+            "$ref": "#/components/schemas/Event.project.updated"
+          },
+          {
+            "$ref": "#/components/schemas/Event.server.instance.disposed"
+          },
+          {
+            "$ref": "#/components/schemas/Event.installation.updated"
+          },
+          {
+            "$ref": "#/components/schemas/Event.installation.update-available"
+          },
+          {
+            "$ref": "#/components/schemas/Event.server.connected"
+          },
+          {
+            "$ref": "#/components/schemas/Event.global.disposed"
+          },
+          {
+            "$ref": "#/components/schemas/Event.file.edited"
+          },
+          {
+            "$ref": "#/components/schemas/Event.file.watcher.updated"
+          },
+          {
+            "$ref": "#/components/schemas/Event.lsp.client.diagnostics"
+          },
+          {
+            "$ref": "#/components/schemas/Event.lsp.updated"
+          },
+          {
+            "$ref": "#/components/schemas/Event.message.part.delta"
+          },
+          {
+            "$ref": "#/components/schemas/Event.permission.asked"
+          },
+          {
+            "$ref": "#/components/schemas/Event.permission.replied"
+          },
+          {
+            "$ref": "#/components/schemas/Event.session.diff"
+          },
+          {
+            "$ref": "#/components/schemas/Event.session.error"
+          },
+          {
+            "$ref": "#/components/schemas/Event.question.asked"
+          },
+          {
+            "$ref": "#/components/schemas/Event.question.replied"
+          },
+          {
+            "$ref": "#/components/schemas/Event.question.rejected"
+          },
+          {
+            "$ref": "#/components/schemas/Event.todo.updated"
+          },
+          {
+            "$ref": "#/components/schemas/Event.session.status"
+          },
+          {
+            "$ref": "#/components/schemas/Event.session.idle"
+          },
+          {
+            "$ref": "#/components/schemas/Event.session.compacted"
+          },
+          {
+            "$ref": "#/components/schemas/Event.tui.prompt.append"
+          },
+          {
+            "$ref": "#/components/schemas/Event.tui.command.execute"
+          },
+          {
+            "$ref": "#/components/schemas/Event.tui.toast.show"
+          },
+          {
+            "$ref": "#/components/schemas/Event.tui.session.select"
+          },
+          {
+            "$ref": "#/components/schemas/Event.mcp.tools.changed"
+          },
+          {
+            "$ref": "#/components/schemas/Event.mcp.browser.open.failed"
+          },
+          {
+            "$ref": "#/components/schemas/Event.command.executed"
+          },
+          {
+            "$ref": "#/components/schemas/Event.vcs.branch.updated"
+          },
+          {
+            "$ref": "#/components/schemas/Event.worktree.ready"
+          },
+          {
+            "$ref": "#/components/schemas/Event.worktree.failed"
+          },
+          {
+            "$ref": "#/components/schemas/Event.pty.created"
+          },
+          {
+            "$ref": "#/components/schemas/Event.pty.updated"
+          },
+          {
+            "$ref": "#/components/schemas/Event.pty.exited"
+          },
+          {
+            "$ref": "#/components/schemas/Event.pty.deleted"
+          },
+          {
+            "$ref": "#/components/schemas/Event.workspace.ready"
+          },
+          {
+            "$ref": "#/components/schemas/Event.workspace.failed"
+          },
+          {
+            "$ref": "#/components/schemas/Event.workspace.status"
+          },
+          {
+            "$ref": "#/components/schemas/Event.message.updated"
+          },
+          {
+            "$ref": "#/components/schemas/Event.message.removed"
+          },
+          {
+            "$ref": "#/components/schemas/Event.message.part.updated"
+          },
+          {
+            "$ref": "#/components/schemas/Event.message.part.removed"
+          },
+          {
+            "$ref": "#/components/schemas/Event.session.created"
+          },
+          {
+            "$ref": "#/components/schemas/Event.session.updated"
+          },
+          {
+            "$ref": "#/components/schemas/Event.session.deleted"
+          }
+        ]
+      },
       "MCPStatusConnected": {
         "type": "object",
         "properties": {