Просмотр исходного кода

Refactor v2 session events as schemas (#24512)

Dax 4 месяцев назад
Родитель
Сommit
a3bc5d35b0
62 измененных файлов с 7933 добавлено и 2912 удалено
  1. 1 0
      packages/core/src/flag/flag.ts
  2. 2 0
      packages/core/src/util/log.ts
  3. 17 0
      packages/opencode/migration/20260427172553_slow_nightmare/migration.sql
  4. 1481 0
      packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json
  5. 124 52
      packages/opencode/migration/20260428004200_add_session_path/snapshot.json
  6. 2 0
      packages/opencode/migration/20260501142318_next_venus/migration.sql
  7. 1511 0
      packages/opencode/migration/20260501142318_next_venus/snapshot.json
  8. 2 0
      packages/opencode/src/bus/bus-event.ts
  9. 12 2
      packages/opencode/src/bus/global.ts
  10. 20 5
      packages/opencode/src/bus/index.ts
  11. 24 21
      packages/opencode/src/cli/cmd/tui/app.tsx
  12. 10 2
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  13. 298 0
      packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx
  14. 1087 0
      packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx
  15. 3 0
      packages/opencode/src/cli/cmd/tui/plugin/internal.ts
  16. 3 0
      packages/opencode/src/server/routes/global.ts
  17. 5 3
      packages/opencode/src/server/routes/instance/event.ts
  18. 2 0
      packages/opencode/src/server/routes/instance/httpapi/api.ts
  19. 2 2
      packages/opencode/src/server/routes/instance/httpapi/event.ts
  20. 14 0
      packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts
  21. 69 0
      packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts
  22. 140 0
      packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts
  23. 3 2
      packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts
  24. 6 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts
  25. 60 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts
  26. 115 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts
  27. 2 0
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  28. 127 1
      packages/opencode/src/server/routes/instance/index.ts
  29. 23 1
      packages/opencode/src/session/compaction.ts
  30. 138 5
      packages/opencode/src/session/processor.ts
  31. 204 0
      packages/opencode/src/session/projectors-next.ts
  32. 4 1
      packages/opencode/src/session/projectors.ts
  33. 116 3
      packages/opencode/src/session/prompt.ts
  34. 16 9
      packages/opencode/src/session/session.sql.ts
  35. 29 0
      packages/opencode/src/session/session.ts
  36. 4 4
      packages/opencode/src/sync/index.ts
  37. 1 1
      packages/opencode/src/util/effect-zod.ts
  38. 53 0
      packages/opencode/src/v2/event.ts
  39. 10 0
      packages/opencode/src/v2/schema.ts
  40. 0 261
      packages/opencode/src/v2/session-entry-stepper.ts
  41. 0 220
      packages/opencode/src/v2/session-entry.ts
  42. 320 381
      packages/opencode/src/v2/session-event.ts
  43. 411 0
      packages/opencode/src/v2/session-message-updater.ts
  44. 178 0
      packages/opencode/src/v2/session-message.ts
  45. 36 0
      packages/opencode/src/v2/session-prompt.ts
  46. 256 46
      packages/opencode/src/v2/session.ts
  47. 18 0
      packages/opencode/src/v2/tool-output.ts
  48. 1 0
      packages/opencode/test/acp/event-subscription.test.ts
  49. 2 0
      packages/opencode/test/cli/tui/use-event.test.tsx
  50. 2 1
      packages/opencode/test/preload.ts
  51. 8 1
      packages/opencode/test/server/httpapi-bridge.test.ts
  52. 13 2
      packages/opencode/test/server/httpapi-event.test.ts
  53. 42 1
      packages/opencode/test/server/httpapi-session.test.ts
  54. 10 0
      packages/opencode/test/session/compaction.test.ts
  55. 44 0
      packages/opencode/test/session/prompt.test.ts
  56. 0 916
      packages/opencode/test/session/session-entry-stepper.test.ts
  57. 1 1
      packages/opencode/test/sync/index.test.ts
  58. 203 0
      packages/opencode/test/v2/session-message-updater.test.ts
  59. 1 1
      packages/sdk/js/script/build.ts
  60. 276 394
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  61. 240 573
      packages/sdk/js/src/v2/gen/types.gen.ts
  62. 131 0
      specs/v2/session-concepts-gap.md

+ 1 - 0
packages/core/src/flag/flag.ts

@@ -95,6 +95,7 @@ export const Flag = {
     truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") ||
     (!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
   OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
+  OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
 
   // Evaluated at access time (not module load) because tests, the CLI, and
   // external tooling set these env vars at runtime.

+ 2 - 0
packages/core/src/util/log.ts

@@ -1,3 +1,5 @@
+export * as Log from "./log"
+
 import path from "path"
 import fs from "fs/promises"
 import { createWriteStream } from "fs"

+ 17 - 0
packages/opencode/migration/20260427172553_slow_nightmare/migration.sql

@@ -0,0 +1,17 @@
+CREATE TABLE `session_message` (
+	`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_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+DROP INDEX IF EXISTS `session_entry_session_idx`;--> statement-breakpoint
+DROP INDEX IF EXISTS `session_entry_session_type_idx`;--> statement-breakpoint
+DROP INDEX IF EXISTS `session_entry_time_created_idx`;--> statement-breakpoint
+CREATE INDEX `session_message_session_idx` ON `session_message` (`session_id`);--> statement-breakpoint
+CREATE INDEX `session_message_session_type_idx` ON `session_message` (`session_id`,`type`);--> statement-breakpoint
+CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);--> statement-breakpoint
+DROP TABLE `session_entry`;

+ 1481 - 0
packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json

@@ -0,0 +1,1481 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "id": "61f807f9-6398-4067-be05-804acc2561bc",
+  "prevIds": [
+    "66cbe0d7-def0-451b-b88a-7608513a9b44"
+  ],
+  "ddl": [
+    {
+      "name": "account_state",
+      "entityType": "tables"
+    },
+    {
+      "name": "account",
+      "entityType": "tables"
+    },
+    {
+      "name": "control_account",
+      "entityType": "tables"
+    },
+    {
+      "name": "workspace",
+      "entityType": "tables"
+    },
+    {
+      "name": "project",
+      "entityType": "tables"
+    },
+    {
+      "name": "message",
+      "entityType": "tables"
+    },
+    {
+      "name": "part",
+      "entityType": "tables"
+    },
+    {
+      "name": "permission",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_message",
+      "entityType": "tables"
+    },
+    {
+      "name": "session",
+      "entityType": "tables"
+    },
+    {
+      "name": "todo",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_share",
+      "entityType": "tables"
+    },
+    {
+      "name": "event_sequence",
+      "entityType": "tables"
+    },
+    {
+      "name": "event",
+      "entityType": "tables"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active_account_id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active_org_id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "access_token",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "refresh_token",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "token_expiry",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "access_token",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "refresh_token",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "token_expiry",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "''",
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "branch",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "extra",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "worktree",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "vcs",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url_override",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_color",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_initialized",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "sandboxes",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "commands",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "message_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "parent_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "slug",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": 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_message_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "session"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_todo_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "todo"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_share_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_share"
+    },
+    {
+      "columns": [
+        "aggregate_id"
+      ],
+      "tableTo": "event_sequence",
+      "columnsTo": [
+        "aggregate_id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk",
+      "entityType": "fks",
+      "table": "event"
+    },
+    {
+      "columns": [
+        "email",
+        "url"
+      ],
+      "nameExplicit": false,
+      "name": "control_account_pk",
+      "entityType": "pks",
+      "table": "control_account"
+    },
+    {
+      "columns": [
+        "session_id",
+        "position"
+      ],
+      "nameExplicit": false,
+      "name": "todo_pk",
+      "entityType": "pks",
+      "table": "todo"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "account_state_pk",
+      "table": "account_state",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "account_pk",
+      "table": "account",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "workspace_pk",
+      "table": "workspace",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "project_pk",
+      "table": "project",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "message_pk",
+      "table": "message",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "part_pk",
+      "table": "part",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "nameExplicit": false,
+      "name": "permission_pk",
+      "table": "permission",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "session_message_pk",
+      "table": "session_message",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "session_pk",
+      "table": "session",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "nameExplicit": false,
+      "name": "session_share_pk",
+      "table": "session_share",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "aggregate_id"
+      ],
+      "nameExplicit": false,
+      "name": "event_sequence_pk",
+      "table": "event_sequence",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "event_pk",
+      "table": "event",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "time_created",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "message_session_time_created_id_idx",
+      "entityType": "indexes",
+      "table": "message"
+    },
+    {
+      "columns": [
+        {
+          "value": "message_id",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_message_id_id_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_session_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_session_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "type",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_session_type_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "time_created",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_time_created_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "project_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_project_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_workspace_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "parent_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_parent_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "todo_session_idx",
+      "entityType": "indexes",
+      "table": "todo"
+    }
+  ],
+  "renames": []
+}

+ 124 - 52
packages/opencode/migration/20260428004200_add_session_path/snapshot.json

@@ -2,7 +2,9 @@
   "version": "7",
   "dialect": "sqlite",
   "id": "aaa2ebeb-caa4-478d-8365-4fc595d16856",
-  "prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"],
+  "prevIds": [
+    "61f807f9-6398-4067-be05-804acc2561bc"
+  ],
   "ddl": [
     {
       "name": "account_state",
@@ -37,7 +39,7 @@
       "entityType": "tables"
     },
     {
-      "name": "session_entry",
+      "name": "session_message",
       "entityType": "tables"
     },
     {
@@ -598,7 +600,7 @@
       "generated": null,
       "name": "id",
       "entityType": "columns",
-      "table": "session_entry"
+      "table": "session_message"
     },
     {
       "type": "text",
@@ -608,7 +610,7 @@
       "generated": null,
       "name": "session_id",
       "entityType": "columns",
-      "table": "session_entry"
+      "table": "session_message"
     },
     {
       "type": "text",
@@ -618,7 +620,7 @@
       "generated": null,
       "name": "type",
       "entityType": "columns",
-      "table": "session_entry"
+      "table": "session_message"
     },
     {
       "type": "integer",
@@ -628,7 +630,7 @@
       "generated": null,
       "name": "time_created",
       "entityType": "columns",
-      "table": "session_entry"
+      "table": "session_message"
     },
     {
       "type": "integer",
@@ -638,7 +640,7 @@
       "generated": null,
       "name": "time_updated",
       "entityType": "columns",
-      "table": "session_entry"
+      "table": "session_message"
     },
     {
       "type": "text",
@@ -648,7 +650,7 @@
       "generated": null,
       "name": "data",
       "entityType": "columns",
-      "table": "session_entry"
+      "table": "session_message"
     },
     {
       "type": "text",
@@ -1051,9 +1053,13 @@
       "table": "event"
     },
     {
-      "columns": ["active_account_id"],
+      "columns": [
+        "active_account_id"
+      ],
       "tableTo": "account",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "SET NULL",
       "nameExplicit": false,
@@ -1062,9 +1068,13 @@
       "table": "account_state"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1073,9 +1083,13 @@
       "table": "workspace"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1084,9 +1098,13 @@
       "table": "message"
     },
     {
-      "columns": ["message_id"],
+      "columns": [
+        "message_id"
+      ],
       "tableTo": "message",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1095,9 +1113,13 @@
       "table": "part"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1106,20 +1128,28 @@
       "table": "permission"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
-      "name": "fk_session_entry_session_id_session_id_fk",
+      "name": "fk_session_message_session_id_session_id_fk",
       "entityType": "fks",
-      "table": "session_entry"
+      "table": "session_message"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "tableTo": "project",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1128,9 +1158,13 @@
       "table": "session"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1139,9 +1173,13 @@
       "table": "todo"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "tableTo": "session",
-      "columnsTo": ["id"],
+      "columnsTo": [
+        "id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1150,9 +1188,13 @@
       "table": "session_share"
     },
     {
-      "columns": ["aggregate_id"],
+      "columns": [
+        "aggregate_id"
+      ],
       "tableTo": "event_sequence",
-      "columnsTo": ["aggregate_id"],
+      "columnsTo": [
+        "aggregate_id"
+      ],
       "onUpdate": "NO ACTION",
       "onDelete": "CASCADE",
       "nameExplicit": false,
@@ -1161,98 +1203,128 @@
       "table": "event"
     },
     {
-      "columns": ["email", "url"],
+      "columns": [
+        "email",
+        "url"
+      ],
       "nameExplicit": false,
       "name": "control_account_pk",
       "entityType": "pks",
       "table": "control_account"
     },
     {
-      "columns": ["session_id", "position"],
+      "columns": [
+        "session_id",
+        "position"
+      ],
       "nameExplicit": false,
       "name": "todo_pk",
       "entityType": "pks",
       "table": "todo"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "account_state_pk",
       "table": "account_state",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "account_pk",
       "table": "account",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "workspace_pk",
       "table": "workspace",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "project_pk",
       "table": "project",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "message_pk",
       "table": "message",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "part_pk",
       "table": "part",
       "entityType": "pks"
     },
     {
-      "columns": ["project_id"],
+      "columns": [
+        "project_id"
+      ],
       "nameExplicit": false,
       "name": "permission_pk",
       "table": "permission",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
-      "name": "session_entry_pk",
-      "table": "session_entry",
+      "name": "session_message_pk",
+      "table": "session_message",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "session_pk",
       "table": "session",
       "entityType": "pks"
     },
     {
-      "columns": ["session_id"],
+      "columns": [
+        "session_id"
+      ],
       "nameExplicit": false,
       "name": "session_share_pk",
       "table": "session_share",
       "entityType": "pks"
     },
     {
-      "columns": ["aggregate_id"],
+      "columns": [
+        "aggregate_id"
+      ],
       "nameExplicit": false,
       "name": "event_sequence_pk",
       "table": "event_sequence",
       "entityType": "pks"
     },
     {
-      "columns": ["id"],
+      "columns": [
+        "id"
+      ],
       "nameExplicit": false,
       "name": "event_pk",
       "table": "event",
@@ -1322,9 +1394,9 @@
       "isUnique": false,
       "where": null,
       "origin": "manual",
-      "name": "session_entry_session_idx",
+      "name": "session_message_session_idx",
       "entityType": "indexes",
-      "table": "session_entry"
+      "table": "session_message"
     },
     {
       "columns": [
@@ -1340,9 +1412,9 @@
       "isUnique": false,
       "where": null,
       "origin": "manual",
-      "name": "session_entry_session_type_idx",
+      "name": "session_message_session_type_idx",
       "entityType": "indexes",
-      "table": "session_entry"
+      "table": "session_message"
     },
     {
       "columns": [
@@ -1354,9 +1426,9 @@
       "isUnique": false,
       "where": null,
       "origin": "manual",
-      "name": "session_entry_time_created_idx",
+      "name": "session_message_time_created_idx",
       "entityType": "indexes",
-      "table": "session_entry"
+      "table": "session_message"
     },
     {
       "columns": [

+ 2 - 0
packages/opencode/migration/20260501142318_next_venus/migration.sql

@@ -0,0 +1,2 @@
+ALTER TABLE `session` ADD `agent` text;--> statement-breakpoint
+ALTER TABLE `session` ADD `model` text;

+ 1511 - 0
packages/opencode/migration/20260501142318_next_venus/snapshot.json

@@ -0,0 +1,1511 @@
+{
+  "version": "7",
+  "dialect": "sqlite",
+  "id": "2ec89846-dcf1-4977-ab5e-244ddc9e3d67",
+  "prevIds": [
+    "aaa2ebeb-caa4-478d-8365-4fc595d16856"
+  ],
+  "ddl": [
+    {
+      "name": "account_state",
+      "entityType": "tables"
+    },
+    {
+      "name": "account",
+      "entityType": "tables"
+    },
+    {
+      "name": "control_account",
+      "entityType": "tables"
+    },
+    {
+      "name": "workspace",
+      "entityType": "tables"
+    },
+    {
+      "name": "project",
+      "entityType": "tables"
+    },
+    {
+      "name": "message",
+      "entityType": "tables"
+    },
+    {
+      "name": "part",
+      "entityType": "tables"
+    },
+    {
+      "name": "permission",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_message",
+      "entityType": "tables"
+    },
+    {
+      "name": "session",
+      "entityType": "tables"
+    },
+    {
+      "name": "todo",
+      "entityType": "tables"
+    },
+    {
+      "name": "session_share",
+      "entityType": "tables"
+    },
+    {
+      "name": "event_sequence",
+      "entityType": "tables"
+    },
+    {
+      "name": "event",
+      "entityType": "tables"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active_account_id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active_org_id",
+      "entityType": "columns",
+      "table": "account_state"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "access_token",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "refresh_token",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "token_expiry",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "email",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "access_token",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "refresh_token",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "token_expiry",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "active",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "control_account"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": "''",
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "branch",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "extra",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "workspace"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "worktree",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "vcs",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "name",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_url_override",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "icon_color",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_initialized",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "sandboxes",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "commands",
+      "entityType": "columns",
+      "table": "project"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "message_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "part"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "permission"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "session_message"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "project_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "workspace_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "parent_id",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "slug",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "directory",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "path",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "title",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "version",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "share_url",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_additions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_deletions",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_files",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "summary_diffs",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "revert",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "permission",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "agent",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "model",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_compacting",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "integer",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_archived",
+      "entityType": "columns",
+      "table": "session"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "content",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "status",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "priority",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "position",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "todo"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "session_id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "secret",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "url",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_created",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "time_updated",
+      "entityType": "columns",
+      "table": "session_share"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "aggregate_id",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event_sequence"
+    },
+    {
+      "type": "text",
+      "notNull": false,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "aggregate_id",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "integer",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "seq",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "type",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "type": "text",
+      "notNull": true,
+      "autoincrement": false,
+      "default": null,
+      "generated": null,
+      "name": "data",
+      "entityType": "columns",
+      "table": "event"
+    },
+    {
+      "columns": [
+        "active_account_id"
+      ],
+      "tableTo": "account",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "SET NULL",
+      "nameExplicit": false,
+      "name": "fk_account_state_active_account_id_account_id_fk",
+      "entityType": "fks",
+      "table": "account_state"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_workspace_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "workspace"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_message_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "message"
+    },
+    {
+      "columns": [
+        "message_id"
+      ],
+      "tableTo": "message",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_part_message_id_message_id_fk",
+      "entityType": "fks",
+      "table": "part"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_permission_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "permission"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_message_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "tableTo": "project",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_project_id_project_id_fk",
+      "entityType": "fks",
+      "table": "session"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_todo_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "todo"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "tableTo": "session",
+      "columnsTo": [
+        "id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_session_share_session_id_session_id_fk",
+      "entityType": "fks",
+      "table": "session_share"
+    },
+    {
+      "columns": [
+        "aggregate_id"
+      ],
+      "tableTo": "event_sequence",
+      "columnsTo": [
+        "aggregate_id"
+      ],
+      "onUpdate": "NO ACTION",
+      "onDelete": "CASCADE",
+      "nameExplicit": false,
+      "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk",
+      "entityType": "fks",
+      "table": "event"
+    },
+    {
+      "columns": [
+        "email",
+        "url"
+      ],
+      "nameExplicit": false,
+      "name": "control_account_pk",
+      "entityType": "pks",
+      "table": "control_account"
+    },
+    {
+      "columns": [
+        "session_id",
+        "position"
+      ],
+      "nameExplicit": false,
+      "name": "todo_pk",
+      "entityType": "pks",
+      "table": "todo"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "account_state_pk",
+      "table": "account_state",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "account_pk",
+      "table": "account",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "workspace_pk",
+      "table": "workspace",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "project_pk",
+      "table": "project",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "message_pk",
+      "table": "message",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "part_pk",
+      "table": "part",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "project_id"
+      ],
+      "nameExplicit": false,
+      "name": "permission_pk",
+      "table": "permission",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "session_message_pk",
+      "table": "session_message",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "session_pk",
+      "table": "session",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "session_id"
+      ],
+      "nameExplicit": false,
+      "name": "session_share_pk",
+      "table": "session_share",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "aggregate_id"
+      ],
+      "nameExplicit": false,
+      "name": "event_sequence_pk",
+      "table": "event_sequence",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        "id"
+      ],
+      "nameExplicit": false,
+      "name": "event_pk",
+      "table": "event",
+      "entityType": "pks"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "time_created",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "message_session_time_created_id_idx",
+      "entityType": "indexes",
+      "table": "message"
+    },
+    {
+      "columns": [
+        {
+          "value": "message_id",
+          "isExpression": false
+        },
+        {
+          "value": "id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_message_id_id_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "part_session_idx",
+      "entityType": "indexes",
+      "table": "part"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_session_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        },
+        {
+          "value": "type",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_session_type_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "time_created",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_message_time_created_idx",
+      "entityType": "indexes",
+      "table": "session_message"
+    },
+    {
+      "columns": [
+        {
+          "value": "project_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_project_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "workspace_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_workspace_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "parent_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "session_parent_idx",
+      "entityType": "indexes",
+      "table": "session"
+    },
+    {
+      "columns": [
+        {
+          "value": "session_id",
+          "isExpression": false
+        }
+      ],
+      "isUnique": false,
+      "where": null,
+      "origin": "manual",
+      "name": "todo_session_idx",
+      "entityType": "indexes",
+      "table": "todo"
+    }
+  ],
+  "renames": []
+}

+ 2 - 0
packages/opencode/src/bus/bus-event.ts

@@ -24,6 +24,7 @@ export function payloads() {
     .map(([type, def]) => {
       return z
         .object({
+          id: z.string(),
           type: z.literal(type),
           properties: zodObject(def.properties),
         })
@@ -39,6 +40,7 @@ export function effectPayloads() {
     .entries()
     .map(([type, def]) =>
       Schema.Struct({
+        id: Schema.String,
         type: Schema.Literal(type),
         properties: def.properties,
       }).annotate({ identifier: `Event.${type}` }),

+ 12 - 2
packages/opencode/src/bus/global.ts

@@ -1,4 +1,5 @@
 import { EventEmitter } from "events"
+import { Identifier } from "@/id/id"
 
 export type GlobalEvent = {
   directory?: string
@@ -7,6 +8,15 @@ export type GlobalEvent = {
   payload: any
 }
 
-export const GlobalBus = new EventEmitter<{
+class GlobalBusEmitter extends EventEmitter<{
   event: [GlobalEvent]
-}>()
+}> {
+  override emit(eventName: "event", event: GlobalEvent): boolean {
+    if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) {
+      event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending")
+    }
+    return super.emit(eventName, event)
+  }
+}
+
+export const GlobalBus = new GlobalBusEmitter()

+ 20 - 5
packages/opencode/src/bus/index.ts

@@ -5,6 +5,7 @@ import { BusEvent } from "./bus-event"
 import { GlobalBus } from "./global"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
+import { Identifier } from "@/id/id"
 
 const log = Log.create({ service: "bus" })
 
@@ -18,6 +19,7 @@ export const InstanceDisposed = BusEvent.define(
 )
 
 type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
+  id: string
   type: D["type"]
   properties: BusProperties<D>
 }
@@ -28,7 +30,11 @@ type State = {
 }
 
 export interface Interface {
-  readonly publish: <D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) => Effect.Effect<void>
+  readonly publish: <D extends BusEvent.Definition>(
+    def: D,
+    properties: BusProperties<D>,
+    options?: { id?: string },
+  ) => Effect.Effect<void>
   readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
   readonly subscribeAll: () => Stream.Stream<Payload>
   readonly subscribeCallback: <D extends BusEvent.Definition>(
@@ -53,6 +59,7 @@ export const layer = Layer.effect(
             // Publish InstanceDisposed before shutting down so subscribers see it
             yield* PubSub.publish(wildcard, {
               type: InstanceDisposed.type,
+              id: createID(),
               properties: { directory: ctx.directory },
             })
             yield* PubSub.shutdown(wildcard)
@@ -77,10 +84,10 @@ export const layer = Layer.effect(
       })
     }
 
-    function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
+    function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>, options?: { id?: string }) {
       return Effect.gen(function* () {
         const s = yield* InstanceState.get(state)
-        const payload: Payload = { type: def.type, properties }
+        const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties }
         log.info("publishing", { type: def.type })
 
         const ps = s.typed.get(def.type)
@@ -173,8 +180,16 @@ const { runPromise, runSync } = makeRuntime(Service, layer)
 
 // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
 // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
-export async function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>) {
-  return runPromise((svc) => svc.publish(def, properties))
+export function createID() {
+  return Identifier.create("evt", "ascending")
+}
+
+export async function publish<D extends BusEvent.Definition>(
+  def: D,
+  properties: BusProperties<D>,
+  options?: { id?: string },
+) {
+  return runPromise((svc) => svc.publish(def, properties, options))
 }
 
 export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => unknown) {

+ 24 - 21
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -28,6 +28,7 @@ import { useEvent } from "@tui/context/event"
 import { SDKProvider, useSDK } from "@tui/context/sdk"
 import { StartupLoading } from "@tui/component/startup-loading"
 import { SyncProvider, useSync } from "@tui/context/sync"
+import { SyncProviderV2 } from "@tui/context/sync-v2"
 import { LocalProvider, useLocal } from "@tui/context/local"
 import { DialogModel } from "@tui/component/dialog-model"
 import { useConnected } from "@tui/component/use-connected"
@@ -168,27 +169,29 @@ export function tui(input: {
                       >
                         <ProjectProvider>
                           <SyncProvider>
-                            <ThemeProvider mode={mode}>
-                              <LocalProvider>
-                                <KeybindProvider>
-                                  <PromptStashProvider>
-                                    <DialogProvider>
-                                      <CommandProvider>
-                                        <FrecencyProvider>
-                                          <PromptHistoryProvider>
-                                            <PromptRefProvider>
-                                              <EditorContextProvider>
-                                                <App onSnapshot={input.onSnapshot} />
-                                              </EditorContextProvider>
-                                            </PromptRefProvider>
-                                          </PromptHistoryProvider>
-                                        </FrecencyProvider>
-                                      </CommandProvider>
-                                    </DialogProvider>
-                                  </PromptStashProvider>
-                                </KeybindProvider>
-                              </LocalProvider>
-                            </ThemeProvider>
+                            <SyncProviderV2>
+                              <ThemeProvider mode={mode}>
+                                <LocalProvider>
+                                  <KeybindProvider>
+                                    <PromptStashProvider>
+                                      <DialogProvider>
+                                        <CommandProvider>
+                                          <FrecencyProvider>
+                                            <PromptHistoryProvider>
+                                              <PromptRefProvider>
+                                                <EditorContextProvider>
+                                                  <App onSnapshot={input.onSnapshot} />
+                                                </EditorContextProvider>
+                                              </PromptRefProvider>
+                                            </PromptHistoryProvider>
+                                          </FrecencyProvider>
+                                        </CommandProvider>
+                                      </DialogProvider>
+                                    </PromptStashProvider>
+                                  </KeybindProvider>
+                                </LocalProvider>
+                              </ThemeProvider>
+                            </SyncProviderV2>
                           </SyncProvider>
                         </ProjectProvider>
                       </SDKProvider>

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

@@ -750,9 +750,18 @@ export function Prompt(props: PromptProps) {
       return false
     }
 
+    const variant = local.model.variant.current()
     let sessionID = props.sessionID
     if (sessionID == null) {
-      const res = await sdk.client.session.create({ workspace: props.workspaceID })
+      const res = await sdk.client.session.create({
+        workspace: props.workspaceID,
+        agent: agent.name,
+        model: {
+          providerID: selectedModel.providerID,
+          id: selectedModel.modelID,
+          variant,
+        },
+      })
 
       if (res.error) {
         console.log("Creating a session failed:", res.error)
@@ -792,7 +801,6 @@ export function Prompt(props: PromptProps) {
 
     // Capture mode before it gets reset
     const currentMode = store.mode
-    const variant = local.model.variant.current()
     const editorSelection = editorContext()
     const currentEditorSelectionKey = editorSelectionKey(editorSelection)
     const editorParts =

+ 298 - 0
packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx

@@ -0,0 +1,298 @@
+import { useEvent } from "@tui/context/event"
+import type {
+  SessionMessage,
+  SessionMessageAssistant,
+  SessionMessageAssistantReasoning,
+  SessionMessageAssistantText,
+  SessionMessageAssistantTool,
+} from "@opencode-ai/sdk/v2"
+import { createStore, produce, reconcile } from "solid-js/store"
+import { createSimpleContext } from "./helper"
+import { useSDK } from "./sdk"
+
+function activeAssistant(messages: SessionMessage[]) {
+  const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
+  if (index < 0) return
+  const assistant = messages[index]
+  return assistant?.type === "assistant" ? assistant : undefined
+}
+
+function activeCompaction(messages: SessionMessage[]) {
+  const index = messages.findLastIndex((message) => message.type === "compaction")
+  if (index < 0) return
+  const compaction = messages[index]
+  return compaction?.type === "compaction" ? compaction : undefined
+}
+
+function activeShell(messages: SessionMessage[], callID: string) {
+  const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
+  if (index < 0) return
+  const shell = messages[index]
+  return shell?.type === "shell" ? shell : undefined
+}
+
+function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
+  return assistant?.content.findLast(
+    (item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID),
+  )
+}
+
+function latestText(assistant: SessionMessageAssistant | undefined) {
+  return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
+}
+
+function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
+  return assistant?.content.findLast(
+    (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
+  )
+}
+
+export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({
+  name: "SyncV2",
+  init: () => {
+    const [store, setStore] = createStore<{
+      messages: {
+        [sessionID: string]: SessionMessage[]
+      }
+    }>({
+      messages: {},
+    })
+
+    const event = useEvent()
+    const sdk = useSDK()
+
+    function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
+      setStore(
+        "messages",
+        produce((draft) => {
+          fn((draft[sessionID] ??= []))
+        }),
+      )
+    }
+
+    event.subscribe((event) => {
+      switch (event.type) {
+        case "session.next.prompted": {
+          update(event.properties.sessionID, (draft) => {
+            draft.push({
+              id: event.id,
+              type: "user",
+              text: event.properties.prompt.text,
+              files: event.properties.prompt.files,
+              agents: event.properties.prompt.agents,
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        }
+        case "session.next.synthetic":
+          update(event.properties.sessionID, (draft) => {
+            draft.push({
+              id: event.id,
+              type: "synthetic",
+              sessionID: event.properties.sessionID,
+              text: event.properties.text,
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        case "session.next.shell.started":
+          update(event.properties.sessionID, (draft) => {
+            draft.push({
+              id: event.id,
+              type: "shell",
+              callID: event.properties.callID,
+              command: event.properties.command,
+              output: "",
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        case "session.next.shell.ended":
+          update(event.properties.sessionID, (draft) => {
+            const match = activeShell(draft, event.properties.callID)
+            if (!match) return
+            match.output = event.properties.output
+            match.time.completed = event.properties.timestamp
+          })
+          break
+        case "session.next.step.started":
+          update(event.properties.sessionID, (draft) => {
+            const currentAssistant = activeAssistant(draft)
+            if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
+            draft.push({
+              id: event.id,
+              type: "assistant",
+              agent: event.properties.agent,
+              model: event.properties.model,
+              content: [],
+              snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        case "session.next.step.ended":
+          update(event.properties.sessionID, (draft) => {
+            const currentAssistant = activeAssistant(draft)
+            if (!currentAssistant) return
+            currentAssistant.time.completed = event.properties.timestamp
+            currentAssistant.finish = event.properties.finish
+            currentAssistant.cost = event.properties.cost
+            currentAssistant.tokens = event.properties.tokens
+            if (event.properties.snapshot)
+              currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
+          })
+          break
+        case "session.next.text.started":
+          update(event.properties.sessionID, (draft) => {
+            activeAssistant(draft)?.content.push({ type: "text", text: "" })
+          })
+          break
+        case "session.next.text.delta":
+          update(event.properties.sessionID, (draft) => {
+            const match = latestText(activeAssistant(draft))
+            if (match) match.text += event.properties.delta
+          })
+          break
+        case "session.next.text.ended":
+          update(event.properties.sessionID, (draft) => {
+            const match = latestText(activeAssistant(draft))
+            if (match) match.text = event.properties.text
+          })
+          break
+        case "session.next.tool.input.started":
+          update(event.properties.sessionID, (draft) => {
+            activeAssistant(draft)?.content.push({
+              type: "tool",
+              id: event.properties.callID,
+              name: event.properties.name,
+              time: { created: event.properties.timestamp },
+              state: { status: "pending", input: "" },
+            })
+          })
+          break
+        case "session.next.tool.input.delta":
+          update(event.properties.sessionID, (draft) => {
+            const match = latestTool(activeAssistant(draft), event.properties.callID)
+            if (match?.state.status === "pending") match.state.input += event.properties.delta
+          })
+          break
+        case "session.next.tool.input.ended":
+          break
+        case "session.next.tool.called":
+          update(event.properties.sessionID, (draft) => {
+            const match = latestTool(activeAssistant(draft), event.properties.callID)
+            if (!match) return
+            match.time.ran = event.properties.timestamp
+            match.provider = event.properties.provider
+            match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
+          })
+          break
+        case "session.next.tool.progress":
+          update(event.properties.sessionID, (draft) => {
+            const match = latestTool(activeAssistant(draft), event.properties.callID)
+            if (match?.state.status !== "running") return
+            match.state.structured = event.properties.structured
+            match.state.content = [...event.properties.content]
+          })
+          break
+        case "session.next.tool.success":
+          update(event.properties.sessionID, (draft) => {
+            const match = latestTool(activeAssistant(draft), event.properties.callID)
+            if (match?.state.status !== "running") return
+            match.state = {
+              status: "completed",
+              input: match.state.input,
+              structured: event.properties.structured,
+              content: [...event.properties.content],
+            }
+            match.provider = event.properties.provider
+            match.time.completed = event.properties.timestamp
+          })
+          break
+        case "session.next.tool.error":
+          update(event.properties.sessionID, (draft) => {
+            const match = latestTool(activeAssistant(draft), event.properties.callID)
+            if (match?.state.status !== "running") return
+            match.state = {
+              status: "error",
+              error: event.properties.error,
+              input: match.state.input,
+              structured: match.state.structured,
+              content: match.state.content,
+            }
+            match.provider = event.properties.provider
+            match.time.completed = event.properties.timestamp
+          })
+          break
+        case "session.next.reasoning.started":
+          update(event.properties.sessionID, (draft) => {
+            activeAssistant(draft)?.content.push({
+              type: "reasoning",
+              id: event.properties.reasoningID,
+              text: "",
+            })
+          })
+          break
+        case "session.next.reasoning.delta":
+          update(event.properties.sessionID, (draft) => {
+            const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
+            if (match) match.text += event.properties.delta
+          })
+          break
+        case "session.next.reasoning.ended":
+          update(event.properties.sessionID, (draft) => {
+            const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID)
+            if (match) match.text = event.properties.text
+          })
+          break
+        case "session.next.retried":
+          break
+        case "session.next.compaction.started":
+          update(event.properties.sessionID, (draft) => {
+            draft.push({
+              id: event.id,
+              type: "compaction",
+              reason: event.properties.reason,
+              summary: "",
+              time: { created: event.properties.timestamp },
+            })
+          })
+          break
+        case "session.next.compaction.delta":
+          update(event.properties.sessionID, (draft) => {
+            const match = activeCompaction(draft)
+            if (match) match.summary += event.properties.text
+          })
+          break
+        case "session.next.compaction.ended":
+          update(event.properties.sessionID, (draft) => {
+            const match = activeCompaction(draft)
+            if (!match) return
+            match.summary = event.properties.text
+            match.include = event.properties.include
+          })
+          break
+      }
+    })
+
+    const result = {
+      data: store,
+      session: {
+        message: {
+          async sync(sessionID: string) {
+            const response = await sdk.client.v2.session.messages({ sessionID })
+            setStore("messages", sessionID, reconcile(response.data?.items ?? []))
+          },
+          fromSession(sessionID: string) {
+            const messages = store.messages[sessionID]
+            if (!messages) return []
+            return messages
+          },
+        },
+      },
+    }
+
+    return result
+  },
+})

+ 1087 - 0
packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx

@@ -0,0 +1,1087 @@
+import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
+import { useSyncV2 } from "@tui/context/sync-v2"
+import { SplitBorder } from "@tui/component/border"
+import { Spinner } from "@tui/component/spinner"
+import { useTheme } from "@tui/context/theme"
+import { useLocal } from "@tui/context/local"
+import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
+import type { SyntaxStyle } from "@opentui/core"
+import { Locale } from "@/util/locale"
+import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
+import path from "path"
+import stripAnsi from "strip-ansi"
+import type {
+  SessionMessage,
+  SessionMessageAgentSwitched,
+  SessionMessageAssistant,
+  SessionMessageAssistantReasoning,
+  SessionMessageAssistantText,
+  SessionMessageAssistantTool,
+  SessionMessageCompaction,
+  SessionMessageModelSwitched,
+  SessionMessageShell,
+  SessionMessageSynthetic,
+  SessionMessageUser,
+  ToolFileContent,
+  ToolTextContent,
+} from "@opencode-ai/sdk/v2"
+import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
+
+const id = "internal:session-v2-debug"
+const route = "session.v2.messages"
+
+function currentSessionID(api: TuiPluginApi) {
+  const current = api.route.current
+  if (current.name !== "session") return
+  const sessionID = current.params?.sessionID
+  return typeof sessionID === "string" ? sessionID : undefined
+}
+
+function View(props: { api: TuiPluginApi; sessionID: string }) {
+  const sync = useSyncV2()
+  const dimensions = useTerminalDimensions()
+  const { theme, syntax, subtleSyntax } = useTheme()
+  const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
+  const renderedMessages = createMemo(() => messages().toReversed())
+  const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
+
+  createEffect(() => {
+    void sync.session.message.sync(props.sessionID)
+  })
+
+  useKeyboard((event) => {
+    if (event.name !== "escape") return
+    event.preventDefault()
+    event.stopPropagation()
+    props.api.route.navigate("session", { sessionID: props.sessionID })
+  })
+
+  return (
+    <box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
+      <box flexDirection="row">
+        <box flexGrow={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
+          <scrollbox
+            viewportOptions={{ paddingRight: 0 }}
+            verticalScrollbarOptions={{ visible: false }}
+            stickyScroll={true}
+            stickyStart="bottom"
+            flexGrow={1}
+          >
+            <box height={1} />
+            <Show when={messages().length === 0}>
+              <MissingData label="Messages" detail="No v2 messages loaded from useSyncV2 yet." />
+            </Show>
+            <For each={renderedMessages()}>
+              {(message, index) => (
+                <Switch>
+                  <Match when={message.type === "user"}>
+                    <UserMessage message={message as SessionMessageUser} index={index()} />
+                  </Match>
+                  <Match when={message.type === "assistant"}>
+                    <AssistantMessage
+                      message={message as SessionMessageAssistant}
+                      last={lastAssistant()?.id === message.id}
+                      syntax={syntax()}
+                      subtleSyntax={subtleSyntax()}
+                    />
+                  </Match>
+                  <Match when={message.type === "synthetic"}>
+                    <SyntheticMessage message={message as SessionMessageSynthetic} index={index()} />
+                  </Match>
+                  <Match when={message.type === "shell"}>
+                    <ShellMessage message={message as SessionMessageShell} />
+                  </Match>
+                  <Match when={message.type === "compaction"}>
+                    <CompactionMessage message={message as SessionMessageCompaction} />
+                  </Match>
+                  <Match when={message.type === "agent-switched"}>
+                    <AgentSwitchedMessage message={message as SessionMessageAgentSwitched} />
+                  </Match>
+                  <Match when={message.type === "model-switched"}>
+                    <ModelSwitchedMessage message={message as SessionMessageModelSwitched} />
+                  </Match>
+                  <Match when={true}>
+                    <UnknownMessage message={message} />
+                  </Match>
+                </Switch>
+              )}
+            </For>
+          </scrollbox>
+          <MissingData
+            label="Session prompt, permission prompt, question prompt, sidebar"
+            detail="The v2 message endpoint only exposes messages, so these session UI regions cannot be rendered here. Press Esc to return to the live session."
+          />
+        </box>
+      </box>
+    </box>
+  )
+}
+
+function MissingData(props: { label: string; detail: string }) {
+  const { theme } = useTheme()
+  return (
+    <box
+      border={["left"]}
+      customBorderChars={SplitBorder.customBorderChars}
+      borderColor={theme.warning}
+      backgroundColor={theme.backgroundPanel}
+      paddingLeft={2}
+      paddingTop={1}
+      paddingBottom={1}
+      marginTop={1}
+      flexShrink={0}
+    >
+      <text fg={theme.text}>
+        <span style={{ bg: theme.warning, fg: theme.background, bold: true }}> MISSING DATA </span> {props.label}
+      </text>
+      <text fg={theme.textMuted}>{props.detail}</text>
+    </box>
+  )
+}
+
+function UserMessage(props: { message: SessionMessageUser; index: number }) {
+  const { theme } = useTheme()
+  const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])])
+  return (
+    <box
+      id={props.message.id}
+      border={["left"]}
+      borderColor={theme.primary}
+      customBorderChars={SplitBorder.customBorderChars}
+      marginTop={props.index === 0 ? 0 : 1}
+      flexShrink={0}
+    >
+      <box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={theme.backgroundPanel}>
+        <Show
+          when={props.message.text.trim()}
+          fallback={
+            <MissingData label="User message text" detail={`Message ${props.message.id} has no text field content.`} />
+          }
+        >
+          <text fg={theme.text}>{props.message.text}</text>
+        </Show>
+        <Show when={attachments().length}>
+          <box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
+            <For each={props.message.files ?? []}>
+              {(file) => (
+                <text fg={theme.text}>
+                  <span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
+                  <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
+                </text>
+              )}
+            </For>
+            <For each={props.message.agents ?? []}>
+              {(agent) => (
+                <text fg={theme.text}>
+                  <span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
+                  <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
+                </text>
+              )}
+            </For>
+          </box>
+        </Show>
+        <text fg={theme.textMuted}>{Locale.todayTimeOrDateTime(props.message.time.created)}</text>
+      </box>
+    </box>
+  )
+}
+
+function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) {
+  const { theme } = useTheme()
+  return (
+    <box
+      id={props.message.id}
+      border={["left"]}
+      borderColor={theme.backgroundElement}
+      customBorderChars={SplitBorder.customBorderChars}
+      marginTop={props.index === 0 ? 0 : 1}
+      paddingLeft={2}
+      paddingTop={1}
+      paddingBottom={1}
+      backgroundColor={theme.backgroundPanel}
+      flexShrink={0}
+    >
+      <text fg={theme.textMuted}>Synthetic</text>
+      <text fg={theme.text}>{props.message.text}</text>
+    </box>
+  )
+}
+
+function ShellMessage(props: { message: SessionMessageShell }) {
+  const { theme } = useTheme()
+  const output = createMemo(() => stripAnsi(props.message.output.trim()))
+  const [expanded, setExpanded] = createSignal(false)
+  const lines = createMemo(() => output().split("\n"))
+  const overflow = createMemo(() => lines().length > 10)
+  const limited = createMemo(() => {
+    if (expanded() || !overflow()) return output()
+    return [...lines().slice(0, 10), "…"].join("\n")
+  })
+  return (
+    <BlockTool
+      title="# Shell"
+      spinner={!props.message.time.completed}
+      onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
+    >
+      <box gap={1}>
+        <text fg={theme.text}>$ {props.message.command}</text>
+        <Show when={output()}>
+          <text fg={theme.text}>{limited()}</text>
+        </Show>
+        <Show when={overflow()}>
+          <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
+        </Show>
+      </box>
+    </BlockTool>
+  )
+}
+
+function CompactionMessage(props: { message: SessionMessageCompaction }) {
+  const { theme } = useTheme()
+  return (
+    <box
+      marginTop={1}
+      border={["top"]}
+      title={props.message.reason === "auto" ? " Auto Compaction " : " Compaction "}
+      titleAlignment="center"
+      borderColor={theme.borderActive}
+      flexShrink={0}
+    >
+      <Show when={props.message.summary}>
+        <text fg={theme.textMuted}>{props.message.summary}</text>
+      </Show>
+    </box>
+  )
+}
+
+function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) {
+  const { theme } = useTheme()
+  const local = useLocal()
+  return (
+    <box paddingLeft={3} marginTop={1} flexShrink={0}>
+      <text>
+        <span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>
+        <span style={{ fg: theme.textMuted }}>Switched agent to </span>
+        <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
+      </text>
+    </box>
+  )
+}
+
+function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) {
+  const { theme } = useTheme()
+  const model = createMemo(() => {
+    const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
+    return `${props.message.model.providerID}/${props.message.model.id}${variant}`
+  })
+  return (
+    <box paddingLeft={3} marginTop={1} flexShrink={0}>
+      <text>
+        <span style={{ fg: theme.secondary }}>◇ </span>
+        <span style={{ fg: theme.textMuted }}>Switched model to </span>
+        <span style={{ fg: theme.text }}>{model()}</span>
+      </text>
+    </box>
+  )
+}
+
+function UnknownMessage(props: { message: SessionMessage }) {
+  return <MissingData label="Unknown message type" detail={JSON.stringify(props.message)} />
+}
+
+function AssistantMessage(props: {
+  message: SessionMessageAssistant
+  last: boolean
+  syntax: SyntaxStyle
+  subtleSyntax: SyntaxStyle
+}) {
+  const { theme } = useTheme()
+  const local = useLocal()
+  const duration = createMemo(() => {
+    if (!props.message.time.completed) return 0
+    return props.message.time.completed - props.message.time.created
+  })
+  const model = createMemo(() => {
+    const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
+    return `${props.message.model.providerID}/${props.message.model.id}${variant}`
+  })
+  const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish))
+  return (
+    <>
+      <For each={props.message.content}>
+        {(part) => (
+          <Switch>
+            <Match when={part.type === "text"}>
+              <AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
+            </Match>
+            <Match when={part.type === "reasoning"}>
+              <AssistantReasoning part={part as SessionMessageAssistantReasoning} subtleSyntax={props.subtleSyntax} />
+            </Match>
+            <Match when={part.type === "tool"}>
+              <AssistantTool part={part as SessionMessageAssistantTool} />
+            </Match>
+          </Switch>
+        )}
+      </For>
+      <Show when={props.message.content.length === 0}>
+        <MissingData label="Assistant content" detail={`Assistant message ${props.message.id} has no content items.`} />
+      </Show>
+      <Show when={props.message.error}>
+        <box
+          border={["left"]}
+          paddingTop={1}
+          paddingBottom={1}
+          paddingLeft={2}
+          marginTop={1}
+          backgroundColor={theme.backgroundPanel}
+          customBorderChars={SplitBorder.customBorderChars}
+          borderColor={theme.error}
+          flexShrink={0}
+        >
+          <text fg={theme.textMuted}>{props.message.error}</text>
+        </box>
+      </Show>
+      <Show when={props.last || final() || props.message.error}>
+        <box paddingLeft={3} flexShrink={0}>
+          <text marginTop={1}>
+            <span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>
+            <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
+            <span style={{ fg: theme.textMuted }}> · {model()}</span>
+            <Show when={duration()}>
+              <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
+            </Show>
+          </text>
+        </box>
+      </Show>
+    </>
+  )
+}
+
+function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) {
+  const { theme } = useTheme()
+  return (
+    <Show when={props.part.text.trim()}>
+      <box paddingLeft={3} marginTop={1} flexShrink={0}>
+        <code
+          filetype="markdown"
+          drawUnstyledText={false}
+          streaming={true}
+          syntaxStyle={props.syntax}
+          content={props.part.text.trim()}
+          conceal={true}
+          fg={theme.text}
+        />
+      </box>
+    </Show>
+  )
+}
+
+function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) {
+  const { theme } = useTheme()
+  const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
+  return (
+    <Show when={content()}>
+      <box
+        paddingLeft={2}
+        marginTop={1}
+        flexDirection="column"
+        border={["left"]}
+        customBorderChars={SplitBorder.customBorderChars}
+        borderColor={theme.backgroundElement}
+        flexShrink={0}
+      >
+        <code
+          filetype="markdown"
+          drawUnstyledText={false}
+          streaming={true}
+          syntaxStyle={props.subtleSyntax}
+          content={"_Thinking:_ " + content()}
+          conceal={true}
+          fg={theme.textMuted}
+        />
+      </box>
+    </Show>
+  )
+}
+
+function AssistantTool(props: { part: SessionMessageAssistantTool }) {
+  const input = createMemo(() => toolInputRecord(props.part.state.input))
+  const toolprops = {
+    get input() {
+      return input()
+    },
+    get metadata() {
+      return props.part.provider?.metadata ?? {}
+    },
+    get output() {
+      return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content)
+    },
+    part: props.part,
+  }
+  return (
+    <Switch>
+      <Match when={props.part.name === "bash"}>
+        <Bash {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "glob"}>
+        <Glob {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "read"}>
+        <Read {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "grep"}>
+        <Grep {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "webfetch"}>
+        <WebFetch {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "codesearch"}>
+        <CodeSearch {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "websearch"}>
+        <WebSearch {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "write"}>
+        <Write {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "edit"}>
+        <Edit {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "apply_patch"}>
+        <ApplyPatch {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "todowrite"}>
+        <TodoWrite {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "question"}>
+        <Question {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "skill"}>
+        <Skill {...toolprops} />
+      </Match>
+      <Match when={props.part.name === "task"}>
+        <Task {...toolprops} />
+      </Match>
+      <Match when={true}>
+        <GenericTool {...toolprops} />
+      </Match>
+    </Switch>
+  )
+}
+
+type ToolProps = {
+  input: Record<string, unknown>
+  metadata: Record<string, unknown>
+  output?: string
+  part: SessionMessageAssistantTool
+}
+
+function GenericTool(props: ToolProps) {
+  const { theme } = useTheme()
+  const output = createMemo(() => props.output?.trim() ?? "")
+  const [expanded, setExpanded] = createSignal(false)
+  const lines = createMemo(() => output().split("\n"))
+  const maxLines = 3
+  const overflow = createMemo(() => lines().length > maxLines)
+  const limited = createMemo(() => {
+    if (expanded() || !overflow()) return output()
+    return [...lines().slice(0, maxLines), "…"].join("\n")
+  })
+  return (
+    <Show
+      when={output()}
+      fallback={
+        <InlineTool icon="⚙" pending="Writing command..." complete={toolComplete(props.part)} part={props.part}>
+          {props.part.name} {input(props.input)}
+        </InlineTool>
+      }
+    >
+      <BlockTool
+        title={`# ${props.part.name} ${input(props.input)}`}
+        part={props.part}
+        onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
+      >
+        <box gap={1}>
+          <text fg={theme.text}>{limited()}</text>
+          <Show when={overflow()}>
+            <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
+          </Show>
+        </box>
+      </BlockTool>
+    </Show>
+  )
+}
+
+function InlineTool(props: {
+  icon: string
+  complete: unknown
+  pending: string
+  spinner?: boolean
+  children: JSX.Element
+  part: SessionMessageAssistantTool
+}) {
+  const { theme } = useTheme()
+  const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
+  const denied = createMemo(() => {
+    const message = error()
+    if (!message) return false
+    return (
+      message.includes("QuestionRejectedError") ||
+      message.includes("rejected permission") ||
+      message.includes("user dismissed")
+    )
+  })
+  return (
+    <box marginTop={1} paddingLeft={3} flexShrink={0}>
+      <Switch>
+        <Match when={props.spinner}>
+          <Spinner color={theme.text}>{props.children}</Spinner>
+        </Match>
+        <Match when={true}>
+          <text paddingLeft={3} fg={props.complete ? theme.textMuted : theme.text}>
+            <Show fallback={<>~ {props.pending}</>} when={props.complete}>
+              {props.icon} {props.children}
+            </Show>
+          </text>
+        </Match>
+      </Switch>
+      <Show when={error() && !denied()}>
+        <text fg={theme.error}>{error()}</text>
+      </Show>
+    </box>
+  )
+}
+
+function BlockTool(props: {
+  title: string
+  children: JSX.Element
+  part?: SessionMessageAssistantTool
+  onClick?: () => void
+  spinner?: boolean
+}) {
+  const { theme } = useTheme()
+  const renderer = useRenderer()
+  const [hover, setHover] = createSignal(false)
+  const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined))
+  return (
+    <box
+      border={["left"]}
+      paddingTop={1}
+      paddingBottom={1}
+      paddingLeft={2}
+      marginTop={1}
+      gap={1}
+      backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
+      customBorderChars={SplitBorder.customBorderChars}
+      borderColor={theme.background}
+      onMouseOver={() => props.onClick && setHover(true)}
+      onMouseOut={() => setHover(false)}
+      onMouseUp={() => {
+        if (renderer.getSelection()?.getSelectedText()) return
+        props.onClick?.()
+      }}
+      flexShrink={0}
+    >
+      <Show
+        when={props.spinner}
+        fallback={
+          <text paddingLeft={3} fg={theme.textMuted}>
+            {props.title}
+          </text>
+        }
+      >
+        <Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
+      </Show>
+      {props.children}
+      <Show when={error()}>
+        <text fg={theme.error}>{error()}</text>
+      </Show>
+    </box>
+  )
+}
+
+function Bash(props: ToolProps) {
+  const { theme } = useTheme()
+  const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
+  const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
+  const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
+  const [expanded, setExpanded] = createSignal(false)
+  const lines = createMemo(() => output().split("\n"))
+  const overflow = createMemo(() => lines().length > 10)
+  const limited = createMemo(() => {
+    if (expanded() || !overflow()) return output()
+    return [...lines().slice(0, 10), "…"].join("\n")
+  })
+  return (
+    <Switch>
+      <Match when={output()}>
+        <BlockTool
+          title={title()}
+          part={props.part}
+          spinner={props.part.state.status === "running"}
+          onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined}
+        >
+          <box gap={1}>
+            <text fg={theme.text}>$ {command()}</text>
+            <text fg={theme.text}>{limited()}</text>
+            <Show when={overflow()}>
+              <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
+            </Show>
+          </box>
+        </BlockTool>
+      </Match>
+      <Match when={true}>
+        <InlineTool icon="$" pending="Writing command..." complete={command()} part={props.part}>
+          {command()}
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
+
+function Glob(props: ToolProps) {
+  return (
+    <InlineTool icon="✱" pending="Finding files..." complete={toolComplete(props.part)} part={props.part}>
+      Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
+      <Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
+      <Show when={numberValue(props.metadata.count)}>
+        {(count) => (
+          <>
+            ({count()} {count() === 1 ? "match" : "matches"})
+          </>
+        )}
+      </Show>
+    </InlineTool>
+  )
+}
+
+function Read(props: ToolProps) {
+  const { theme } = useTheme()
+  const loaded = createMemo(() =>
+    arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"),
+  )
+  return (
+    <>
+      <InlineTool
+        icon="→"
+        pending="Reading file..."
+        complete={stringValue(props.input.filePath) ?? pendingInput(props.part)}
+        spinner={props.part.state.status === "running"}
+        part={props.part}
+      >
+        Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "}
+        {input(props.input, ["filePath"])}
+      </InlineTool>
+      <For each={loaded()}>
+        {(filepath) => (
+          <box paddingLeft={3} flexShrink={0}>
+            <text paddingLeft={3} fg={theme.textMuted}>
+              ↳ Loaded {normalizePath(filepath)}
+            </text>
+          </box>
+        )}
+      </For>
+    </>
+  )
+}
+
+function Grep(props: ToolProps) {
+  return (
+    <InlineTool icon="✱" pending="Searching content..." complete={toolComplete(props.part)} part={props.part}>
+      Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
+      <Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
+      <Show when={numberValue(props.metadata.matches)}>
+        {(matches) => (
+          <>
+            ({matches()} {matches() === 1 ? "match" : "matches"})
+          </>
+        )}
+      </Show>
+    </InlineTool>
+  )
+}
+
+function WebFetch(props: ToolProps) {
+  return (
+    <InlineTool icon="%" pending="Fetching from the web..." complete={toolComplete(props.part)} part={props.part}>
+      WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)}
+    </InlineTool>
+  )
+}
+
+function CodeSearch(props: ToolProps) {
+  return (
+    <InlineTool icon="◇" pending="Searching code..." complete={toolComplete(props.part)} part={props.part}>
+      Exa Code Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "}
+      <Show when={numberValue(props.metadata.results)}>{(results) => <>({results()} results)</>}</Show>
+    </InlineTool>
+  )
+}
+
+function WebSearch(props: ToolProps) {
+  return (
+    <InlineTool icon="◈" pending="Searching web..." complete={toolComplete(props.part)} part={props.part}>
+      Exa Web Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "}
+      <Show when={numberValue(props.metadata.numResults)}>{(results) => <>({results()} results)</>}</Show>
+    </InlineTool>
+  )
+}
+
+function Write(props: ToolProps) {
+  const { theme, syntax } = useTheme()
+  const filePath = createMemo(() => stringValue(props.input.filePath) ?? "")
+  const content = createMemo(() => stringValue(props.input.content) ?? "")
+  return (
+    <Switch>
+      <Match when={content() && props.part.state.status === "completed"}>
+        <BlockTool title={"# Wrote " + normalizePath(filePath())} part={props.part}>
+          <line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
+            <code
+              conceal={false}
+              fg={theme.text}
+              filetype={filetype(filePath())}
+              syntaxStyle={syntax()}
+              content={content()}
+            />
+          </line_number>
+          <Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
+        </BlockTool>
+      </Match>
+      <Match when={true}>
+        <InlineTool icon="←" pending="Preparing write..." complete={filePath()} part={props.part}>
+          Write {normalizePath(filePath())}
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
+
+function Edit(props: ToolProps) {
+  const { theme, syntax } = useTheme()
+  const dimensions = useTerminalDimensions()
+  const filePath = createMemo(() => stringValue(props.input.filePath) ?? "")
+  const diff = createMemo(() => stringValue(props.metadata.diff))
+  return (
+    <Switch>
+      <Match when={diff()}>
+        {(diff) => (
+          <BlockTool title={"← Edit " + normalizePath(filePath())} part={props.part}>
+            <box paddingLeft={1}>
+              <diff
+                diff={diff()}
+                view={dimensions().width > 120 ? "split" : "unified"}
+                filetype={filetype(filePath())}
+                syntaxStyle={syntax()}
+                showLineNumbers={true}
+                width="100%"
+                wrapMode="word"
+                fg={theme.text}
+                addedBg={theme.diffAddedBg}
+                removedBg={theme.diffRemovedBg}
+                contextBg={theme.diffContextBg}
+                addedSignColor={theme.diffHighlightAdded}
+                removedSignColor={theme.diffHighlightRemoved}
+                lineNumberFg={theme.diffLineNumber}
+                lineNumberBg={theme.diffContextBg}
+                addedLineNumberBg={theme.diffAddedLineNumberBg}
+                removedLineNumberBg={theme.diffRemovedLineNumberBg}
+              />
+            </box>
+            <Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
+          </BlockTool>
+        )}
+      </Match>
+      <Match when={true}>
+        <InlineTool icon="←" pending="Preparing edit..." complete={filePath()} part={props.part}>
+          Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })}
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
+
+function ApplyPatch(props: ToolProps) {
+  const { theme, syntax } = useTheme()
+  const dimensions = useTerminalDimensions()
+  const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : [])))
+  const fileTitle = (file: Record<string, unknown>) => {
+    const type = stringValue(file.type)
+    const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch"
+    if (type === "delete") return "# Deleted " + relativePath
+    if (type === "add") return "# Created " + relativePath
+    if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath
+    return "← Patched " + relativePath
+  }
+  return (
+    <Switch>
+      <Match when={files().length > 0}>
+        <For each={files()}>
+          {(file) => (
+            <BlockTool title={fileTitle(file)} part={props.part}>
+              <Show
+                when={stringValue(file.patch)}
+                fallback={
+                  <text fg={theme.diffRemoved}>
+                    -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"}
+                  </text>
+                }
+              >
+                {(patch) => (
+                  <box paddingLeft={1}>
+                    <diff
+                      diff={patch()}
+                      view={dimensions().width > 120 ? "split" : "unified"}
+                      filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))}
+                      syntaxStyle={syntax()}
+                      showLineNumbers={true}
+                      width="100%"
+                      wrapMode="word"
+                      fg={theme.text}
+                      addedBg={theme.diffAddedBg}
+                      removedBg={theme.diffRemovedBg}
+                      contextBg={theme.diffContextBg}
+                      addedSignColor={theme.diffHighlightAdded}
+                      removedSignColor={theme.diffHighlightRemoved}
+                      lineNumberFg={theme.diffLineNumber}
+                      lineNumberBg={theme.diffContextBg}
+                      addedLineNumberBg={theme.diffAddedLineNumberBg}
+                      removedLineNumberBg={theme.diffRemovedLineNumberBg}
+                    />
+                  </box>
+                )}
+              </Show>
+            </BlockTool>
+          )}
+        </For>
+      </Match>
+      <Match when={true}>
+        <InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
+          Patch
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
+
+function TodoWrite(props: ToolProps) {
+  const { theme } = useTheme()
+  const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : [])))
+  return (
+    <Switch>
+      <Match when={todos().length > 0 && props.part.state.status === "completed"}>
+        <BlockTool title="# Todos" part={props.part}>
+          <box>
+            <For each={todos()}>
+              {(todo) => (
+                <text fg={theme.text}>
+                  {todoIcon(stringValue(todo.status))} {stringValue(todo.content)}
+                </text>
+              )}
+            </For>
+          </box>
+        </BlockTool>
+      </Match>
+      <Match when={true}>
+        <InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
+          Updating todos...
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
+
+function Question(props: ToolProps) {
+  const { theme } = useTheme()
+  const questions = createMemo(() =>
+    arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])),
+  )
+  const answers = createMemo(() => arrayValue(props.metadata.answers))
+  return (
+    <Switch>
+      <Match when={answers().length > 0}>
+        <BlockTool title="# Questions" part={props.part}>
+          <box gap={1}>
+            <For each={questions()}>
+              {(question, index) => (
+                <box>
+                  <text fg={theme.textMuted}>{stringValue(question.question)}</text>
+                  <text fg={theme.text}>{formatAnswer(answers()[index()])}</text>
+                </box>
+              )}
+            </For>
+          </box>
+        </BlockTool>
+      </Match>
+      <Match when={true}>
+        <InlineTool icon="→" pending="Asking questions..." complete={questions().length} part={props.part}>
+          Asked {questions().length} question{questions().length === 1 ? "" : "s"}
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
+
+function Skill(props: ToolProps) {
+  return (
+    <InlineTool icon="→" pending="Loading skill..." complete={toolComplete(props.part)} part={props.part}>
+      Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}"
+    </InlineTool>
+  )
+}
+
+function Task(props: ToolProps) {
+  const content = createMemo(() => {
+    const description = stringValue(props.input.description)
+    if (!description) return pendingInput(props.part)
+    return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}`
+  })
+  return (
+    <InlineTool
+      icon="│"
+      spinner={props.part.state.status === "running"}
+      complete={toolComplete(props.part)}
+      pending="Delegating..."
+      part={props.part}
+    >
+      {content()}
+    </InlineTool>
+  )
+}
+
+function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
+  const { theme } = useTheme()
+  const errors = createMemo(() => {
+    if (!isRecord(props.diagnostics)) return []
+    const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath]
+    return arrayValue(value)
+      .flatMap((item) => (isRecord(item) ? [item] : []))
+      .filter((diagnostic) => diagnostic.severity === 1)
+      .slice(0, 3)
+  })
+  return (
+    <Show when={errors().length}>
+      <box>
+        <For each={errors()}>
+          {(diagnostic) => <text fg={theme.error}>Error {stringValue(diagnostic.message)}</text>}
+        </For>
+      </box>
+    </Show>
+  )
+}
+
+function toolOutput(content?: Array<ToolTextContent | ToolFileContent>) {
+  return (content ?? [])
+    .map((item) => {
+      if (item.type === "text") return item.text.trim()
+      return `[file ${item.name ?? item.uri}]`
+    })
+    .filter(Boolean)
+    .join("\n")
+}
+
+function toolInputRecord(input: string | Record<string, unknown>) {
+  if (typeof input === "string") return {}
+  return input
+}
+
+function pendingInput(part: SessionMessageAssistantTool) {
+  if (part.state.status !== "pending") return ""
+  return part.state.input.trim()
+}
+
+function toolComplete(part: SessionMessageAssistantTool) {
+  if (part.state.status === "pending") return pendingInput(part)
+  return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running"
+}
+
+function stringValue(value: unknown) {
+  return typeof value === "string" ? value : undefined
+}
+
+function numberValue(value: unknown) {
+  return typeof value === "number" ? value : undefined
+}
+
+function arrayValue(value: unknown): unknown[] {
+  return Array.isArray(value) ? value : []
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return !!value && typeof value === "object" && !Array.isArray(value)
+}
+
+function input(input: Record<string, unknown>, omit?: string[]) {
+  const primitives = Object.entries(input).filter(([key, value]) => {
+    if (omit?.includes(key)) return false
+    return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
+  })
+  if (primitives.length === 0) return ""
+  return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
+}
+
+function normalizePath(input?: string) {
+  if (!input) return ""
+  const absolute = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input)
+  const relative = path.relative(process.cwd(), absolute)
+  if (!relative) return "."
+  if (!relative.startsWith("..")) return relative
+  return absolute
+}
+
+function filetype(input?: string) {
+  if (!input) return "none"
+  const language = LANGUAGE_EXTENSIONS[path.extname(input)]
+  if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
+  return language
+}
+
+function todoIcon(status?: string) {
+  if (status === "completed") return "✓"
+  if (status === "in_progress") return "~"
+  if (status === "cancelled") return "✕"
+  return "☐"
+}
+
+function formatAnswer(answer: unknown) {
+  if (!Array.isArray(answer)) return "(no answer)"
+  if (answer.length === 0) return "(no answer)"
+  return answer.filter((item): item is string => typeof item === "string").join(", ")
+}
+
+const tui: TuiPlugin = async (api) => {
+  api.route.register([
+    {
+      name: route,
+      render(input) {
+        const sessionID = input.params?.sessionID
+        if (typeof sessionID !== "string") {
+          return <text fg={api.theme.current.error}>Missing sessionID</text>
+        }
+        return <View api={api} sessionID={sessionID} />
+      },
+    },
+  ])
+
+  api.command.register(() => [
+    {
+      title: "View v2 session messages",
+      value: route,
+      category: "Debug",
+      suggested: api.route.current.name === "session",
+      enabled: api.route.current.name === "session",
+      onSelect() {
+        const sessionID = currentSessionID(api)
+        if (!sessionID) return
+        api.route.navigate(route, { sessionID })
+        api.ui.dialog.clear()
+      },
+    },
+  ])
+}
+
+const plugin: TuiPluginModule & { id: string } = {
+  id,
+  tui,
+}
+
+export default plugin

+ 3 - 0
packages/opencode/src/cli/cmd/tui/plugin/internal.ts

@@ -7,7 +7,9 @@ import SidebarTodo from "../feature-plugins/sidebar/todo"
 import SidebarFiles from "../feature-plugins/sidebar/files"
 import SidebarFooter from "../feature-plugins/sidebar/footer"
 import PluginManager from "../feature-plugins/system/plugins"
+import SessionV2Debug from "../feature-plugins/system/session-v2"
 import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
+import { Flag } from "@opencode-ai/core/flag/flag"
 
 export type InternalTuiPlugin = TuiPluginModule & {
   id: string
@@ -24,4 +26,5 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
   SidebarFiles,
   SidebarFooter,
   PluginManager,
+  ...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []),
 ]

+ 3 - 0
packages/opencode/src/server/routes/global.ts

@@ -6,6 +6,7 @@ import z from "zod"
 import { BusEvent } from "@/bus/bus-event"
 import { SyncEvent } from "@/sync"
 import { GlobalBus } from "@/bus/global"
+import { Bus } from "@/bus"
 import { AppRuntime } from "@/effect/app-runtime"
 import { AsyncQueue } from "@/util/queue"
 import { Installation } from "@/installation"
@@ -26,6 +27,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>
     q.push(
       JSON.stringify({
         payload: {
+          id: Bus.createID(),
           type: "server.connected",
           properties: {},
         },
@@ -37,6 +39,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>
       q.push(
         JSON.stringify({
           payload: {
+            id: Bus.createID(),
             type: "server.heartbeat",
             properties: {},
           },

+ 5 - 3
packages/opencode/src/server/routes/instance/event.ts

@@ -42,6 +42,7 @@ export const EventRoutes = () =>
 
         q.push(
           JSON.stringify({
+            id: Bus.createID(),
             type: "server.connected",
             properties: {},
           }),
@@ -50,9 +51,10 @@ export const EventRoutes = () =>
         // Send heartbeat every 10s to prevent stalled proxy streams.
         const heartbeat = setInterval(() => {
           q.push(
-            JSON.stringify({
-              type: "server.heartbeat",
-              properties: {},
+          JSON.stringify({
+            id: Bus.createID(),
+            type: "server.heartbeat",
+            properties: {},
             }),
           )
         }, 10_000)

+ 2 - 0
packages/opencode/src/server/routes/instance/httpapi/api.ts

@@ -19,6 +19,7 @@ import { SessionApi } from "./groups/session"
 import { SyncApi } from "./groups/sync"
 import { TuiApi } from "./groups/tui"
 import { WorkspaceApi } from "./groups/workspace"
+import { V2Api } from "./groups/v2"
 
 // SSE event schemas built from the same BusEvent/SyncEvent registries that
 // the Hono spec uses, so both specs emit identical Event/SyncEvent components.
@@ -40,6 +41,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
   .addHttpApi(ProviderApi)
   .addHttpApi(SessionApi)
   .addHttpApi(SyncApi)
+  .addHttpApi(V2Api)
   .addHttpApi(TuiApi)
   .addHttpApi(WorkspaceApi)
 

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

@@ -41,12 +41,12 @@ function eventResponse(bus: Bus.Interface) {
   const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type))
   const heartbeat = Stream.tick("10 seconds").pipe(
     Stream.drop(1),
-    Stream.map(() => ({ type: "server.heartbeat", properties: {} })),
+    Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })),
   )
 
   log.info("event connected")
   return HttpServerResponse.stream(
-    Stream.make({ type: "server.connected", properties: {} }).pipe(
+    Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe(
       Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
       Stream.map(eventData),
       Stream.pipeThroughChannel(Sse.encode()),

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

@@ -0,0 +1,14 @@
+import { HttpApi, OpenApi } from "effect/unstable/httpapi"
+import { MessageGroup } from "./v2/message"
+import { SessionGroup } from "./v2/session"
+
+export const V2Api = HttpApi.make("v2")
+  .add(SessionGroup)
+  .add(MessageGroup)
+  .annotateMerge(
+    OpenApi.annotations({
+      title: "opencode experimental HttpApi",
+      version: "0.0.1",
+      description: "Experimental HttpApi surface for selected instance routes.",
+    }),
+  )

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

@@ -0,0 +1,69 @@
+import { SessionID } from "@/session/schema"
+import { SessionMessage } from "@/v2/session-message"
+import { Schema } from "effect"
+import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { Authorization } from "../../middleware/authorization"
+
+export const MessageGroup = HttpApiGroup.make("v2.message")
+  .add(
+    HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
+      params: { sessionID: SessionID },
+      query: Schema.Union([
+        Schema.Struct({
+          limit: Schema.optional(
+            Schema.NumberFromString.check(
+              Schema.isInt(),
+              Schema.isGreaterThanOrEqualTo(1),
+              Schema.isLessThanOrEqualTo(200),
+            ),
+          ).annotate({
+            description:
+              "Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
+          }),
+          order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
+            description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
+          }),
+          cursor: Schema.optional(Schema.Never),
+        }),
+        Schema.Struct({
+          limit: Schema.optional(
+            Schema.NumberFromString.check(
+              Schema.isInt(),
+              Schema.isGreaterThanOrEqualTo(1),
+              Schema.isLessThanOrEqualTo(200),
+            ),
+          ).annotate({
+            description:
+              "Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
+          }),
+          cursor: Schema.String.annotate({
+            description:
+              "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
+          }),
+          order: Schema.optional(Schema.Never),
+        }),
+      ]).annotate({ identifier: "V2SessionMessagesQuery" }),
+      success: Schema.Struct({
+        items: Schema.Array(SessionMessage.Message),
+        cursor: Schema.Struct({
+          previous: Schema.String.pipe(Schema.optional),
+          next: Schema.String.pipe(Schema.optional),
+        }),
+      }).annotate({ identifier: "V2SessionMessagesResponse" }),
+      error: HttpApiError.BadRequest,
+    }).annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.session.messages",
+        summary: "Get v2 session messages",
+        description:
+          "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
+      }),
+    ),
+  )
+  .annotateMerge(
+    OpenApi.annotations({
+      title: "v2 messages",
+      description: "Experimental v2 message routes.",
+    }),
+  )
+  .middleware(Authorization)

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

@@ -0,0 +1,140 @@
+import { WorkspaceID } from "@/control-plane/schema"
+import { SessionID } from "@/session/schema"
+import { SessionMessage } from "@/v2/session-message"
+import { Prompt } from "@/v2/session-prompt"
+import { SessionV2 } from "@/v2/session"
+import { Schema, SchemaGetter } from "effect"
+import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import { Authorization } from "../../middleware/authorization"
+
+export const SessionGroup = HttpApiGroup.make("v2.session")
+  .add(
+    HttpApiEndpoint.get("sessions", "/api/session", {
+      query: Schema.Union([
+        Schema.Struct({
+          limit: Schema.optional(
+            Schema.NumberFromString.check(
+              Schema.isInt(),
+              Schema.isGreaterThanOrEqualTo(1),
+              Schema.isLessThanOrEqualTo(200),
+            ),
+          ).annotate({
+            description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
+          }),
+          order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
+            description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
+          }),
+          directory: Schema.String.pipe(Schema.optional),
+          path: Schema.String.pipe(Schema.optional),
+          workspace: WorkspaceID.pipe(Schema.optional),
+          roots: Schema.Literals(["true", "false"])
+            .pipe(
+              Schema.decodeTo(Schema.Boolean, {
+                decode: SchemaGetter.transform((value) => value === "true"),
+                encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
+              }),
+            )
+            .pipe(Schema.optional),
+          start: Schema.NumberFromString.pipe(Schema.optional),
+          search: Schema.String.pipe(Schema.optional),
+          cursor: Schema.optional(Schema.Never),
+        }),
+        Schema.Struct({
+          limit: Schema.optional(
+            Schema.NumberFromString.check(
+              Schema.isInt(),
+              Schema.isGreaterThanOrEqualTo(1),
+              Schema.isLessThanOrEqualTo(200),
+            ),
+          ).annotate({
+            description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
+          }),
+          cursor: Schema.String.annotate({
+            description:
+              "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
+          }),
+          order: Schema.optional(Schema.Never),
+          directory: Schema.optional(Schema.Never),
+          path: Schema.optional(Schema.Never),
+          workspace: Schema.optional(Schema.Never),
+          roots: Schema.optional(Schema.Never),
+          start: Schema.optional(Schema.Never),
+          search: Schema.optional(Schema.Never),
+        }),
+      ]).annotate({ identifier: "V2SessionsQuery" }),
+      success: Schema.Struct({
+        items: Schema.Array(SessionV2.Info),
+        cursor: Schema.Struct({
+          previous: Schema.String.pipe(Schema.optional),
+          next: Schema.String.pipe(Schema.optional),
+        }),
+      }).annotate({ identifier: "V2SessionsResponse" }),
+      error: HttpApiError.BadRequest,
+    }).annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.session.list",
+        summary: "List v2 sessions",
+        description:
+          "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
+      }),
+    ),
+  )
+  .add(
+    HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
+      params: { sessionID: SessionID },
+      payload: Schema.Struct({
+        prompt: Prompt,
+        delivery: SessionV2.Delivery.pipe(Schema.optional),
+      }),
+      success: SessionMessage.Message,
+    }).annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.session.prompt",
+        summary: "Send v2 message",
+        description: "Create a v2 session message and queue it for the agent loop.",
+      }),
+    ),
+  )
+  .add(
+    HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
+      params: { sessionID: SessionID },
+      success: HttpApiSchema.NoContent,
+    }).annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.session.compact",
+        summary: "Compact v2 session",
+        description: "Compact a v2 session conversation.",
+      }),
+    ),
+  )
+  .add(
+    HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
+      params: { sessionID: SessionID },
+      success: HttpApiSchema.NoContent,
+    }).annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.session.wait",
+        summary: "Wait for v2 session",
+        description: "Wait for a v2 session agent loop to become idle.",
+      }),
+    ),
+  )
+  .add(
+    HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
+      params: { sessionID: SessionID },
+      success: Schema.Array(SessionMessage.Message),
+    }).annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.session.context",
+        summary: "Get v2 session context",
+        description: "Retrieve the active context messages for a v2 session (all messages after the last compaction).",
+      }),
+    ),
+  )
+  .annotateMerge(
+    OpenApi.annotations({
+      title: "v2",
+      description: "Experimental v2 routes.",
+    }),
+  )
+  .middleware(Authorization)

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

@@ -1,6 +1,7 @@
 import { Config } from "@/config/config"
 import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
 import { EffectBridge } from "@/effect/bridge"
+import { Bus } from "@/bus"
 import { Installation } from "@/installation"
 import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -43,11 +44,11 @@ function eventResponse() {
   })
   const heartbeat = Stream.tick("10 seconds").pipe(
     Stream.drop(1),
-    Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })),
+    Stream.map(() => ({ payload: { id: Bus.createID(), type: "server.heartbeat", properties: {} } })),
   )
 
   return HttpServerResponse.stream(
-    Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe(
+    Stream.make({ payload: { id: Bus.createID(), type: "server.connected", properties: {} } }).pipe(
       Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
       Stream.map(eventData),
       Stream.pipeThroughChannel(Sse.encode()),

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

@@ -0,0 +1,6 @@
+import { SessionV2 } from "@/v2/session"
+import { Layer } from "effect"
+import { messageHandlers } from "./v2/message"
+import { sessionHandlers } from "./v2/session"
+
+export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers).pipe(Layer.provide(SessionV2.defaultLayer))

+ 60 - 0
packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts

@@ -0,0 +1,60 @@
+import { SessionMessage } from "@/v2/session-message"
+import { SessionV2 } from "@/v2/session"
+import { Effect, Schema } from "effect"
+import * as DateTime from "effect/DateTime"
+import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
+import { InstanceHttpApi } from "../../api"
+
+const DefaultMessagesLimit = 50
+
+const Cursor = Schema.Struct({
+  id: SessionMessage.ID,
+  time: Schema.Finite,
+  order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
+  direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
+})
+
+const decodeCursor = Schema.decodeUnknownSync(Cursor)
+
+const cursor = {
+  encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") {
+    return Buffer.from(
+      JSON.stringify({ id: message.id, time: DateTime.toEpochMillis(message.time.created), order, direction }),
+    ).toString("base64url")
+  },
+  decode(input: string) {
+    return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
+  },
+}
+
+export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message", (handlers) =>
+  Effect.gen(function* () {
+    const session = yield* SessionV2.Service
+
+    return handlers.handle(
+      "messages",
+      Effect.fn(function* (ctx) {
+        const decoded = yield* Effect.try({
+          try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
+          catch: () => new HttpApiError.BadRequest({}),
+        })
+        const order = decoded?.order ?? ctx.query.order ?? "desc"
+        const messages = yield* session.messages({
+          sessionID: ctx.params.sessionID,
+          limit: ctx.query.limit ?? DefaultMessagesLimit,
+          order,
+          cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined,
+        })
+        const first = messages[0]
+        const last = messages.at(-1)
+        return {
+          items: messages,
+          cursor: {
+            previous: first ? cursor.encode(first, order, "previous") : undefined,
+            next: last ? cursor.encode(last, order, "next") : undefined,
+          },
+        }
+      }),
+    )
+  }),
+)

+ 115 - 0
packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts

@@ -0,0 +1,115 @@
+import { WorkspaceID } from "@/control-plane/schema"
+import { SessionV2 } from "@/v2/session"
+import { Effect, Schema } from "effect"
+import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
+import { InstanceHttpApi } from "../../api"
+
+const DefaultSessionsLimit = 50
+
+const SessionCursor = Schema.Struct({
+  id: SessionV2.Info.fields.id,
+  time: Schema.Finite,
+  order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
+  direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
+  directory: Schema.String.pipe(Schema.optional),
+  path: Schema.String.pipe(Schema.optional),
+  workspaceID: WorkspaceID.pipe(Schema.optional),
+  roots: Schema.Boolean.pipe(Schema.optional),
+  start: Schema.Finite.pipe(Schema.optional),
+  search: Schema.String.pipe(Schema.optional),
+})
+type SessionCursor = typeof SessionCursor.Type
+
+const decodeCursor = Schema.decodeUnknownSync(SessionCursor)
+
+const sessionCursor = {
+  encode(
+    session: SessionV2.Info,
+    order: "asc" | "desc",
+    direction: "previous" | "next",
+    filters: Pick<SessionCursor, "directory" | "path" | "workspaceID" | "roots" | "start" | "search">,
+  ) {
+    return Buffer.from(
+      JSON.stringify({ id: session.id, time: session.time.created, order, direction, ...filters }),
+    ).toString("base64url")
+  },
+  decode(input: string) {
+    return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
+  },
+}
+
+export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) =>
+  Effect.gen(function* () {
+    const session = yield* SessionV2.Service
+
+    return handlers
+      .handle(
+        "sessions",
+        Effect.fn(function* (ctx) {
+          const decoded = yield* Effect.try({
+            try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined),
+            catch: () => new HttpApiError.BadRequest({}),
+          })
+          const order = decoded?.order ?? ctx.query.order ?? "desc"
+          const filters = decoded ?? {
+            directory: ctx.query.directory,
+            path: ctx.query.path,
+            workspaceID: ctx.query.workspace ? WorkspaceID.make(ctx.query.workspace) : undefined,
+            roots: ctx.query.roots,
+            start: ctx.query.start,
+            search: ctx.query.search,
+          }
+          const sessions = yield* session.list({
+            limit: ctx.query.limit ?? DefaultSessionsLimit,
+            order,
+            directory: filters.directory,
+            path: filters.path,
+            workspaceID: filters.workspaceID,
+            roots: filters.roots,
+            start: filters.start,
+            search: filters.search,
+            cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined,
+          })
+          const first = sessions[0]
+          const last = sessions.at(-1)
+          return {
+            items: sessions,
+            cursor: {
+              previous: first ? sessionCursor.encode(first, order, "previous", filters) : undefined,
+              next: last ? sessionCursor.encode(last, order, "next", filters) : undefined,
+            },
+          }
+        }),
+      )
+      .handle(
+        "prompt",
+        Effect.fn(function* (ctx) {
+          return yield* session.prompt({
+            sessionID: ctx.params.sessionID,
+            prompt: ctx.payload.prompt,
+            delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery,
+          })
+        }),
+      )
+      .handle(
+        "compact",
+        Effect.fn(function* (ctx) {
+          yield* session.compact(ctx.params.sessionID)
+          return HttpApiSchema.NoContent.make()
+        }),
+      )
+      .handle(
+        "wait",
+        Effect.fn(function* (ctx) {
+          yield* session.wait(ctx.params.sessionID)
+          return HttpApiSchema.NoContent.make()
+        }),
+      )
+      .handle(
+        "context",
+        Effect.fn(function* (ctx) {
+          return yield* session.context(ctx.params.sessionID)
+        }),
+      )
+  }),
+)

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

@@ -64,6 +64,7 @@ import { questionHandlers } from "./handlers/question"
 import { sessionHandlers } from "./handlers/session"
 import { syncHandlers } from "./handlers/sync"
 import { tuiHandlers } from "./handlers/tui"
+import { v2Handlers } from "./handlers/v2"
 import { workspaceHandlers } from "./handlers/workspace"
 import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context"
 import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing"
@@ -115,6 +116,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
     providerHandlers,
     sessionHandlers,
     syncHandlers,
+    v2Handlers,
     tuiHandlers,
     workspaceHandlers,
   ]),

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

@@ -1,7 +1,8 @@
 import { describeRoute, resolver, validator } from "hono-openapi"
 import { Hono } from "hono"
 import type { UpgradeWebSocket } from "hono/ws"
-import { Effect } from "effect"
+import { Context, Effect } from "effect"
+import { Flag } from "@opencode-ai/core/flag/flag"
 import z from "zod"
 import { Format } from "@/format"
 import { TuiRoutes } from "./tui"
@@ -25,10 +26,135 @@ import { ExperimentalRoutes } from "./experimental"
 import { ProviderRoutes } from "./provider"
 import { EventRoutes } from "./event"
 import { SyncRoutes } from "./sync"
+import { InstanceMiddleware } from "./middleware"
 import { jsonRequest } from "./trace"
+import { ExperimentalHttpApiServer } from "./httpapi/server"
+import { EventPaths } from "./httpapi/event"
+import { ExperimentalPaths } from "./httpapi/groups/experimental"
+import { FilePaths } from "./httpapi/groups/file"
+import { InstancePaths } from "./httpapi/groups/instance"
+import { McpPaths } from "./httpapi/groups/mcp"
+import { PtyPaths } from "./httpapi/groups/pty"
+import { SessionPaths } from "./httpapi/groups/session"
+import { SyncPaths } from "./httpapi/groups/sync"
+import { TuiPaths } from "./httpapi/groups/tui"
+import { WorkspacePaths } from "./httpapi/groups/workspace"
 
 export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
   const app = new Hono()
+  const handler = ExperimentalHttpApiServer.webHandler().handler
+  const context = Context.empty() as Context.Context<unknown>
+
+  app.all("/api/*", (c) => handler(c.req.raw, context))
+
+  if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
+    app.get(EventPaths.event, (c) => handler(c.req.raw, context))
+    app.get("/question", (c) => handler(c.req.raw, context))
+    app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context))
+    app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
+    app.get("/permission", (c) => handler(c.req.raw, context))
+    app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
+    app.get("/config", (c) => handler(c.req.raw, context))
+    app.patch("/config", (c) => handler(c.req.raw, context))
+    app.get("/config/providers", (c) => handler(c.req.raw, context))
+    app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context))
+    app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
+    app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context))
+    app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context))
+    app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context))
+    app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
+    app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
+    app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
+    app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
+    app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context))
+    app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
+    app.get("/provider", (c) => handler(c.req.raw, context))
+    app.get("/provider/auth", (c) => handler(c.req.raw, context))
+    app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context))
+    app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
+    app.get("/project", (c) => handler(c.req.raw, context))
+    app.get("/project/current", (c) => handler(c.req.raw, context))
+    app.post("/project/git/init", (c) => handler(c.req.raw, context))
+    app.patch("/project/:projectID", (c) => handler(c.req.raw, context))
+    app.get(FilePaths.findText, (c) => handler(c.req.raw, context))
+    app.get(FilePaths.findFile, (c) => handler(c.req.raw, context))
+    app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context))
+    app.get(FilePaths.list, (c) => handler(c.req.raw, context))
+    app.get(FilePaths.content, (c) => handler(c.req.raw, context))
+    app.get(FilePaths.status, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
+    app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context))
+    app.get(McpPaths.status, (c) => handler(c.req.raw, context))
+    app.post(McpPaths.status, (c) => handler(c.req.raw, context))
+    app.post(McpPaths.auth, (c) => handler(c.req.raw, context))
+    app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context))
+    app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context))
+    app.delete(McpPaths.auth, (c) => handler(c.req.raw, context))
+    app.post(McpPaths.connect, (c) => handler(c.req.raw, context))
+    app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context))
+    app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
+    app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
+    app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
+    app.get(PtyPaths.list, (c) => handler(c.req.raw, context))
+    app.post(PtyPaths.create, (c) => handler(c.req.raw, context))
+    app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
+    app.put(PtyPaths.update, (c) => handler(c.req.raw, context))
+    app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context))
+    app.get(PtyPaths.connect, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.get, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.children, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.todo, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.message, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.create, (c) => handler(c.req.raw, context))
+    app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context))
+    app.patch(SessionPaths.update, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.init, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.fork, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.abort, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.share, (c) => handler(c.req.raw, context))
+    app.delete(SessionPaths.share, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.command, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.shell, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.revert, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context))
+    app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context))
+    app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context))
+    app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context))
+    app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context))
+    app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context))
+    app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context))
+    app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context))
+    app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context))
+    app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context))
+    app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context))
+    app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context))
+    app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context))
+    app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context))
+    app.post(TuiPaths.publish, (c) => handler(c.req.raw, context))
+    app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context))
+    app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context))
+    app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context))
+    app.get(WorkspacePaths.adapters, (c) => handler(c.req.raw, context))
+    app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context))
+    app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
+    app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
+    app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
+    app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
+  }
 
   return app
     .route("/project", ProjectRoutes())

+ 23 - 1
packages/opencode/src/session/compaction.ts

@@ -14,10 +14,13 @@ import { Config } from "@/config/config"
 import { NotFoundError } from "@/storage/storage"
 import { ModelID, ProviderID } from "@/provider/schema"
 import { Effect, Layer, Context, Schema } from "effect"
+import * as DateTime from "effect/DateTime"
 import { InstanceState } from "@/effect/instance-state"
 import { isOverflow as overflow, usable } from "./overflow"
 import { makeRuntime } from "@/effect/run-service"
 import { fn } from "@/util/fn"
+import { EventV2 } from "@/v2/event"
+import { SessionEvent } from "@/v2/session-event"
 
 const log = Log.create({ service: "session.compaction" })
 
@@ -556,7 +559,21 @@ export const layer: Layer.Layer<
       }
 
       if (processor.message.error) return "stop"
-      if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
+      if (result === "continue") {
+        const summary = summaryText(
+          (yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? {
+            info: msg,
+            parts: [],
+          },
+        )
+        EventV2.run(SessionEvent.Compaction.Ended.Sync, {
+          sessionID: input.sessionID,
+          timestamp: DateTime.makeUnsafe(Date.now()),
+          text: summary ?? "",
+          include: selected.tail_start_id,
+        })
+        yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
+      }
       return result
     })
 
@@ -583,6 +600,11 @@ export const layer: Layer.Layer<
         auto: input.auto,
         overflow: input.overflow,
       })
+      EventV2.run(SessionEvent.Compaction.Started.Sync, {
+        sessionID: input.sessionID,
+        timestamp: DateTime.makeUnsafe(Date.now()),
+        reason: input.auto ? "auto" : "manual",
+      })
     })
 
     return Service.of({

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

@@ -20,6 +20,9 @@ import { Question } from "@/question"
 import { errorMessage } from "@/util/error"
 import * as Log from "@opencode-ai/core/util/log"
 import { isRecord } from "@/util/record"
+import { EventV2 } from "@/v2/event"
+import { SessionEvent } from "@/v2/session-event"
+import * as DateTime from "effect/DateTime"
 
 const DOOM_LOOP_THRESHOLD = 3
 const log = Log.create({ service: "session.processor" })
@@ -221,6 +224,12 @@ export const layer: Layer.Layer<
 
           case "reasoning-start":
             if (value.id in ctx.reasoningMap) return
+            // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+            EventV2.run(SessionEvent.Reasoning.Started.Sync, {
+              sessionID: ctx.sessionID,
+              reasoningID: value.id,
+              timestamp: DateTime.makeUnsafe(Date.now()),
+            })
             ctx.reasoningMap[value.id] = {
               id: PartID.ascending(),
               messageID: ctx.assistantMessage.id,
@@ -248,6 +257,13 @@ export const layer: Layer.Layer<
 
           case "reasoning-end":
             if (!(value.id in ctx.reasoningMap)) return
+            // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+            EventV2.run(SessionEvent.Reasoning.Ended.Sync, {
+              sessionID: ctx.sessionID,
+              reasoningID: value.id,
+              text: ctx.reasoningMap[value.id].text,
+              timestamp: DateTime.makeUnsafe(Date.now()),
+            })
             // oxlint-disable-next-line no-self-assign -- reactivity trigger
             ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
             ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
@@ -260,6 +276,13 @@ export const layer: Layer.Layer<
             if (ctx.assistantMessage.summary) {
               throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
             }
+            // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+            EventV2.run(SessionEvent.Tool.Input.Started.Sync, {
+              sessionID: ctx.sessionID,
+              callID: value.id,
+              name: value.toolName,
+              timestamp: DateTime.makeUnsafe(Date.now()),
+            })
             const part = yield* session.updatePart({
               id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
               messageID: ctx.assistantMessage.id,
@@ -281,13 +304,34 @@ export const layer: Layer.Layer<
           case "tool-input-delta":
             return
 
-          case "tool-input-end":
+          case "tool-input-end": {
+            // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+            EventV2.run(SessionEvent.Tool.Input.Ended.Sync, {
+              sessionID: ctx.sessionID,
+              callID: value.id,
+              text: "",
+              timestamp: DateTime.makeUnsafe(Date.now()),
+            })
             return
+          }
 
           case "tool-call": {
             if (ctx.assistantMessage.summary) {
               throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
             }
+            const toolCall = yield* readToolCall(value.toolCallId)
+            // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+            EventV2.run(SessionEvent.Tool.Called.Sync, {
+              sessionID: ctx.sessionID,
+              callID: value.toolCallId,
+              tool: value.toolName,
+              input: value.input,
+              provider: {
+                executed: toolCall?.part.metadata?.providerExecuted === true,
+                ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
+              },
+              timestamp: DateTime.makeUnsafe(Date.now()),
+            })
             yield* updateToolCall(value.toolCallId, (match) => ({
               ...match,
               tool: value.toolName,
@@ -331,11 +375,48 @@ export const layer: Layer.Layer<
           }
 
           case "tool-result": {
+            const toolCall = yield* readToolCall(value.toolCallId)
+            // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+              EventV2.run(SessionEvent.Tool.Success.Sync, {
+              sessionID: ctx.sessionID,
+              callID: value.toolCallId,
+              structured: value.output.metadata,
+              content: [
+                {
+                  type: "text",
+                  text: value.output.output,
+                },
+                ...(value.output.attachments?.map((item: MessageV2.FilePart) => ({
+                  type: "file",
+                  uri: item.url,
+                  mime: item.mime,
+                  name: item.filename,
+                })) ?? []),
+              ],
+              provider: {
+                executed: toolCall?.part.metadata?.providerExecuted === true,
+              },
+              timestamp: DateTime.makeUnsafe(Date.now()),
+            })
             yield* completeToolCall(value.toolCallId, value.output)
             return
           }
 
           case "tool-error": {
+            const toolCall = yield* readToolCall(value.toolCallId)
+            // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+            EventV2.run(SessionEvent.Tool.Error.Sync, {
+              sessionID: ctx.sessionID,
+              callID: value.toolCallId,
+              error: {
+                type: "unknown",
+                message: errorMessage(value.error),
+              },
+              provider: {
+                executed: toolCall?.part.metadata?.providerExecuted === true,
+              },
+              timestamp: DateTime.makeUnsafe(Date.now()),
+            })
             yield* failToolCall(value.toolCallId, value.error)
             return
           }
@@ -345,6 +426,20 @@ export const layer: Layer.Layer<
 
           case "start-step":
             if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
+            if (!ctx.assistantMessage.summary) {
+              // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+              EventV2.run(SessionEvent.Step.Started.Sync, {
+                sessionID: ctx.sessionID,
+                agent: input.assistantMessage.agent,
+                model: {
+                  id: ctx.model.id,
+                  providerID: ctx.model.providerID,
+                  variant: input.assistantMessage.variant,
+                },
+                snapshot: ctx.snapshot,
+                timestamp: DateTime.makeUnsafe(Date.now()),
+              })
+            }
             yield* session.updatePart({
               id: PartID.ascending(),
               messageID: ctx.assistantMessage.id,
@@ -355,18 +450,30 @@ export const layer: Layer.Layer<
             return
 
           case "finish-step": {
+            const completedSnapshot = yield* snapshot.track()
             const usage = Session.getUsage({
               model: ctx.model,
               usage: value.usage,
               metadata: value.providerMetadata,
             })
+            if (!ctx.assistantMessage.summary) {
+              // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+              EventV2.run(SessionEvent.Step.Ended.Sync, {
+                sessionID: ctx.sessionID,
+                finish: value.finishReason,
+                cost: usage.cost,
+                tokens: usage.tokens,
+                snapshot: completedSnapshot,
+                timestamp: DateTime.makeUnsafe(Date.now()),
+              })
+            }
             ctx.assistantMessage.finish = value.finishReason
             ctx.assistantMessage.cost += usage.cost
             ctx.assistantMessage.tokens = usage.tokens
             yield* session.updatePart({
               id: PartID.ascending(),
               reason: value.finishReason,
-              snapshot: yield* snapshot.track(),
+              snapshot: completedSnapshot,
               messageID: ctx.assistantMessage.id,
               sessionID: ctx.assistantMessage.sessionID,
               type: "step-finish",
@@ -404,6 +511,13 @@ export const layer: Layer.Layer<
           }
 
           case "text-start":
+            if (!ctx.assistantMessage.summary) {
+              // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+              EventV2.run(SessionEvent.Text.Started.Sync, {
+                sessionID: ctx.sessionID,
+                timestamp: DateTime.makeUnsafe(Date.now()),
+              })
+            }
             ctx.currentText = {
               id: PartID.ascending(),
               messageID: ctx.assistantMessage.id,
@@ -442,6 +556,14 @@ export const layer: Layer.Layer<
               },
               { text: ctx.currentText.text },
             )).text
+            if (!ctx.assistantMessage.summary) {
+              // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+              EventV2.run(SessionEvent.Text.Ended.Sync, {
+                sessionID: ctx.sessionID,
+                text: ctx.currentText.text,
+                timestamp: DateTime.makeUnsafe(Date.now()),
+              })
+            }
             {
               const end = Date.now()
               ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
@@ -568,13 +690,24 @@ export const layer: Layer.Layer<
             Effect.retry(
               SessionRetry.policy({
                 parse,
-                set: (info) =>
-                  status.set(ctx.sessionID, {
+                set: (info) => {
+                  // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+                  EventV2.run(SessionEvent.Retried.Sync, {
+                    sessionID: ctx.sessionID,
+                    attempt: info.attempt,
+                    error: {
+                      message: info.message,
+                      isRetryable: true,
+                    },
+                    timestamp: DateTime.makeUnsafe(Date.now()),
+                  })
+                  return status.set(ctx.sessionID, {
                     type: "retry",
                     attempt: info.attempt,
                     message: info.message,
                     next: info.next,
-                  }),
+                  })
+                },
               }),
             ),
             Effect.catch(halt),

+ 204 - 0
packages/opencode/src/session/projectors-next.ts

@@ -0,0 +1,204 @@
+import { and, desc, eq } from "@/storage/db"
+import type { Database } from "@/storage/db"
+import { SessionMessage } from "@/v2/session-message"
+import { SessionMessageUpdater } from "@/v2/session-message-updater"
+import { SessionEvent } from "@/v2/session-event"
+import * as DateTime from "effect/DateTime"
+import { SyncEvent } from "@/sync"
+import { SessionMessageTable, SessionTable } from "./session.sql"
+import type { SessionID } from "./schema"
+import { Schema } from "effect"
+
+const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
+type SessionMessageData = NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>
+
+function encodeDateTimes(value: unknown): unknown {
+  if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value)
+  if (Array.isArray(value)) return value.map(encodeDateTimes)
+  if (typeof value === "object" && value !== null) {
+    return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeDateTimes(item)]))
+  }
+  return value
+}
+
+function encodeMessageData(value: unknown): SessionMessageData {
+  return encodeDateTimes(value) as SessionMessageData
+}
+
+function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdater.Adapter<void> {
+  return {
+    getCurrentAssistant() {
+      return db
+        .select()
+        .from(SessionMessageTable)
+        .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant")))
+        .orderBy(desc(SessionMessageTable.id))
+        .all()
+        .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
+        .find((message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed)
+    },
+    getCurrentCompaction() {
+      return db
+        .select()
+        .from(SessionMessageTable)
+        .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
+        .orderBy(desc(SessionMessageTable.id))
+        .all()
+        .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
+        .find((message): message is SessionMessage.Compaction => message.type === "compaction")
+    },
+    getCurrentShell(callID) {
+      return db
+        .select()
+        .from(SessionMessageTable)
+        .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "shell")))
+        .orderBy(desc(SessionMessageTable.id))
+        .all()
+        .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
+        .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
+    },
+    updateAssistant(assistant) {
+      const { id, type, ...data } = assistant
+      db.update(SessionMessageTable)
+        .set({ data: encodeMessageData(data) })
+        .where(
+          and(
+            eq(SessionMessageTable.id, id),
+            eq(SessionMessageTable.session_id, sessionID),
+            eq(SessionMessageTable.type, type),
+          ),
+        )
+        .run()
+    },
+    updateCompaction(compaction) {
+      const { id, type, ...data } = compaction
+      db.update(SessionMessageTable)
+        .set({ data: encodeMessageData(data) })
+        .where(
+          and(
+            eq(SessionMessageTable.id, id),
+            eq(SessionMessageTable.session_id, sessionID),
+            eq(SessionMessageTable.type, type),
+          ),
+        )
+        .run()
+    },
+    updateShell(shell) {
+      const { id, type, ...data } = shell
+      db.update(SessionMessageTable)
+        .set({ data: encodeMessageData(data) })
+        .where(
+          and(
+            eq(SessionMessageTable.id, id),
+            eq(SessionMessageTable.session_id, sessionID),
+            eq(SessionMessageTable.type, type),
+          ),
+        )
+        .run()
+    },
+    appendMessage(message) {
+      const { id, type, ...data } = message
+      db.insert(SessionMessageTable)
+        .values([
+          {
+            id,
+            session_id: sessionID,
+            type,
+            time_created: DateTime.toEpochMillis(message.time.created),
+            data: encodeMessageData(data),
+          },
+        ])
+        .run()
+    },
+    finish() {},
+  }
+}
+
+function update(db: Database.TxOrDb, event: SessionEvent.Event) {
+  SessionMessageUpdater.update(sqlite(db, event.data.sessionID), event)
+}
+
+export default [
+  SyncEvent.project(SessionEvent.AgentSwitched.Sync, (db, data, event) => {
+    db.update(SessionTable)
+      .set({
+        agent: data.agent,
+        time_updated: DateTime.toEpochMillis(data.timestamp),
+      })
+      .where(eq(SessionTable.id, data.sessionID))
+      .run()
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.agent.switched", data })
+  }),
+  SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => {
+    db.update(SessionTable)
+      .set({
+        model: {
+          id: data.id,
+          providerID: data.providerID,
+          variant: data.variant,
+        },
+        time_updated: DateTime.toEpochMillis(data.timestamp),
+      })
+      .where(eq(SessionTable.id, data.sessionID))
+      .run()
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.model.switched", data })
+  }),
+  SyncEvent.project(SessionEvent.Prompted.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.prompted", data })
+  }),
+  SyncEvent.project(SessionEvent.Synthetic.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.synthetic", data })
+  }),
+  SyncEvent.project(SessionEvent.Shell.Started.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.started", data })
+  }),
+  SyncEvent.project(SessionEvent.Shell.Ended.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.ended", data })
+  }),
+  SyncEvent.project(SessionEvent.Step.Started.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.started", data })
+  }),
+  SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data })
+  }),
+  SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data })
+  }),
+  SyncEvent.project(SessionEvent.Text.Delta.Sync, () => {}),
+  SyncEvent.project(SessionEvent.Text.Ended.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.ended", data })
+  }),
+  SyncEvent.project(SessionEvent.Tool.Input.Started.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.started", data })
+  }),
+  SyncEvent.project(SessionEvent.Tool.Input.Delta.Sync, () => {}),
+  SyncEvent.project(SessionEvent.Tool.Input.Ended.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.ended", data })
+  }),
+  SyncEvent.project(SessionEvent.Tool.Called.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.called", data })
+  }),
+  SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data })
+  }),
+  SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.error", data })
+  }),
+  SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data })
+  }),
+  SyncEvent.project(SessionEvent.Reasoning.Delta.Sync, () => {}),
+  SyncEvent.project(SessionEvent.Reasoning.Ended.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.ended", data })
+  }),
+  SyncEvent.project(SessionEvent.Retried.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.retried", data })
+  }),
+  SyncEvent.project(SessionEvent.Compaction.Started.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.started", data })
+  }),
+  SyncEvent.project(SessionEvent.Compaction.Delta.Sync, () => {}),
+  SyncEvent.project(SessionEvent.Compaction.Ended.Sync, (db, data, event) => {
+    update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.ended", data })
+  }),
+]

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

@@ -5,7 +5,8 @@ import { SyncEvent } from "@/sync"
 import * as Session from "./session"
 import { MessageV2 } from "./message-v2"
 import { SessionTable, MessageTable, PartTable } from "./session.sql"
-import * as Log from "@opencode-ai/core/util/log"
+import { Log } from "@opencode-ai/core/util/log"
+import nextProjectors from "./projectors-next"
 
 const log = Log.create({ service: "session.projector" })
 
@@ -136,4 +137,6 @@ export default [
       log.warn("ignored late part update", { partID: id, messageID, sessionID })
     }
   }),
+
+  ...nextProjectors,
 ]

+ 116 - 3
packages/opencode/src/session/prompt.ts

@@ -54,6 +54,13 @@ import { InstanceState } from "@/effect/instance-state"
 import { TaskTool, type TaskPromptOps } from "@/tool/task"
 import { SessionRunState } from "./run-state"
 import { EffectBridge } from "@/effect/bridge"
+import { EventV2 } from "@/v2/event"
+import { SessionEvent } from "@/v2/session-event"
+import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
+import * as DateTime from "effect/DateTime"
+import { eq } from "@/storage/db"
+import * as Database from "@/storage/db"
+import { SessionTable } from "./session.sql"
 
 // @ts-ignore
 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -785,6 +792,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
               providerID: model.providerID,
             }
             yield* sessions.updateMessage(msg)
+            const callID = ulid()
+            const started = Date.now()
             const part: MessageV2.ToolPart = {
               type: "tool",
               id: PartID.ascending(),
@@ -794,11 +803,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
               callID: ulid(),
               state: {
                 status: "running",
-                time: { start: Date.now() },
+                time: { start: started },
                 input: { command: input.command },
               },
             }
             yield* sessions.updatePart(part)
+            EventV2.run(SessionEvent.Shell.Started.Sync, {
+              sessionID: input.sessionID,
+              timestamp: DateTime.makeUnsafe(started),
+              callID,
+              command: input.command,
+            })
             return { msg, part, cwd: ctx.directory }
           }).pipe(Effect.ensuring(markReady))
 
@@ -813,14 +828,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
               if (aborted) {
                 output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
               }
+              const completed = Date.now()
+              EventV2.run(SessionEvent.Shell.Ended.Sync, {
+                sessionID: input.sessionID,
+                timestamp: DateTime.makeUnsafe(completed),
+                callID: part.callID,
+                output,
+              })
               if (!msg.time.completed) {
-                msg.time.completed = Date.now()
+                msg.time.completed = completed
                 yield* sessions.updateMessage(msg)
               }
               if (part.state.status === "running") {
                 part.state = {
                   status: "completed",
-                  time: { ...part.state.time, end: Date.now() },
+                  time: { ...part.state.time, end: completed },
                   input: part.state.input,
                   title: "",
                   metadata: { output, description: "" },
@@ -934,6 +956,34 @@ NOTE: At any point in time through this workflow you should feel free to ask the
         format: input.format,
       }
 
+      const current = Database.use((db) =>
+        db
+          .select({ agent: SessionTable.agent, model: SessionTable.model })
+          .from(SessionTable)
+          .where(eq(SessionTable.id, input.sessionID))
+          .get(),
+      )
+      if (current?.agent !== info.agent) {
+        EventV2.run(SessionEvent.AgentSwitched.Sync, {
+          sessionID: input.sessionID,
+          timestamp: DateTime.makeUnsafe(info.time.created),
+          agent: info.agent,
+        })
+      }
+      if (
+        current?.model?.providerID !== info.model.providerID ||
+        current.model.id !== info.model.modelID ||
+        current.model.variant !== info.model.variant
+      ) {
+        EventV2.run(SessionEvent.ModelSwitched.Sync, {
+          sessionID: input.sessionID,
+          timestamp: DateTime.makeUnsafe(info.time.created),
+          id: info.model.modelID,
+          providerID: info.model.providerID,
+          variant: info.model.variant,
+        })
+      }
+
       yield* Effect.addFinalizer(() => instruction.clear(info.id))
 
       type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
@@ -1250,6 +1300,69 @@ NOTE: At any point in time through this workflow you should feel free to ask the
 
       yield* sessions.updateMessage(info)
       for (const part of parts) yield* sessions.updatePart(part)
+      const nextPrompt = parts.reduce(
+        (result, part) => {
+          if (part.type === "text") {
+            if (part.synthetic) result.synthetic.push(part.text)
+            else result.text.push(part.text)
+          }
+          if (part.type === "file") {
+            result.files.push(
+              new FileAttachment({
+                uri: part.url,
+                mime: part.mime,
+                name: part.filename,
+                source: part.source
+                  ? new Source({
+                      start: part.source.text.start,
+                      end: part.source.text.end,
+                      text: part.source.text.value,
+                    })
+                  : undefined,
+              }),
+            )
+          }
+          if (part.type === "agent") {
+            result.agents.push(
+              new AgentAttachment({
+                name: part.name,
+                source: part.source
+                  ? new Source({
+                      start: part.source.start,
+                      end: part.source.end,
+                      text: part.source.value,
+                    })
+                  : undefined,
+              }),
+            )
+          }
+          return result
+        },
+        {
+          text: [] as string[],
+          files: [] as FileAttachment[],
+          agents: [] as AgentAttachment[],
+          synthetic: [] as string[],
+        },
+      )
+      // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+      EventV2.run(SessionEvent.Prompted.Sync, {
+        sessionID: input.sessionID,
+        timestamp: DateTime.makeUnsafe(info.time.created),
+        prompt: {
+          text: nextPrompt.text.join("\n"),
+          files: nextPrompt.files,
+          agents: nextPrompt.agents,
+        },
+      })
+      for (const text of nextPrompt.synthetic) {
+        // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
+        EventV2.run(SessionEvent.Synthetic.Sync, {
+          sessionID: input.sessionID,
+          timestamp: DateTime.makeUnsafe(info.time.created),
+          text,
+        })
+      }
 
       return { info, parts }
     }, Effect.scoped)

+ 16 - 9
packages/opencode/src/session/session.sql.ts

@@ -1,7 +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 { SessionMessage } from "../v2/session-message"
 import type { Snapshot } from "../snapshot"
 import type { Permission } from "../permission"
 import type { ProjectID } from "../project/schema"
@@ -11,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 SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
 
 export const SessionTable = sqliteTable(
   "session",
@@ -34,6 +35,12 @@ export const SessionTable = sqliteTable(
     summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
     revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
     permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
+    agent: text(),
+    model: text({ mode: "json" }).$type<{
+      id: string
+      providerID: string
+      variant?: string
+    }>(),
     ...Timestamps,
     time_compacting: integer(),
     time_archived: integer(),
@@ -96,22 +103,22 @@ export const TodoTable = sqliteTable(
   ],
 )
 
-export const SessionEntryTable = sqliteTable(
-  "session_entry",
+export const SessionMessageTable = sqliteTable(
+  "session_message",
   {
-    id: text().$type<SessionEntry.ID>().primaryKey(),
+    id: text().$type<SessionMessage.ID>().primaryKey(),
     session_id: text()
       .$type<SessionID>()
       .notNull()
       .references(() => SessionTable.id, { onDelete: "cascade" }),
-    type: text().$type<SessionEntry.Type>().notNull(),
+    type: text().$type<SessionMessage.Type>().notNull(),
     ...Timestamps,
-    data: text({ mode: "json" }).notNull().$type<Omit<SessionEntry.Entry, "type" | "id">>(),
+    data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
   },
   (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),
+    index("session_message_session_idx").on(table.session_id),
+    index("session_message_session_type_idx").on(table.session_id, table.type),
+    index("session_message_time_created_idx").on(table.time_created),
   ],
 )
 

+ 29 - 0
packages/opencode/src/session/session.ts

@@ -32,6 +32,7 @@ import { Snapshot } from "@/snapshot"
 import { ProjectID } from "../project/schema"
 import { WorkspaceID } from "../control-plane/schema"
 import { SessionID, MessageID, PartID } from "./schema"
+import { ModelID, ProviderID } from "@/provider/schema"
 
 import type { Provider } from "@/provider/provider"
 import { Permission } from "@/permission"
@@ -78,6 +79,10 @@ export function fromRow(row: SessionRow): Info {
     path: row.path ?? undefined,
     parentID: row.parent_id ?? undefined,
     title: row.title,
+    agent: row.agent ?? undefined,
+    model: row.model
+      ? { id: ModelID.make(row.model.id), providerID: ProviderID.make(row.model.providerID), variant: row.model.variant }
+      : undefined,
     version: row.version,
     summary,
     share,
@@ -102,6 +107,8 @@ export function toRow(info: Info) {
     directory: info.directory,
     path: info.path,
     title: info.title,
+    agent: info.agent,
+    model: info.model,
     version: info.version,
     share_url: info.share?.url,
     summary_additions: info.summary?.additions,
@@ -160,6 +167,12 @@ const Revert = Schema.Struct({
   diff: optionalOmitUndefined(Schema.String),
 })
 
+const Model = Schema.Struct({
+  id: ModelID,
+  providerID: ProviderID,
+  variant: optionalOmitUndefined(Schema.String),
+})
+
 export const Info = Schema.Struct({
   id: SessionID,
   slug: Schema.String,
@@ -171,6 +184,8 @@ export const Info = Schema.Struct({
   summary: optionalOmitUndefined(Summary),
   share: optionalOmitUndefined(Share),
   title: Schema.String,
+  agent: optionalOmitUndefined(Schema.String),
+  model: optionalOmitUndefined(Model),
   version: Schema.String,
   time: Time,
   permission: optionalOmitUndefined(Permission.Ruleset),
@@ -201,6 +216,8 @@ export const CreateInput = Schema.optional(
   Schema.Struct({
     parentID: Schema.optional(SessionID),
     title: Schema.optional(Schema.String),
+    agent: Schema.optional(Schema.String),
+    model: Schema.optional(Model),
     permission: Schema.optional(Permission.Ruleset),
     workspaceID: Schema.optional(WorkspaceID),
   }),
@@ -272,6 +289,8 @@ const UpdatedInfo = Schema.Struct({
   summary: Schema.optional(Schema.NullOr(Summary)),
   share: Schema.optional(UpdatedShare),
   title: Schema.optional(Schema.NullOr(Schema.String)),
+  agent: Schema.optional(Schema.NullOr(Schema.String)),
+  model: Schema.optional(Schema.NullOr(Model)),
   version: Schema.optional(Schema.NullOr(Schema.String)),
   time: Schema.optional(UpdatedTime),
   permission: Schema.optional(Schema.NullOr(Permission.Ruleset)),
@@ -404,6 +423,8 @@ export interface Interface {
   readonly create: (input?: {
     parentID?: SessionID
     title?: string
+    agent?: string
+    model?: Schema.Schema.Type<typeof Model>
     permission?: Permission.Ruleset
     workspaceID?: WorkspaceID
   }) => Effect.Effect<Info>
@@ -464,6 +485,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
     const createNext = Effect.fn("Session.createNext")(function* (input: {
       id?: SessionID
       title?: string
+      agent?: string
+      model?: Schema.Schema.Type<typeof Model>
       parentID?: SessionID
       workspaceID?: WorkspaceID
       directory: string
@@ -481,6 +504,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
         workspaceID: input.workspaceID,
         parentID: input.parentID,
         title: input.title ?? createDefaultTitle(!!input.parentID),
+        agent: input.agent,
+        model: input.model,
         permission: input.permission,
         time: {
           created: Date.now(),
@@ -591,6 +616,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
     const create = Effect.fn("Session.create")(function* (input?: {
       parentID?: SessionID
       title?: string
+      agent?: string
+      model?: Schema.Schema.Type<typeof Model>
       permission?: Permission.Ruleset
       workspaceID?: WorkspaceID
     }) {
@@ -601,6 +628,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
         directory: ctx.directory,
         path: sessionPath(ctx.worktree, ctx.directory),
         title: input?.title,
+        agent: input?.agent,
+        model: input?.model,
         permission: input?.permission,
         workspaceID: input?.workspaceID ?? workspace,
       })

+ 4 - 4
packages/opencode/src/sync/index.ts

@@ -46,7 +46,7 @@ export type Properties<Def extends Definition = Definition> = EffectSchema.Schem
 
 export type SerializedEvent<Def extends Definition = Definition> = Event<Def> & { type: string }
 
-type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
+type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void
 type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
 type PublishContext = {
   instance?: InstanceContext
@@ -255,7 +255,7 @@ export function define<
 
 export function project<Def extends Definition>(
   def: Def,
-  func: (db: Database.TxOrDb, data: Event<Def>["data"]) => void,
+  func: (db: Database.TxOrDb, data: Event<Def>["data"], event: Event<Def>) => void,
 ): [Definition, ProjectorFunc] {
   return [def, func as ProjectorFunc]
 }
@@ -277,7 +277,7 @@ function process<Def extends Definition>(
   // idempotent: need to ignore any events already logged
 
   Database.transaction((tx) => {
-    projector(tx, event.data)
+    projector(tx, event.data, event)
 
     if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
       tx.insert(EventSequenceTable)
@@ -308,7 +308,7 @@ function process<Def extends Definition>(
         }
 
         const result = convertEvent(def.type, event.data)
-        const publish = (data: unknown) => ProjectBus.publish(def, data as Properties<Def>)
+        const publish = (data: unknown) => ProjectBus.publish(def, data as Properties<Def>, { id: event.id })
         if (result instanceof Promise) {
           void result.then(publish)
         } else {

+ 1 - 1
packages/opencode/src/util/effect-zod.ts

@@ -90,7 +90,7 @@ function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny {
   // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
   // on the inner Zod rather than a transform wrapper — so optional ASTs whose
   // encoding resolves a default from Option.none() route through body()/opt().
-  const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration"
+  const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0)
   const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
   const base = hasTransform ? encoded(ast) : body(ast)
   return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base

+ 53 - 0
packages/opencode/src/v2/event.ts

@@ -0,0 +1,53 @@
+import { Identifier } from "@/id/id"
+import { SyncEvent } from "@/sync"
+import { withStatics } from "@/util/schema"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import * as Schema from "effect/Schema"
+
+export const ID = Schema.String.pipe(
+  Schema.brand("Event.ID"),
+  withStatics((s) => ({
+    create: () => s.make(Identifier.create("evt", "ascending")),
+  })),
+)
+export type ID = Schema.Schema.Type<typeof ID>
+
+export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
+  type: Type
+  schema: Fields
+  aggregate: string
+  version?: number
+}) {
+  const Payload = Schema.Struct({
+    id: ID,
+    metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
+    type: Schema.Literal(input.type),
+    data: Schema.Struct(input.schema),
+  }).annotate({
+    identifier: input.type,
+  })
+
+  const Sync = SyncEvent.define({
+    type: input.type,
+    version: input.version ?? 1,
+    aggregate: input.aggregate,
+    schema: Payload.fields.data,
+  })
+
+  return Object.assign(Payload, {
+    Sync,
+    version: input.version,
+    aggregate: input.aggregate,
+  })
+}
+
+export function run<Def extends SyncEvent.Definition>(
+  def: Def,
+  data: SyncEvent.Event<Def>["data"],
+  options?: { publish?: boolean },
+) {
+  if (!Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) return
+  SyncEvent.run(def, data, options)
+}
+
+export * as EventV2 from "./event"

+ 10 - 0
packages/opencode/src/v2/schema.ts

@@ -0,0 +1,10 @@
+import { DateTime, Schema, SchemaGetter } from "effect"
+
+export const DateTimeUtcFromMillis = Schema.Finite.pipe(
+  Schema.decodeTo(Schema.DateTimeUtc, {
+    decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)),
+    encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)),
+  }),
+)
+
+export * as V2Schema from "./schema"

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

@@ -1,261 +0,0 @@
-import { produce, type WritableDraft } from "immer"
-import { SessionEvent } from "./session-event"
-import { SessionEntry } from "./session-entry"
-
-export type MemoryState = {
-  entries: SessionEntry.Entry[]
-  pending: SessionEntry.Entry[]
-}
-
-export interface Adapter<Result> {
-  readonly getCurrentAssistant: () => SessionEntry.Assistant | undefined
-  readonly updateAssistant: (assistant: SessionEntry.Assistant) => void
-  readonly appendEntry: (entry: SessionEntry.Entry) => void
-  readonly appendPending: (entry: SessionEntry.Entry) => void
-  readonly finish: () => Result
-}
-
-export function memory(state: MemoryState): Adapter<MemoryState> {
-  const activeAssistantIndex = () =>
-    state.entries.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed)
-
-  return {
-    getCurrentAssistant() {
-      const index = activeAssistantIndex()
-      if (index < 0) return
-      const assistant = state.entries[index]
-      return assistant?.type === "assistant" ? assistant : undefined
-    },
-    updateAssistant(assistant) {
-      const index = activeAssistantIndex()
-      if (index < 0) return
-      const current = state.entries[index]
-      if (current?.type !== "assistant") return
-      state.entries[index] = assistant
-    },
-    appendEntry(entry) {
-      state.entries.push(entry)
-    },
-    appendPending(entry) {
-      state.pending.push(entry)
-    },
-    finish() {
-      return state
-    },
-  }
-}
-
-export function stepWith<Result>(adapter: Adapter<Result>, event: SessionEvent.Event): Result {
-  const currentAssistant = adapter.getCurrentAssistant()
-  type DraftAssistant = WritableDraft<SessionEntry.Assistant>
-  type DraftTool = WritableDraft<SessionEntry.AssistantTool>
-  type DraftText = WritableDraft<SessionEntry.AssistantText>
-  type DraftReasoning = WritableDraft<SessionEntry.AssistantReasoning>
-
-  const latestTool = (assistant: DraftAssistant | undefined, callID?: string) =>
-    assistant?.content.findLast(
-      (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID),
-    )
-
-  const latestText = (assistant: DraftAssistant | undefined) =>
-    assistant?.content.findLast((item): item is DraftText => item.type === "text")
-
-  const latestReasoning = (assistant: DraftAssistant | undefined) =>
-    assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning")
-
-  SessionEvent.Event.match(event, {
-    prompt: (event) => {
-      const entry = SessionEntry.User.fromEvent(event)
-      if (currentAssistant) {
-        adapter.appendPending(entry)
-        return
-      }
-      adapter.appendEntry(entry)
-    },
-    synthetic: (event) => {
-      adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event))
-    },
-    "step.started": (event) => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            draft.time.completed = event.timestamp
-          }),
-        )
-      }
-      adapter.appendEntry(SessionEntry.Assistant.fromEvent(event))
-    },
-    "step.ended": (event) => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            draft.time.completed = event.timestamp
-            draft.cost = event.cost
-            draft.tokens = event.tokens
-          }),
-        )
-      }
-    },
-    "text.started": () => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            draft.content.push({
-              type: "text",
-              text: "",
-            })
-          }),
-        )
-      }
-    },
-    "text.delta": (event) => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            const match = latestText(draft)
-            if (match) match.text += event.delta
-          }),
-        )
-      }
-    },
-    "text.ended": () => {},
-    "tool.input.started": (event) => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            draft.content.push({
-              type: "tool",
-              callID: event.callID,
-              name: event.name,
-              time: {
-                created: event.timestamp,
-              },
-              state: {
-                status: "pending",
-                input: "",
-              },
-            })
-          }),
-        )
-      }
-    },
-    "tool.input.delta": (event) => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            const match = latestTool(draft, event.callID)
-            // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
-            if (match && match.state.status === "pending") match.state.input += event.delta
-          }),
-        )
-      }
-    },
-    "tool.input.ended": () => {},
-    "tool.called": (event) => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            const match = latestTool(draft, event.callID)
-            if (match) {
-              match.time.ran = event.timestamp
-              match.state = {
-                status: "running",
-                input: event.input,
-              }
-            }
-          }),
-        )
-      }
-    },
-    "tool.success": (event) => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            const match = latestTool(draft, event.callID)
-            if (match && match.state.status === "running") {
-              match.state = {
-                status: "completed",
-                input: match.state.input,
-                output: event.output ?? "",
-                title: event.title,
-                metadata: event.metadata ?? {},
-                attachments: [...(event.attachments ?? [])],
-              }
-            }
-          }),
-        )
-      }
-    },
-    "tool.error": (event) => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            const match = latestTool(draft, event.callID)
-            if (match && match.state.status === "running") {
-              match.state = {
-                status: "error",
-                error: event.error,
-                input: match.state.input,
-                metadata: event.metadata ?? {},
-              }
-            }
-          }),
-        )
-      }
-    },
-    "reasoning.started": () => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            draft.content.push({
-              type: "reasoning",
-              text: "",
-            })
-          }),
-        )
-      }
-    },
-    "reasoning.delta": (event) => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            const match = latestReasoning(draft)
-            if (match) match.text += event.delta
-          }),
-        )
-      }
-    },
-    "reasoning.ended": (event) => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            const match = latestReasoning(draft)
-            if (match) match.text = event.text
-          }),
-        )
-      }
-    },
-    retried: (event) => {
-      if (currentAssistant) {
-        adapter.updateAssistant(
-          produce(currentAssistant, (draft) => {
-            draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)]
-          }),
-        )
-      }
-    },
-    compacted: (event) => {
-      adapter.appendEntry(SessionEntry.Compaction.fromEvent(event))
-    },
-  })
-
-  return adapter.finish()
-}
-
-export function step(old: MemoryState, event: SessionEvent.Event): MemoryState {
-  return produce(old, (draft) => {
-    stepWith(memory(draft as MemoryState), event)
-  })
-}
-
-export * as SessionEntryStepper from "./session-entry-stepper"

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

@@ -1,220 +0,0 @@
-import { Schema } from "effect"
-import { NonNegativeInt } from "@/util/schema"
-import { SessionEvent } from "./session-event"
-
-export const ID = SessionEvent.ID
-export type ID = Schema.Schema.Type<typeof ID>
-
-const Base = {
-  id: SessionEvent.ID,
-  metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
-  time: Schema.Struct({
-    created: Schema.DateTimeUtc,
-  }),
-}
-
-export class User extends Schema.Class<User>("Session.Entry.User")({
-  ...Base,
-  text: SessionEvent.Prompt.fields.text,
-  files: SessionEvent.Prompt.fields.files,
-  agents: SessionEvent.Prompt.fields.agents,
-  type: Schema.Literal("user"),
-  time: Schema.Struct({
-    created: Schema.DateTimeUtc,
-  }),
-}) {
-  static fromEvent(event: SessionEvent.Prompt) {
-    return new User({
-      id: event.id,
-      type: "user",
-      metadata: event.metadata,
-      text: event.text,
-      files: event.files,
-      agents: event.agents,
-      time: { created: event.timestamp },
-    })
-  }
-}
-
-export class Synthetic extends Schema.Class<Synthetic>("Session.Entry.Synthetic")({
-  ...SessionEvent.Synthetic.fields,
-  ...Base,
-  type: Schema.Literal("synthetic"),
-}) {
-  static fromEvent(event: SessionEvent.Synthetic) {
-    return new Synthetic({
-      ...event,
-      time: { created: event.timestamp },
-    })
-  }
-}
-
-export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
-  status: Schema.Literal("pending"),
-  input: 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: SessionEvent.FileAttachment.pipe(Schema.Array, 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),
-}) {}
-
-export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
-  Schema.toTaggedUnion("status"),
-)
-export type ToolState = Schema.Schema.Type<typeof ToolState>
-
-export class AssistantTool extends Schema.Class<AssistantTool>("Session.Entry.Assistant.Tool")({
-  type: Schema.Literal("tool"),
-  callID: Schema.String,
-  name: Schema.String,
-  state: ToolState,
-  time: Schema.Struct({
-    created: Schema.DateTimeUtc,
-    ran: Schema.DateTimeUtc.pipe(Schema.optional),
-    completed: Schema.DateTimeUtc.pipe(Schema.optional),
-    pruned: Schema.DateTimeUtc.pipe(Schema.optional),
-  }),
-}) {}
-
-export class AssistantText extends Schema.Class<AssistantText>("Session.Entry.Assistant.Text")({
-  type: Schema.Literal("text"),
-  text: Schema.String,
-}) {}
-
-export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Session.Entry.Assistant.Reasoning")({
-  type: Schema.Literal("reasoning"),
-  text: Schema.String,
-}) {}
-
-export class AssistantRetry extends Schema.Class<AssistantRetry>("Session.Entry.Assistant.Retry")({
-  attempt: NonNegativeInt,
-  error: SessionEvent.RetryError,
-  time: Schema.Struct({
-    created: Schema.DateTimeUtc,
-  }),
-}) {
-  static fromEvent(event: SessionEvent.Retried) {
-    return new AssistantRetry({
-      attempt: event.attempt,
-      error: event.error,
-      time: {
-        created: event.timestamp,
-      },
-    })
-  }
-}
-
-export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
-  Schema.toTaggedUnion("type"),
-)
-export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
-
-export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant")({
-  ...Base,
-  type: Schema.Literal("assistant"),
-  content: AssistantContent.pipe(Schema.Array),
-  retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
-  cost: Schema.Finite.pipe(Schema.optional),
-  tokens: Schema.Struct({
-    input: NonNegativeInt,
-    output: NonNegativeInt,
-    reasoning: NonNegativeInt,
-    cache: Schema.Struct({
-      read: NonNegativeInt,
-      write: NonNegativeInt,
-    }),
-  }).pipe(Schema.optional),
-  error: Schema.String.pipe(Schema.optional),
-  time: Schema.Struct({
-    created: Schema.DateTimeUtc,
-    completed: Schema.DateTimeUtc.pipe(Schema.optional),
-  }),
-}) {
-  static fromEvent(event: SessionEvent.Step.Started) {
-    return new Assistant({
-      id: event.id,
-      type: "assistant",
-      time: {
-        created: event.timestamp,
-      },
-      content: [],
-      retries: [],
-    })
-  }
-}
-
-export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
-  ...SessionEvent.Compacted.fields,
-  type: Schema.Literal("compaction"),
-  ...Base,
-}) {
-  static fromEvent(event: SessionEvent.Compacted) {
-    return new Compaction({
-      ...event,
-      type: "compaction",
-      time: { created: event.timestamp },
-    })
-  }
-}
-
-export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type"))
-
-export type Entry = Schema.Schema.Type<typeof Entry>
-
-export type Type = Entry["type"]
-
-/*
-export interface Interface {
-  readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry
-  readonly fromSession: (sessionID: SessionID) => Effect.Effect<Entry[], never>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEntry") {}
-
-export const layer: Layer.Layer<Service, never, never> = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const decodeEntry = Schema.decodeUnknownSync(Entry)
-
-    const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type })
-
-    const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) {
-      return Database.use((db) =>
-        db
-          .select()
-          .from(SessionEntryTable)
-          .where(eq(SessionEntryTable.session_id, sessionID))
-          .orderBy(SessionEntryTable.id)
-          .all()
-          .map((row) => decode(row)),
-      )
-    })
-
-    return Service.of({
-      decode,
-      fromSession,
-    })
-  }),
-)
-*/
-
-export * as SessionEntry from "./session-entry"

+ 320 - 381
packages/opencode/src/v2/session-event.ts

@@ -1,128 +1,119 @@
-import { Identifier } from "@/id/id"
-import { NonNegativeInt, withStatics } from "@/util/schema"
-import * as DateTime from "effect/DateTime"
+import { SessionID } from "@/session/schema"
+import { NonNegativeInt } from "@/util/schema"
+import { EventV2 } from "./event"
+import { FileAttachment, Prompt } from "./session-prompt"
 import { Schema } from "effect"
+export { FileAttachment }
+import { ToolOutput } from "./tool-output"
+import { ModelID, ProviderID } from "@/provider/schema"
+import { V2Schema } from "./schema"
 
-export namespace SessionEvent {
-  export const ID = Schema.String.pipe(
-    Schema.brand("Session.Event.ID"),
-    withStatics((s) => ({
-      create: () => s.make(Identifier.create("evt", "ascending")),
-    })),
-  )
-  export type ID = Schema.Schema.Type<typeof ID>
-  type Stamp = Schema.Schema.Type<typeof Schema.DateTimeUtc>
-  type BaseInput = {
-    id?: ID
-    metadata?: Record<string, unknown>
-    timestamp?: Stamp
-  }
+export const Source = Schema.Struct({
+  start: NonNegativeInt,
+  end: NonNegativeInt,
+  text: Schema.String,
+}).annotate({
+  identifier: "session.next.event.source",
+})
+export type Source = Schema.Schema.Type<typeof Source>
 
-  const Base = {
-    id: ID,
-    metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
-    timestamp: Schema.DateTimeUtc,
-  }
+const Base = {
+  timestamp: V2Schema.DateTimeUtcFromMillis,
+  sessionID: SessionID,
+}
 
-  export class Source extends Schema.Class<Source>("Session.Event.Source")({
-    start: NonNegativeInt,
-    end: NonNegativeInt,
-    text: Schema.String,
-  }) {}
-
-  export class FileAttachment extends Schema.Class<FileAttachment>("Session.Event.FileAttachment")({
-    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(input: FileAttachment) {
-      return new FileAttachment({
-        uri: input.uri,
-        mime: input.mime,
-        name: input.name,
-        description: input.description,
-        source: input.source,
-      })
-    }
-  }
+export const AgentSwitched = EventV2.define({
+  type: "session.next.agent.switched",
+  aggregate: "sessionID",
+  version: 1,
+  schema: {
+    ...Base,
+    agent: Schema.String,
+  },
+})
+export type AgentSwitched = Schema.Schema.Type<typeof AgentSwitched>
 
-  export class AgentAttachment extends Schema.Class<AgentAttachment>("Session.Event.AgentAttachment")({
-    name: Schema.String,
-    source: Source.pipe(Schema.optional),
-  }) {}
-
-  export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
-    message: Schema.String,
-    statusCode: NonNegativeInt.pipe(Schema.optional),
-    isRetryable: Schema.Boolean,
-    responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
-    responseBody: Schema.String.pipe(Schema.optional),
-    metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
-  }) {}
-
-  export class Prompt extends Schema.Class<Prompt>("Session.Event.Prompt")({
+export const ModelSwitched = EventV2.define({
+  type: "session.next.model.switched",
+  aggregate: "sessionID",
+  version: 1,
+  schema: {
     ...Base,
-    type: Schema.Literal("prompt"),
-    text: Schema.String,
-    files: Schema.Array(FileAttachment).pipe(Schema.optional),
-    agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
-  }) {
-    static create(input: BaseInput & { text: string; files?: FileAttachment[]; agents?: AgentAttachment[] }) {
-      return new Prompt({
-        id: input.id ?? ID.create(),
-        type: "prompt",
-        timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-        metadata: input.metadata,
-        text: input.text,
-        files: input.files,
-        agents: input.agents,
-      })
-    }
-  }
+    id: ModelID,
+    providerID: ProviderID,
+    variant: Schema.String.pipe(Schema.optional),
+  },
+})
+export type ModelSwitched = Schema.Schema.Type<typeof ModelSwitched>
+
+export const Prompted = EventV2.define({
+  type: "session.next.prompted",
+  aggregate: "sessionID",
+  version: 1,
+  schema: {
+    ...Base,
+    prompt: Prompt,
+  },
+})
+export type Prompted = Schema.Schema.Type<typeof Prompted>
 
-  export class Synthetic extends Schema.Class<Synthetic>("Session.Event.Synthetic")({
+export const Synthetic = EventV2.define({
+  type: "session.next.synthetic",
+  aggregate: "sessionID",
+  schema: {
     ...Base,
-    type: Schema.Literal("synthetic"),
     text: Schema.String,
-  }) {
-    static create(input: BaseInput & { text: string }) {
-      return new Synthetic({
-        id: input.id ?? ID.create(),
-        type: "synthetic",
-        timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-        metadata: input.metadata,
-        text: input.text,
-      })
-    }
-  }
+  },
+})
+export type Synthetic = Schema.Schema.Type<typeof Synthetic>
+
+export namespace Shell {
+  export const Started = EventV2.define({
+    type: "session.next.shell.started",
+    aggregate: "sessionID",
+    schema: {
+      ...Base,
+      callID: Schema.String,
+      command: Schema.String,
+    },
+  })
+  export type Started = Schema.Schema.Type<typeof Started>
+
+  export const Ended = EventV2.define({
+    type: "session.next.shell.ended",
+    aggregate: "sessionID",
+    schema: {
+      ...Base,
+      callID: Schema.String,
+      output: Schema.String,
+    },
+  })
+  export type Ended = Schema.Schema.Type<typeof Ended>
+}
 
-  export namespace Step {
-    export class Started extends Schema.Class<Started>("Session.Event.Step.Started")({
+export namespace Step {
+  export const Started = EventV2.define({
+    type: "session.next.step.started",
+    aggregate: "sessionID",
+    schema: {
       ...Base,
-      type: Schema.Literal("step.started"),
+      agent: Schema.String,
       model: Schema.Struct({
         id: Schema.String,
         providerID: Schema.String,
         variant: Schema.String.pipe(Schema.optional),
       }),
-    }) {
-      static create(input: BaseInput & { model: { id: string; providerID: string; variant?: string } }) {
-        return new Started({
-          id: input.id ?? ID.create(),
-          type: "step.started",
-          timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-          metadata: input.metadata,
-          model: input.model,
-        })
-      }
-    }
-
-    export class Ended extends Schema.Class<Ended>("Session.Event.Step.Ended")({
+      snapshot: Schema.String.pipe(Schema.optional),
+    },
+  })
+  export type Started = Schema.Schema.Type<typeof Started>
+
+  export const Ended = EventV2.define({
+    type: "session.next.step.ended",
+    aggregate: "sessionID",
+    schema: {
       ...Base,
-      type: Schema.Literal("step.ended"),
-      reason: Schema.String,
+      finish: Schema.String,
       cost: Schema.Finite,
       tokens: Schema.Struct({
         input: NonNegativeInt,
@@ -133,177 +124,118 @@ export namespace SessionEvent {
           write: NonNegativeInt,
         }),
       }),
-    }) {
-      static create(input: BaseInput & { reason: string; cost: number; tokens: Ended["tokens"] }) {
-        return new Ended({
-          id: input.id ?? ID.create(),
-          type: "step.ended",
-          timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-          metadata: input.metadata,
-          reason: input.reason,
-          cost: input.cost,
-          tokens: input.tokens,
-        })
-      }
-    }
-  }
+      snapshot: Schema.String.pipe(Schema.optional),
+    },
+  })
+  export type Ended = Schema.Schema.Type<typeof Ended>
+}
 
-  export namespace Text {
-    export class Started extends Schema.Class<Started>("Session.Event.Text.Started")({
+export namespace Text {
+  export const Started = EventV2.define({
+    type: "session.next.text.started",
+    aggregate: "sessionID",
+    schema: {
       ...Base,
-      type: Schema.Literal("text.started"),
-    }) {
-      static create(input: BaseInput = {}) {
-        return new Started({
-          id: input.id ?? ID.create(),
-          type: "text.started",
-          timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-          metadata: input.metadata,
-        })
-      }
-    }
-
-    export class Delta extends Schema.Class<Delta>("Session.Event.Text.Delta")({
+    },
+  })
+  export type Started = Schema.Schema.Type<typeof Started>
+
+  export const Delta = EventV2.define({
+    type: "session.next.text.delta",
+    aggregate: "sessionID",
+    schema: {
       ...Base,
-      type: Schema.Literal("text.delta"),
       delta: Schema.String,
-    }) {
-      static create(input: BaseInput & { delta: string }) {
-        return new Delta({
-          id: input.id ?? ID.create(),
-          type: "text.delta",
-          timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-          metadata: input.metadata,
-          delta: input.delta,
-        })
-      }
-    }
-
-    export class Ended extends Schema.Class<Ended>("Session.Event.Text.Ended")({
+    },
+  })
+  export type Delta = Schema.Schema.Type<typeof Delta>
+
+  export const Ended = EventV2.define({
+    type: "session.next.text.ended",
+    aggregate: "sessionID",
+    schema: {
       ...Base,
-      type: Schema.Literal("text.ended"),
       text: Schema.String,
-    }) {
-      static create(input: BaseInput & { text: string }) {
-        return new Ended({
-          id: input.id ?? ID.create(),
-          type: "text.ended",
-          timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-          metadata: input.metadata,
-          text: input.text,
-        })
-      }
-    }
-  }
+    },
+  })
+  export type Ended = Schema.Schema.Type<typeof Ended>
+}
 
-  export namespace Reasoning {
-    export class Started extends Schema.Class<Started>("Session.Event.Reasoning.Started")({
+export namespace Reasoning {
+  export const Started = EventV2.define({
+    type: "session.next.reasoning.started",
+    aggregate: "sessionID",
+    schema: {
       ...Base,
-      type: Schema.Literal("reasoning.started"),
-    }) {
-      static create(input: BaseInput = {}) {
-        return new Started({
-          id: input.id ?? ID.create(),
-          type: "reasoning.started",
-          timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-          metadata: input.metadata,
-        })
-      }
-    }
-
-    export class Delta extends Schema.Class<Delta>("Session.Event.Reasoning.Delta")({
+      reasoningID: Schema.String,
+    },
+  })
+  export type Started = Schema.Schema.Type<typeof Started>
+
+  export const Delta = EventV2.define({
+    type: "session.next.reasoning.delta",
+    aggregate: "sessionID",
+    schema: {
       ...Base,
-      type: Schema.Literal("reasoning.delta"),
+      reasoningID: Schema.String,
       delta: Schema.String,
-    }) {
-      static create(input: BaseInput & { delta: string }) {
-        return new Delta({
-          id: input.id ?? ID.create(),
-          type: "reasoning.delta",
-          timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-          metadata: input.metadata,
-          delta: input.delta,
-        })
-      }
-    }
-
-    export class Ended extends Schema.Class<Ended>("Session.Event.Reasoning.Ended")({
+    },
+  })
+  export type Delta = Schema.Schema.Type<typeof Delta>
+
+  export const Ended = EventV2.define({
+    type: "session.next.reasoning.ended",
+    aggregate: "sessionID",
+    schema: {
       ...Base,
-      type: Schema.Literal("reasoning.ended"),
+      reasoningID: Schema.String,
       text: Schema.String,
-    }) {
-      static create(input: BaseInput & { text: string }) {
-        return new Ended({
-          id: input.id ?? ID.create(),
-          type: "reasoning.ended",
-          timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-          metadata: input.metadata,
-          text: input.text,
-        })
-      }
-    }
-  }
+    },
+  })
+  export type Ended = Schema.Schema.Type<typeof Ended>
+}
 
-  export namespace Tool {
-    export namespace Input {
-      export class Started extends Schema.Class<Started>("Session.Event.Tool.Input.Started")({
+export namespace Tool {
+  export namespace Input {
+    export const Started = EventV2.define({
+      type: "session.next.tool.input.started",
+      aggregate: "sessionID",
+      schema: {
         ...Base,
         callID: Schema.String,
         name: Schema.String,
-        type: Schema.Literal("tool.input.started"),
-      }) {
-        static create(input: BaseInput & { callID: string; name: string }) {
-          return new Started({
-            id: input.id ?? ID.create(),
-            type: "tool.input.started",
-            timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-            metadata: input.metadata,
-            callID: input.callID,
-            name: input.name,
-          })
-        }
-      }
-
-      export class Delta extends Schema.Class<Delta>("Session.Event.Tool.Input.Delta")({
+      },
+    })
+    export type Started = Schema.Schema.Type<typeof Started>
+
+    export const Delta = EventV2.define({
+      type: "session.next.tool.input.delta",
+      aggregate: "sessionID",
+      schema: {
         ...Base,
         callID: Schema.String,
-        type: Schema.Literal("tool.input.delta"),
         delta: Schema.String,
-      }) {
-        static create(input: BaseInput & { callID: string; delta: string }) {
-          return new Delta({
-            id: input.id ?? ID.create(),
-            type: "tool.input.delta",
-            timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-            metadata: input.metadata,
-            callID: input.callID,
-            delta: input.delta,
-          })
-        }
-      }
-
-      export class Ended extends Schema.Class<Ended>("Session.Event.Tool.Input.Ended")({
+      },
+    })
+    export type Delta = Schema.Schema.Type<typeof Delta>
+
+    export const Ended = EventV2.define({
+      type: "session.next.tool.input.ended",
+      aggregate: "sessionID",
+      schema: {
         ...Base,
         callID: Schema.String,
-        type: Schema.Literal("tool.input.ended"),
         text: Schema.String,
-      }) {
-        static create(input: BaseInput & { callID: string; text: string }) {
-          return new Ended({
-            id: input.id ?? ID.create(),
-            type: "tool.input.ended",
-            timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-            metadata: input.metadata,
-            callID: input.callID,
-            text: input.text,
-          })
-        }
-      }
-    }
-
-    export class Called extends Schema.Class<Called>("Session.Event.Tool.Called")({
+      },
+    })
+    export type Ended = Schema.Schema.Type<typeof Ended>
+  }
+
+  export const Called = EventV2.define({
+    type: "session.next.tool.called",
+    aggregate: "sessionID",
+    schema: {
       ...Base,
-      type: Schema.Literal("tool.called"),
       callID: Schema.String,
       tool: Schema.String,
       input: Schema.Record(Schema.String, Schema.Unknown),
@@ -311,148 +243,155 @@ export namespace SessionEvent {
         executed: Schema.Boolean,
         metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
       }),
-    }) {
-      static create(
-        input: BaseInput & {
-          callID: string
-          tool: string
-          input: Record<string, unknown>
-          provider: Called["provider"]
-        },
-      ) {
-        return new Called({
-          id: input.id ?? ID.create(),
-          type: "tool.called",
-          timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-          metadata: input.metadata,
-          callID: input.callID,
-          tool: input.tool,
-          input: input.input,
-          provider: input.provider,
-        })
-      }
-    }
-
-    export class Success extends Schema.Class<Success>("Session.Event.Tool.Success")({
+    },
+  })
+  export type Called = Schema.Schema.Type<typeof Called>
+
+  export const Progress = EventV2.define({
+    type: "session.next.tool.progress",
+    aggregate: "sessionID",
+    schema: {
       ...Base,
-      type: Schema.Literal("tool.success"),
       callID: Schema.String,
-      title: Schema.String,
-      output: Schema.String.pipe(Schema.optional),
-      attachments: Schema.Array(FileAttachment).pipe(Schema.optional),
+      structured: ToolOutput.Structured,
+      content: Schema.Array(ToolOutput.Content),
+    },
+  })
+  export type Progress = Schema.Schema.Type<typeof Progress>
+
+  export const Success = EventV2.define({
+    type: "session.next.tool.success",
+    aggregate: "sessionID",
+    schema: {
+      ...Base,
+      callID: Schema.String,
+      structured: ToolOutput.Structured,
+      content: Schema.Array(ToolOutput.Content),
       provider: Schema.Struct({
         executed: Schema.Boolean,
         metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
       }),
-    }) {
-      static create(
-        input: BaseInput & {
-          callID: string
-          title: string
-          output?: string
-          attachments?: FileAttachment[]
-          provider: Success["provider"]
-        },
-      ) {
-        return new Success({
-          id: input.id ?? ID.create(),
-          type: "tool.success",
-          timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-          metadata: input.metadata,
-          callID: input.callID,
-          title: input.title,
-          output: input.output,
-          attachments: input.attachments,
-          provider: input.provider,
-        })
-      }
-    }
-
-    export class Error extends Schema.Class<Error>("Session.Event.Tool.Error")({
+    },
+  })
+  export type Success = Schema.Schema.Type<typeof Success>
+
+  export const Error = EventV2.define({
+    type: "session.next.tool.error",
+    aggregate: "sessionID",
+    schema: {
       ...Base,
-      type: Schema.Literal("tool.error"),
       callID: Schema.String,
-      error: Schema.String,
+      error: Schema.Struct({
+        type: Schema.String,
+        message: Schema.String,
+      }),
       provider: Schema.Struct({
         executed: Schema.Boolean,
         metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
       }),
-    }) {
-      static create(input: BaseInput & { callID: string; error: string; provider: Error["provider"] }) {
-        return new Error({
-          id: input.id ?? ID.create(),
-          type: "tool.error",
-          timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-          metadata: input.metadata,
-          callID: input.callID,
-          error: input.error,
-          provider: input.provider,
-        })
-      }
-    }
-  }
+    },
+  })
+  export type Error = Schema.Schema.Type<typeof Error>
+}
+
+export const RetryError = Schema.Struct({
+  message: Schema.String,
+  statusCode: NonNegativeInt.pipe(Schema.optional),
+  isRetryable: Schema.Boolean,
+  responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
+  responseBody: Schema.String.pipe(Schema.optional),
+  metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
+}).annotate({
+  identifier: "session.next.retry_error",
+})
+export type RetryError = Schema.Schema.Type<typeof RetryError>
 
-  export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
+export const Retried = EventV2.define({
+  type: "session.next.retried",
+  aggregate: "sessionID",
+  schema: {
     ...Base,
-    type: Schema.Literal("retried"),
     attempt: NonNegativeInt,
     error: RetryError,
-  }) {
-    static create(input: BaseInput & { attempt: number; error: RetryError }) {
-      return new Retried({
-        id: input.id ?? ID.create(),
-        type: "retried",
-        timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-        metadata: input.metadata,
-        attempt: input.attempt,
-        error: input.error,
-      })
-    }
-  }
+  },
+})
+export type Retried = Schema.Schema.Type<typeof Retried>
 
-  export class Compacted extends Schema.Class<Compacted>("Session.Event.Compated")({
-    ...Base,
-    type: Schema.Literal("compacted"),
-    auto: Schema.Boolean,
-    overflow: Schema.Boolean.pipe(Schema.optional),
-  }) {
-    static create(input: BaseInput & { auto: boolean; overflow?: boolean }) {
-      return new Compacted({
-        id: input.id ?? ID.create(),
-        type: "compacted",
-        timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
-        metadata: input.metadata,
-        auto: input.auto,
-        overflow: input.overflow,
-      })
-    }
-  }
+export namespace Compaction {
+  export const Started = EventV2.define({
+    type: "session.next.compaction.started",
+    aggregate: "sessionID",
+    schema: {
+      ...Base,
+      reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
+    },
+  })
+  export type Started = Schema.Schema.Type<typeof Started>
 
-  export const Event = Schema.Union(
-    [
-      Prompt,
-      Synthetic,
-      Step.Started,
-      Step.Ended,
-      Text.Started,
-      Text.Delta,
-      Text.Ended,
-      Tool.Input.Started,
-      Tool.Input.Delta,
-      Tool.Input.Ended,
-      Tool.Called,
-      Tool.Success,
-      Tool.Error,
-      Reasoning.Started,
-      Reasoning.Delta,
-      Reasoning.Ended,
-      Retried,
-      Compacted,
-    ],
-    {
-      mode: "oneOf",
+  export const Delta = EventV2.define({
+    type: "session.next.compaction.delta",
+    aggregate: "sessionID",
+    schema: {
+      ...Base,
+      text: Schema.String,
     },
-  ).pipe(Schema.toTaggedUnion("type"))
-  export type Event = Schema.Schema.Type<typeof Event>
-  export type Type = Event["type"]
+  })
+
+  export const Ended = EventV2.define({
+    type: "session.next.compaction.ended",
+    aggregate: "sessionID",
+    schema: {
+      ...Base,
+      text: Schema.String,
+      include: Schema.String.pipe(Schema.optional),
+    },
+  })
+  export type Ended = Schema.Schema.Type<typeof Ended>
 }
+
+export const All = Schema.Union(
+  [
+    AgentSwitched,
+    ModelSwitched,
+    Prompted,
+    Synthetic,
+    Shell.Started,
+    Shell.Ended,
+    Step.Started,
+    Step.Ended,
+    Text.Started,
+    Text.Delta,
+    Text.Ended,
+    Tool.Input.Started,
+    Tool.Input.Delta,
+    Tool.Input.Ended,
+    Tool.Called,
+    Tool.Progress,
+    Tool.Success,
+    Tool.Error,
+    Reasoning.Started,
+    Reasoning.Delta,
+    Reasoning.Ended,
+    Retried,
+    Compaction.Started,
+    Compaction.Delta,
+    Compaction.Ended,
+  ],
+  {
+    mode: "oneOf",
+  },
+).pipe(Schema.toTaggedUnion("type"))
+
+// user
+// assistant
+// assistant
+// assistant
+// user
+// compaction marker
+// -> text
+// assistant
+
+export type Event = Schema.Schema.Type<typeof All>
+export type Type = Event["type"]
+
+export * as SessionEvent from "./session-event"

+ 411 - 0
packages/opencode/src/v2/session-message-updater.ts

@@ -0,0 +1,411 @@
+import { produce, type WritableDraft } from "immer"
+import { SessionEvent } from "./session-event"
+import { SessionMessage } from "./session-message"
+
+export type MemoryState = {
+  messages: SessionMessage.Message[]
+}
+
+export interface Adapter<Result> {
+  readonly getCurrentAssistant: () => SessionMessage.Assistant | undefined
+  readonly getCurrentCompaction: () => SessionMessage.Compaction | undefined
+  readonly getCurrentShell: (callID: string) => SessionMessage.Shell | undefined
+  readonly updateAssistant: (assistant: SessionMessage.Assistant) => void
+  readonly updateCompaction: (compaction: SessionMessage.Compaction) => void
+  readonly updateShell: (shell: SessionMessage.Shell) => void
+  readonly appendMessage: (message: SessionMessage.Message) => void
+  readonly finish: () => Result
+}
+
+export function memory(state: MemoryState): Adapter<MemoryState> {
+  const activeAssistantIndex = () =>
+    state.messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
+  const activeCompactionIndex = () => state.messages.findLastIndex((message) => message.type === "compaction")
+  const activeShellIndex = (callID: string) =>
+    state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
+
+  return {
+    getCurrentAssistant() {
+      const index = activeAssistantIndex()
+      if (index < 0) return
+      const assistant = state.messages[index]
+      return assistant?.type === "assistant" ? assistant : undefined
+    },
+    getCurrentCompaction() {
+      const index = activeCompactionIndex()
+      if (index < 0) return
+      const compaction = state.messages[index]
+      return compaction?.type === "compaction" ? compaction : undefined
+    },
+    getCurrentShell(callID) {
+      const index = activeShellIndex(callID)
+      if (index < 0) return
+      const shell = state.messages[index]
+      return shell?.type === "shell" ? shell : undefined
+    },
+    updateAssistant(assistant) {
+      const index = activeAssistantIndex()
+      if (index < 0) return
+      const current = state.messages[index]
+      if (current?.type !== "assistant") return
+      state.messages[index] = assistant
+    },
+    updateCompaction(compaction) {
+      const index = activeCompactionIndex()
+      if (index < 0) return
+      const current = state.messages[index]
+      if (current?.type !== "compaction") return
+      state.messages[index] = compaction
+    },
+    updateShell(shell) {
+      const index = activeShellIndex(shell.callID)
+      if (index < 0) return
+      const current = state.messages[index]
+      if (current?.type !== "shell") return
+      state.messages[index] = shell
+    },
+    appendMessage(message) {
+      state.messages.push(message)
+    },
+    finish() {
+      return state
+    },
+  }
+}
+
+export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Event): Result {
+  const currentAssistant = adapter.getCurrentAssistant()
+  type DraftAssistant = WritableDraft<SessionMessage.Assistant>
+  type DraftTool = WritableDraft<SessionMessage.AssistantTool>
+  type DraftText = WritableDraft<SessionMessage.AssistantText>
+  type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
+
+  const latestTool = (assistant: DraftAssistant | undefined, callID?: string) =>
+    assistant?.content.findLast(
+      (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
+    )
+
+  const latestText = (assistant: DraftAssistant | undefined) =>
+    assistant?.content.findLast((item): item is DraftText => item.type === "text")
+
+  const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) =>
+    assistant?.content.findLast(
+      (item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID,
+    )
+
+  SessionEvent.All.match(event, {
+    "session.next.agent.switched": (event) => {
+      adapter.appendMessage(
+        new SessionMessage.AgentSwitched({
+          id: event.id,
+          type: "agent-switched",
+          metadata: event.metadata,
+          agent: event.data.agent,
+          time: { created: event.data.timestamp },
+        }),
+      )
+    },
+    "session.next.model.switched": (event) => {
+      adapter.appendMessage(
+        new SessionMessage.ModelSwitched({
+          id: event.id,
+          type: "model-switched",
+          metadata: event.metadata,
+          model: {
+            id: event.data.id,
+            providerID: event.data.providerID,
+            variant: event.data.variant,
+          },
+          time: { created: event.data.timestamp },
+        }),
+      )
+    },
+    "session.next.prompted": (event) => {
+      adapter.appendMessage(
+        new SessionMessage.User({
+          id: event.id,
+          type: "user",
+          metadata: event.metadata,
+          text: event.data.prompt.text,
+          files: event.data.prompt.files,
+          agents: event.data.prompt.agents,
+          time: { created: event.data.timestamp },
+        }),
+      )
+    },
+    "session.next.synthetic": (event) => {
+      adapter.appendMessage(
+        new SessionMessage.Synthetic({
+          sessionID: event.data.sessionID,
+          text: event.data.text,
+          id: event.id,
+          type: "synthetic",
+          time: { created: event.data.timestamp },
+        }),
+      )
+    },
+    "session.next.shell.started": (event) => {
+      adapter.appendMessage(
+        new SessionMessage.Shell({
+          id: event.id,
+          type: "shell",
+          metadata: event.metadata,
+          callID: event.data.callID,
+          command: event.data.command,
+          output: "",
+          time: { created: event.data.timestamp },
+        }),
+      )
+    },
+    "session.next.shell.ended": (event) => {
+      const currentShell = adapter.getCurrentShell(event.data.callID)
+      if (currentShell) {
+        adapter.updateShell(
+          produce(currentShell, (draft) => {
+            draft.output = event.data.output
+            draft.time.completed = event.data.timestamp
+          }),
+        )
+      }
+    },
+    "session.next.step.started": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            draft.time.completed = event.data.timestamp
+          }),
+        )
+      }
+      adapter.appendMessage(
+        new SessionMessage.Assistant({
+          id: event.id,
+          type: "assistant",
+          agent: event.data.agent,
+          model: event.data.model,
+          time: { created: event.data.timestamp },
+          content: [],
+          snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
+        }),
+      )
+    },
+    "session.next.step.ended": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            draft.time.completed = event.data.timestamp
+            draft.finish = event.data.finish
+            draft.cost = event.data.cost
+            draft.tokens = event.data.tokens
+            if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot }
+          }),
+        )
+      }
+    },
+    "session.next.text.started": () => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            draft.content.push({
+              type: "text",
+              text: "",
+            })
+          }),
+        )
+      }
+    },
+    "session.next.text.delta": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            const match = latestText(draft)
+            if (match) match.text += event.data.delta
+          }),
+        )
+      }
+    },
+    "session.next.text.ended": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            const match = latestText(draft)
+            if (match) match.text = event.data.text
+          }),
+        )
+      }
+    },
+    "session.next.tool.input.started": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            draft.content.push({
+              type: "tool",
+              id: event.data.callID,
+              name: event.data.name,
+              time: {
+                created: event.data.timestamp,
+              },
+              state: {
+                status: "pending",
+                input: "",
+              },
+            })
+          }),
+        )
+      }
+    },
+    "session.next.tool.input.delta": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            const match = latestTool(draft, event.data.callID)
+            // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
+            if (match && match.state.status === "pending") match.state.input += event.data.delta
+          }),
+        )
+      }
+    },
+    "session.next.tool.input.ended": () => {},
+    "session.next.tool.called": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            const match = latestTool(draft, event.data.callID)
+            if (match) {
+              match.provider = event.data.provider
+              match.time.ran = event.data.timestamp
+              match.state = {
+                status: "running",
+                input: event.data.input,
+                structured: {},
+                content: [],
+              }
+            }
+          }),
+        )
+      }
+    },
+    "session.next.tool.progress": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            const match = latestTool(draft, event.data.callID)
+            if (match && match.state.status === "running") {
+              match.state.structured = event.data.structured
+              match.state.content = [...event.data.content]
+            }
+          }),
+        )
+      }
+    },
+    "session.next.tool.success": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            const match = latestTool(draft, event.data.callID)
+            if (match && match.state.status === "running") {
+              match.provider = event.data.provider
+              match.time.completed = event.data.timestamp
+              match.state = {
+                status: "completed",
+                input: match.state.input,
+                structured: event.data.structured,
+                content: [...event.data.content],
+              }
+            }
+          }),
+        )
+      }
+    },
+    "session.next.tool.error": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            const match = latestTool(draft, event.data.callID)
+            if (match && match.state.status === "running") {
+              match.provider = event.data.provider
+              match.time.completed = event.data.timestamp
+              match.state = {
+                status: "error",
+                error: event.data.error,
+                input: match.state.input,
+                structured: match.state.structured,
+                content: match.state.content,
+              }
+            }
+          }),
+        )
+      }
+    },
+    "session.next.reasoning.started": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            draft.content.push({
+              type: "reasoning",
+              id: event.data.reasoningID,
+              text: "",
+            })
+          }),
+        )
+      }
+    },
+    "session.next.reasoning.delta": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            const match = latestReasoning(draft, event.data.reasoningID)
+            if (match) match.text += event.data.delta
+          }),
+        )
+      }
+    },
+    "session.next.reasoning.ended": (event) => {
+      if (currentAssistant) {
+        adapter.updateAssistant(
+          produce(currentAssistant, (draft) => {
+            const match = latestReasoning(draft, event.data.reasoningID)
+            if (match) match.text = event.data.text
+          }),
+        )
+      }
+    },
+    "session.next.retried": () => {},
+    "session.next.compaction.started": (event) => {
+      adapter.appendMessage(
+        new SessionMessage.Compaction({
+          id: event.id,
+          type: "compaction",
+          metadata: event.metadata,
+          reason: event.data.reason,
+          summary: "",
+          time: { created: event.data.timestamp },
+        }),
+      )
+    },
+    "session.next.compaction.delta": (event) => {
+      const currentCompaction = adapter.getCurrentCompaction()
+      if (currentCompaction) {
+        adapter.updateCompaction(
+          produce(currentCompaction, (draft) => {
+            draft.summary += event.data.text
+          }),
+        )
+      }
+    },
+    "session.next.compaction.ended": (event) => {
+      const currentCompaction = adapter.getCurrentCompaction()
+      if (currentCompaction) {
+        adapter.updateCompaction(
+          produce(currentCompaction, (draft) => {
+            draft.summary = event.data.text
+            draft.include = event.data.include
+          }),
+        )
+      }
+    },
+  })
+
+  return adapter.finish()
+}
+
+export * as SessionMessageUpdater from "./session-message-updater"

+ 178 - 0
packages/opencode/src/v2/session-message.ts

@@ -0,0 +1,178 @@
+import { Schema } from "effect"
+import { Prompt } from "./session-prompt"
+import { SessionEvent } from "./session-event"
+import { EventV2 } from "./event"
+import { ToolOutput } from "./tool-output"
+import { V2Schema } from "./schema"
+
+export const ID = EventV2.ID
+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: V2Schema.DateTimeUtcFromMillis,
+  }),
+}
+
+export class AgentSwitched extends Schema.Class<AgentSwitched>("Session.Message.AgentSwitched")({
+  ...Base,
+  type: Schema.Literal("agent-switched"),
+  agent: SessionEvent.AgentSwitched.fields.data.fields.agent,
+}) {}
+
+export class ModelSwitched extends Schema.Class<ModelSwitched>("Session.Message.ModelSwitched")({
+  ...Base,
+  type: Schema.Literal("model-switched"),
+  model: Schema.Struct({
+    id: SessionEvent.ModelSwitched.fields.data.fields.id,
+    providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID,
+    variant: SessionEvent.ModelSwitched.fields.data.fields.variant,
+  }),
+}) {}
+
+export class User extends Schema.Class<User>("Session.Message.User")({
+  ...Base,
+  text: Prompt.fields.text,
+  files: Prompt.fields.files,
+  agents: Prompt.fields.agents,
+  type: Schema.Literal("user"),
+  time: Schema.Struct({
+    created: V2Schema.DateTimeUtcFromMillis,
+  }),
+}) {}
+
+export class Synthetic extends Schema.Class<Synthetic>("Session.Message.Synthetic")({
+  ...Base,
+  sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID,
+  text: SessionEvent.Synthetic.fields.data.fields.text,
+  type: Schema.Literal("synthetic"),
+}) {}
+
+export class Shell extends Schema.Class<Shell>("Session.Message.Shell")({
+  ...Base,
+  type: Schema.Literal("shell"),
+  callID: SessionEvent.Shell.Started.fields.data.fields.callID,
+  command: SessionEvent.Shell.Started.fields.data.fields.command,
+  output: Schema.String,
+  time: Schema.Struct({
+    created: V2Schema.DateTimeUtcFromMillis,
+    completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
+  }),
+}) {}
+
+export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Message.ToolState.Pending")({
+  status: Schema.Literal("pending"),
+  input: Schema.String,
+}) {}
+
+export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Message.ToolState.Running")({
+  status: Schema.Literal("running"),
+  input: Schema.Record(Schema.String, Schema.Unknown),
+  structured: ToolOutput.Structured,
+  content: ToolOutput.Content.pipe(Schema.Array),
+}) {}
+
+export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Session.Message.ToolState.Completed")({
+  status: Schema.Literal("completed"),
+  input: Schema.Record(Schema.String, Schema.Unknown),
+  attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
+  content: ToolOutput.Content.pipe(Schema.Array),
+  structured: ToolOutput.Structured,
+}) {}
+
+export class ToolStateError extends Schema.Class<ToolStateError>("Session.Message.ToolState.Error")({
+  status: Schema.Literal("error"),
+  input: Schema.Record(Schema.String, Schema.Unknown),
+  content: ToolOutput.Content.pipe(Schema.Array),
+  structured: ToolOutput.Structured,
+  error: Schema.Struct({
+    type: Schema.String,
+    message: Schema.String,
+  }),
+}) {}
+
+export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(
+  Schema.toTaggedUnion("status"),
+)
+export type ToolState = Schema.Schema.Type<typeof ToolState>
+
+export class AssistantTool extends Schema.Class<AssistantTool>("Session.Message.Assistant.Tool")({
+  type: Schema.Literal("tool"),
+  id: Schema.String,
+  name: Schema.String,
+  provider: Schema.Struct({
+    executed: Schema.Boolean,
+    metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
+  }).pipe(Schema.optional),
+  state: ToolState,
+  time: Schema.Struct({
+    created: V2Schema.DateTimeUtcFromMillis,
+    ran: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
+    completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
+    pruned: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
+  }),
+}) {}
+
+export class AssistantText extends Schema.Class<AssistantText>("Session.Message.Assistant.Text")({
+  type: Schema.Literal("text"),
+  text: Schema.String,
+}) {}
+
+export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Session.Message.Assistant.Reasoning")({
+  type: Schema.Literal("reasoning"),
+  id: Schema.String,
+  text: Schema.String,
+}) {}
+
+export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
+  Schema.toTaggedUnion("type"),
+)
+export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
+
+export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistant")({
+  ...Base,
+  type: Schema.Literal("assistant"),
+  agent: Schema.String,
+  model: SessionEvent.Step.Started.fields.data.fields.model,
+  content: AssistantContent.pipe(Schema.Array),
+  snapshot: Schema.Struct({
+    start: Schema.String.pipe(Schema.optional),
+    end: Schema.String.pipe(Schema.optional),
+  }).pipe(Schema.optional),
+  finish: Schema.String.pipe(Schema.optional),
+  cost: Schema.Finite.pipe(Schema.optional),
+  tokens: Schema.Struct({
+    input: Schema.Finite,
+    output: Schema.Finite,
+    reasoning: Schema.Finite,
+    cache: Schema.Struct({
+      read: Schema.Finite,
+      write: Schema.Finite,
+    }),
+  }).pipe(Schema.optional),
+  error: Schema.String.pipe(Schema.optional),
+  time: Schema.Struct({
+    created: V2Schema.DateTimeUtcFromMillis,
+    completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
+  }),
+}) {}
+
+export class Compaction extends Schema.Class<Compaction>("Session.Message.Compaction")({
+  type: Schema.Literal("compaction"),
+  reason: SessionEvent.Compaction.Started.fields.data.fields.reason,
+  summary: Schema.String,
+  include: Schema.String.pipe(Schema.optional),
+  ...Base,
+}) {}
+
+export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction])
+  .pipe(Schema.toTaggedUnion("type"))
+  .annotate({ identifier: "Session.Message" })
+
+export type Message = Schema.Schema.Type<typeof Message>
+
+export type Type = Message["type"]
+
+export * as SessionMessage from "./session-message"

+ 36 - 0
packages/opencode/src/v2/session-prompt.ts

@@ -0,0 +1,36 @@
+import * as Schema from "effect/Schema"
+
+export class Source extends Schema.Class<Source>("Prompt.Source")({
+  start: Schema.Finite,
+  end: Schema.Finite,
+  text: Schema.String,
+}) {}
+
+export class FileAttachment extends Schema.Class<FileAttachment>("Prompt.FileAttachment")({
+  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(input: FileAttachment) {
+    return new FileAttachment({
+      uri: input.uri,
+      mime: input.mime,
+      name: input.name,
+      description: input.description,
+      source: input.source,
+    })
+  }
+}
+
+export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.AgentAttachment")({
+  name: Schema.String,
+  source: Source.pipe(Schema.optional),
+}) {}
+
+export class Prompt extends Schema.Class<Prompt>("Prompt")({
+  text: Schema.String,
+  files: Schema.Array(FileAttachment).pipe(Schema.optional),
+  agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
+}) {}

+ 256 - 46
packages/opencode/src/v2/session.ts

@@ -1,69 +1,279 @@
-import { Context, Layer, Schema, Effect } from "effect"
-import { SessionEntry } from "./session-entry"
-import { Struct } from "effect"
-import { Session } from "@/session/session"
+import { SessionMessageTable, SessionTable } from "@/session/session.sql"
 import { SessionID } from "@/session/schema"
+import { WorkspaceID } from "@/control-plane/schema"
+import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db"
+import * as Database from "@/storage/db"
+import { Context, DateTime, Effect, Layer, Schema } from "effect"
+import { SessionMessage } from "./session-message"
+import type { Prompt } from "./session-prompt"
+import { EventV2 } from "./event"
+import { ProjectID } from "@/project/schema"
+import { ModelID, ProviderID } from "@/provider/schema"
+import { SessionEvent } from "./session-event"
+import { V2Schema } from "./schema"
 
-export const ID = SessionID
+export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({
+  identifier: "Session.Delivery",
+})
+export type Delivery = Schema.Schema.Type<typeof Delivery>
 
-export type ID = Schema.Schema.Type<typeof ID>
-
-export class PromptInput extends Schema.Class<PromptInput>("Session.PromptInput")({
-  ...Struct.omit(SessionEntry.User.fields, ["time", "type"]),
-  id: Schema.optionalKey(SessionEntry.ID),
-  sessionID: ID,
-}) {}
-
-export class CreateInput extends Schema.Class<CreateInput>("Session.CreateInput")({
-  id: Schema.optionalKey(ID),
-}) {}
+export const DefaultDelivery = "immediate" satisfies Delivery
 
 export class Info extends Schema.Class<Info>("Session.Info")({
-  id: ID,
+  id: SessionID,
+  parentID: SessionID.pipe(Schema.optional),
+  projectID: ProjectID,
+  workspaceID: WorkspaceID.pipe(Schema.optional),
+  path: Schema.String.pipe(Schema.optional),
+  agent: Schema.String.pipe(Schema.optional),
   model: Schema.Struct({
-    id: Schema.String,
-    providerID: Schema.String,
-    modelID: Schema.String,
+    id: ModelID,
+    providerID: ProviderID,
+    variant: Schema.String.pipe(Schema.optional),
   }).pipe(Schema.optional),
+  time: Schema.Struct({
+    created: V2Schema.DateTimeUtcFromMillis,
+    updated: V2Schema.DateTimeUtcFromMillis,
+    archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
+  }),
+  title: Schema.String,
+  /*
+  slug: Schema.String,
+  directory: Schema.String,
+  path: optionalOmitUndefined(Schema.String),
+  parentID: optionalOmitUndefined(SessionID),
+  summary: optionalOmitUndefined(Summary),
+  share: optionalOmitUndefined(Share),
+  title: Schema.String,
+  version: Schema.String,
+  time: Time,
+  permission: optionalOmitUndefined(Permission.Ruleset),
+  revert: optionalOmitUndefined(Revert),
+  */
 }) {}
 
 export interface Interface {
-  fromID: (id: ID) => Effect.Effect<Info>
-  create: (input: CreateInput) => Effect.Effect<Info>
-  prompt: (input: PromptInput) => Effect.Effect<SessionEntry.User>
+  readonly list: (input: {
+    limit?: number
+    order?: "asc" | "desc"
+    directory?: string
+    path?: string
+    workspaceID?: WorkspaceID
+    roots?: boolean
+    start?: number
+    search?: string
+    cursor?: {
+      id: SessionID
+      time: number
+      direction: "previous" | "next"
+    }
+  }) => Effect.Effect<Info[], never>
+  readonly messages: (input: {
+    sessionID: SessionID
+    limit?: number
+    order?: "asc" | "desc"
+    cursor?: {
+      id: SessionMessage.ID
+      time: number
+      direction: "previous" | "next"
+    }
+  }) => Effect.Effect<SessionMessage.Message[], never>
+  readonly context: (sessionID: SessionID) => Effect.Effect<SessionMessage.Message[], never>
+  readonly prompt: (input: {
+    id?: EventV2.ID
+    sessionID: SessionID
+    prompt: Prompt
+    delivery?: Delivery
+  }) => Effect.Effect<SessionMessage.User, never>
+  readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect<void, never>
+  readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect<void, never>
+  readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
+  readonly switchModel: (input: {
+    sessionID: SessionID
+    id: ModelID
+    providerID: ProviderID
+    variant?: string
+  }) => Effect.Effect<void, never>
+  readonly compact: (sessionID: SessionID) => Effect.Effect<void, never>
+  readonly wait: (sessionID: SessionID) => Effect.Effect<void, never>
 }
 
-export class Service extends Context.Service<Service, Interface>()("Session.Service") {}
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
 
-export const layer = Layer.effect(Service)(
+export const layer = Layer.effect(
+  Service,
   Effect.gen(function* () {
-    const session = yield* Session.Service
+    const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
 
-    const create: Interface["create"] = Effect.fn("Session.create")(function* (_input) {
-      throw new Error("Not implemented")
-    })
+    const decode = (row: typeof SessionMessageTable.$inferSelect) =>
+      decodeMessage({ ...row.data, id: row.id, type: row.type })
 
-    const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (_input) {
-      throw new Error("Not implemented")
-    })
+    function fromRow(row: typeof SessionTable.$inferSelect): Info {
+      return {
+        id: SessionID.make(row.id),
+        projectID: ProjectID.make(row.project_id),
+        workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined,
+        title: row.title,
+        parentID: row.parent_id ? SessionID.make(row.parent_id) : undefined,
+        path: row.path ?? "",
+        agent: row.agent ?? undefined,
+        model: row.model
+          ? {
+              id: ModelID.make(row.model.id),
+              providerID: ProviderID.make(row.model.providerID),
+              variant: row.model.variant,
+            }
+          : undefined,
+        time: {
+          created: DateTime.makeUnsafe(row.time_created),
+          updated: DateTime.makeUnsafe(row.time_updated),
+          archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
+        },
+      }
+    }
 
-    const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) {
-      const match = yield* session.get(id)
-      return fromV1(match)
-    })
+    const result: Interface = {
+      list: Effect.fn("V2Session.list")(function* (input) {
+        const direction = input.cursor?.direction ?? "next"
+        let order = input.order ?? "desc"
+        // Query the adjacent rows in reverse, then flip them back into the requested order below.
+        if (direction === "previous" && order === "asc") order = "desc"
+        if (direction === "previous" && order === "desc") order = "asc"
+        const conditions: SQL[] = []
+        if (input.directory) conditions.push(eq(SessionTable.directory, input.directory))
+        if (input.path)
+          conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!)
+        if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
+        if (input.roots) conditions.push(isNull(SessionTable.parent_id))
+        if (input.start) conditions.push(gte(SessionTable.time_created, input.start))
+        if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
+        if (input.cursor) {
+          conditions.push(
+            order === "asc"
+              ? or(
+                  gt(SessionTable.time_created, input.cursor.time),
+                  and(eq(SessionTable.time_created, input.cursor.time), gt(SessionTable.id, input.cursor.id)),
+                )!
+              : or(
+                  lt(SessionTable.time_created, input.cursor.time),
+                  and(eq(SessionTable.time_created, input.cursor.time), lt(SessionTable.id, input.cursor.id)),
+                )!,
+          )
+        }
+        const query = Database.Client()
+          .select()
+          .from(SessionTable)
+          .where(conditions.length > 0 ? and(...conditions) : undefined)
+          .orderBy(
+            order === "asc" ? asc(SessionTable.time_created) : desc(SessionTable.time_created),
+            order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
+          )
 
-    return Service.of({
-      create,
-      prompt,
-      fromID,
-    })
+        const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all()
+        return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
+      }),
+      messages: Effect.fn("V2Session.messages")(function* (input) {
+        const direction = input.cursor?.direction ?? "next"
+        let order = input.order ?? "desc"
+        // Query the adjacent rows in reverse, then flip them back into the requested order below.
+        if (direction === "previous" && order === "asc") order = "desc"
+        if (direction === "previous" && order === "desc") order = "asc"
+        const boundary = input.cursor
+          ? order === "asc"
+            ? or(
+                gt(SessionMessageTable.time_created, input.cursor.time),
+                and(
+                  eq(SessionMessageTable.time_created, input.cursor.time),
+                  gt(SessionMessageTable.id, input.cursor.id),
+                ),
+              )
+            : or(
+                lt(SessionMessageTable.time_created, input.cursor.time),
+                and(
+                  eq(SessionMessageTable.time_created, input.cursor.time),
+                  lt(SessionMessageTable.id, input.cursor.id),
+                ),
+              )
+          : undefined
+        const where = boundary
+          ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
+          : eq(SessionMessageTable.session_id, input.sessionID)
+
+        const rows = Database.use((db) => {
+          const query = db
+            .select()
+            .from(SessionMessageTable)
+            .where(where)
+            .orderBy(
+              order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created),
+              order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id),
+            )
+          const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all()
+          return direction === "previous" ? rows.toReversed() : rows
+        })
+        return rows.map((row) => decode(row))
+      }),
+      context: Effect.fn("V2Session.context")(function* (sessionID) {
+        const rows = Database.use((db) => {
+          const compaction = db
+            .select()
+            .from(SessionMessageTable)
+            .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
+            .orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id))
+            .limit(1)
+            .get()
+
+          return db
+            .select()
+            .from(SessionMessageTable)
+            .where(
+              and(
+                eq(SessionMessageTable.session_id, sessionID),
+                compaction
+                  ? or(
+                      gt(SessionMessageTable.time_created, compaction.time_created),
+                      and(
+                        eq(SessionMessageTable.time_created, compaction.time_created),
+                        gte(SessionMessageTable.id, compaction.id),
+                      ),
+                    )
+                  : undefined,
+              ),
+            )
+            .orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id))
+            .all()
+        })
+        return rows.map((row) => decode(row))
+      }),
+      prompt: Effect.fn("V2Session.prompt")(function* (_input) {
+        return {} as any
+      }),
+      shell: Effect.fn("V2Session.shell")(function* (_input) {}),
+      skill: Effect.fn("V2Session.skill")(function* (_input) {}),
+      switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
+        EventV2.run(SessionEvent.AgentSwitched.Sync, {
+          sessionID: input.sessionID,
+          timestamp: DateTime.makeUnsafe(Date.now()),
+          agent: input.agent,
+        })
+      }),
+      switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
+        EventV2.run(SessionEvent.ModelSwitched.Sync, {
+          sessionID: input.sessionID,
+          timestamp: DateTime.makeUnsafe(Date.now()),
+          id: input.id,
+          providerID: input.providerID,
+          variant: input.variant,
+        })
+      }),
+      compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}),
+      wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}),
+    }
+
+    return Service.of(result)
   }),
 )
 
-function fromV1(input: Session.Info): Info {
-  return new Info({
-    id: ID.make(input.id),
-  })
-}
+export const defaultLayer = layer
 
 export * as SessionV2 from "./session"

+ 18 - 0
packages/opencode/src/v2/tool-output.ts

@@ -0,0 +1,18 @@
+export * as ToolOutput from "./tool-output"
+import { Schema } from "effect"
+
+export class TextContent extends Schema.Class<TextContent>("Tool.TextContent")({
+  type: Schema.Literal("text"),
+  text: Schema.String,
+}) {}
+
+export class FileContent extends Schema.Class<FileContent>("Tool.FileContent")({
+  type: Schema.Literal("file"),
+  uri: Schema.String,
+  mime: Schema.String,
+  name: Schema.String.pipe(Schema.optional),
+}) {}
+
+export const Content = Schema.Union([TextContent, FileContent]).pipe(Schema.toTaggedUnion("type"))
+
+export const Structured = Schema.Record(Schema.String, Schema.Any)

+ 1 - 0
packages/opencode/test/acp/event-subscription.test.ts

@@ -59,6 +59,7 @@ function toolEvent(
           raw: opts.raw,
         }
   const payload: EventMessagePartUpdated = {
+    id: `evt_${opts.callID}`,
     type: "message.part.updated",
     properties: {
       sessionID: sessionId,

+ 2 - 0
packages/opencode/test/cli/tui/use-event.test.tsx

@@ -25,6 +25,7 @@ function event(payload: Event, input: { directory: string; workspace?: string })
 
 function vcs(branch: string): Event {
   return {
+    id: `evt_vcs_${branch}`,
     type: "vcs.branch.updated",
     properties: {
       branch,
@@ -34,6 +35,7 @@ function vcs(branch: string): Event {
 
 function update(version: string): Event {
   return {
+    id: `evt_update_${version}`,
     type: "installation.update-available",
     properties: {
       version,

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

@@ -34,6 +34,7 @@ process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
 process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
 process.env["XDG_STATE_HOME"] = path.join(dir, "state")
 process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
+process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true"
 
 // Set test home directory to isolate tests from user's actual home directory
 // This prevents tests from picking up real user configs/skills from ~/.claude/skills
@@ -79,7 +80,7 @@ delete process.env["OPENCODE_SERVER_USERNAME"]
 process.env["OPENCODE_DB"] = ":memory:"
 
 // Now safe to import from src/
-const Log = await import("@opencode-ai/core/util/log")
+const { Log } = await import("@opencode-ai/core/util/log")
 const { initProjectors } = await import("../src/server/projectors")
 
 void Log.init({

+ 8 - 1
packages/opencode/test/server/httpapi-bridge.test.ts

@@ -226,7 +226,14 @@ describe("HttpApi server", () => {
     const effectRoutes = openApiRouteKeys(effectOpenApi())
 
     expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
-    expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([])
+    expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([
+      "GET /api/session",
+      "GET /api/session/{sessionID}/context",
+      "GET /api/session/{sessionID}/message",
+      "POST /api/session/{sessionID}/compact",
+      "POST /api/session/{sessionID}/prompt",
+      "POST /api/session/{sessionID}/wait",
+    ])
   })
 
   test("matches generated OpenAPI route parameters", async () => {

+ 13 - 2
packages/opencode/test/server/httpapi-event.test.ts

@@ -27,6 +27,14 @@ async function readFirstChunk(response: Response) {
   return new TextDecoder().decode(result.value)
 }
 
+async function readFirstEvent(response: Response) {
+  return JSON.parse((await readFirstChunk(response)).replace(/^data: /, "")) as {
+    id?: string
+    type: string
+    properties: Record<string, unknown>
+  }
+}
+
 afterEach(async () => {
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
   await disposeAllInstances()
@@ -43,7 +51,7 @@ describe("event HttpApi bridge", () => {
     expect(response.headers.get("cache-control")).toBe("no-cache, no-transform")
     expect(response.headers.get("x-accel-buffering")).toBe("no")
     expect(response.headers.get("x-content-type-options")).toBe("nosniff")
-    expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n')
+    expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} })
   })
 
   test("matches legacy first event frame", async () => {
@@ -52,6 +60,9 @@ describe("event HttpApi bridge", () => {
     const legacy = await app(false).request(EventPaths.event, { headers })
     const effect = await app(true).request(EventPaths.event, { headers })
 
-    expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy))
+    const legacyEvent = await readFirstEvent(legacy)
+    const effectEvent = await readFirstEvent(effect)
+    expect(effectEvent.type).toBe(legacyEvent.type)
+    expect(effectEvent.properties).toEqual(legacyEvent.properties)
   })
 })

+ 42 - 1
packages/opencode/test/server/httpapi-session.test.ts

@@ -17,7 +17,9 @@ import { Session } from "@/session/session"
 import { MessageID, PartID, type SessionID } from "../../src/session/schema"
 import { MessageV2 } from "../../src/session/message-v2"
 import { Database } from "@/storage/db"
-import { SessionTable } from "@/session/session.sql"
+import { SessionMessageTable, SessionTable } from "@/session/session.sql"
+import { SessionMessage } from "../../src/v2/session-message"
+import * as DateTime from "effect/DateTime"
 import * as Log from "@opencode-ai/core/util/log"
 import { eq } from "drizzle-orm"
 import { resetDatabase } from "../fixture/db"
@@ -203,6 +205,45 @@ describe("session HttpApi", () => {
             { headers },
           ),
         ).toMatchObject({ info: { id: message.info.id } })
+
+        yield* Effect.promise(() =>
+          WithInstance.provide({
+            directory: tmp.path,
+            fn: async () => {
+              const message = new SessionMessage.Assistant({
+                id: SessionMessage.ID.create(),
+                type: "assistant",
+                agent: "build",
+                model: { id: "model", providerID: "provider" },
+                time: { created: DateTime.makeUnsafe(1) },
+                content: [],
+              })
+              Database.use((db) =>
+                db
+                  .insert(SessionMessageTable)
+                  .values([
+                    {
+                      id: message.id,
+                      session_id: parent.id,
+                      type: message.type,
+                      time_created: 1,
+                      data: {
+                        time: { created: 1 },
+                        agent: message.agent,
+                        model: message.model,
+                        content: message.content,
+                      } as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
+                    },
+                  ])
+                  .run(),
+              )
+            },
+          }),
+        )
+
+        expect(
+          (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })).items,
+        ).toMatchObject([{ type: "assistant" }])
       }),
     ),
   )

+ 10 - 0
packages/opencode/test/session/compaction.test.ts

@@ -20,6 +20,7 @@ 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 { SessionV2 } from "../../src/v2/session"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import type { Provider } from "@/provider/provider"
 import * as SessionProcessorModule from "../../src/session/processor"
@@ -597,6 +598,15 @@ describe("session.compaction.create", () => {
           auto: true,
           overflow: true,
         })
+
+        const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe(
+          Effect.provide(SessionV2.defaultLayer),
+        )
+        expect(v2.at(-1)).toMatchObject({
+          type: "compaction",
+          reason: "auto",
+          summary: "",
+        })
       }),
     ),
   )

+ 44 - 0
packages/opencode/test/session/prompt.test.ts

@@ -19,6 +19,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema"
 import { Question } from "../../src/question"
 import { Todo } from "../../src/session/todo"
 import { Session } from "@/session/session"
+import { SessionMessageTable } from "../../src/session/session.sql"
 import { LLM } from "../../src/session/llm"
 import { MessageV2 } from "../../src/session/message-v2"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -31,6 +32,7 @@ import { SessionRevert } from "../../src/session/revert"
 import { SessionRunState } from "../../src/session/run-state"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
 import { SessionStatus } from "../../src/session/status"
+import { SessionV2 } from "../../src/v2/session"
 import { Skill } from "../../src/skill"
 import { SystemPrompt } from "../../src/session/system"
 import { Shell } from "../../src/shell/shell"
@@ -39,6 +41,7 @@ import { ToolRegistry } from "@/tool/registry"
 import { Truncate } from "@/tool/truncate"
 import * as Log from "@opencode-ai/core/util/log"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import * as Database from "../../src/storage/db"
 import { Ripgrep } from "../../src/file/ripgrep"
 import { Format } from "../../src/format"
 import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
@@ -371,6 +374,47 @@ it.live("loop calls LLM and returns assistant message", () =>
   ),
 )
 
+it.live("prompt emits v2 prompted and synthetic events", () =>
+  provideTmpdirServer(
+    Effect.fnUntraced(function* () {
+      const prompt = yield* SessionPrompt.Service
+      const sessions = yield* Session.Service
+      const chat = yield* sessions.create({ title: "Pinned" })
+
+      yield* prompt.prompt({
+        sessionID: chat.id,
+        agent: "build",
+        noReply: true,
+        parts: [
+          { type: "text", text: "hello v2" },
+          {
+            type: "file",
+            mime: "text/plain",
+            filename: "note.txt",
+            url: "data:text/plain;base64,bm90ZSBjb250ZW50",
+          },
+        ],
+      })
+
+      const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe(
+        Effect.provide(SessionV2.layer),
+      )
+      const row = Database.use((db) =>
+        db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(),
+      )
+      expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" })
+      expect(typeof row?.data.time.created).toBe("number")
+      expect(messages).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }),
+          expect.objectContaining({ type: "synthetic", text: "note content" }),
+        ]),
+      )
+    }),
+    { git: true, config: providerCfg },
+  ),
+)
+
 it.live("static loop returns assistant text through local provider", () =>
   provideTmpdirServer(
     Effect.fnUntraced(function* ({ llm }) {

+ 0 - 916
packages/opencode/test/session/session-entry-stepper.test.ts

@@ -1,916 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import * as DateTime from "effect/DateTime"
-import * as FastCheck from "effect/testing/FastCheck"
-import { SessionEntry } from "../../src/v2/session-entry"
-import { SessionEntryStepper } from "../../src/v2/session-entry-stepper"
-import { SessionEvent } from "../../src/v2/session-event"
-
-const time = (n: number) => DateTime.makeUnsafe(n)
-
-const word = FastCheck.string({ minLength: 1, maxLength: 8 })
-const text = FastCheck.string({ maxLength: 16 })
-const texts = FastCheck.array(text, { maxLength: 8 })
-const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 }))
-const dict = FastCheck.dictionary(word, val, { maxKeys: 4 })
-const files = FastCheck.array(
-  word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })),
-  { maxLength: 2 },
-)
-
-function maybe<A>(arb: FastCheck.Arbitrary<A>) {
-  return FastCheck.oneof(FastCheck.constant(undefined), arb)
-}
-
-function assistant() {
-  return new SessionEntry.Assistant({
-    id: SessionEvent.ID.create(),
-    type: "assistant",
-    time: { created: time(0) },
-    content: [],
-    retries: [],
-  })
-}
-
-function retryError(message: string) {
-  return new SessionEvent.RetryError({
-    message,
-    isRetryable: true,
-  })
-}
-
-function retry(attempt: number, message: string, created: number) {
-  return new SessionEntry.AssistantRetry({
-    attempt,
-    error: retryError(message),
-    time: {
-      created: time(created),
-    },
-  })
-}
-
-function memoryState() {
-  const state: SessionEntryStepper.MemoryState = {
-    entries: [],
-    pending: [],
-  }
-  return state
-}
-
-function active() {
-  const state: SessionEntryStepper.MemoryState = {
-    entries: [assistant()],
-    pending: [],
-  }
-  return state
-}
-
-function run(events: SessionEvent.Event[], state = memoryState()) {
-  return events.reduce<SessionEntryStepper.MemoryState>((state, event) => SessionEntryStepper.step(state, event), state)
-}
-
-function last(state: SessionEntryStepper.MemoryState) {
-  const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant")
-  expect(entry?.type).toBe("assistant")
-  return entry?.type === "assistant" ? entry : undefined
-}
-
-function texts_of(state: SessionEntryStepper.MemoryState) {
-  const entry = last(state)
-  if (!entry) return []
-  return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text")
-}
-
-function reasons(state: SessionEntryStepper.MemoryState) {
-  const entry = last(state)
-  if (!entry) return []
-  return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning")
-}
-
-function tools(state: SessionEntryStepper.MemoryState) {
-  const entry = last(state)
-  if (!entry) return []
-  return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool")
-}
-
-function tool(state: SessionEntryStepper.MemoryState, callID: string) {
-  return tools(state).find((x) => x.callID === callID)
-}
-
-function retriesOf(state: SessionEntryStepper.MemoryState) {
-  const entry = last(state)
-  if (!entry) return []
-  return entry.retries ?? []
-}
-
-function adapterStore() {
-  return {
-    committed: [] as SessionEntry.Entry[],
-    deferred: [] as SessionEntry.Entry[],
-  }
-}
-
-function adapterFor(store: ReturnType<typeof adapterStore>): SessionEntryStepper.Adapter<typeof store> {
-  const activeAssistantIndex = () =>
-    store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed)
-
-  const getCurrentAssistant = () => {
-    const index = activeAssistantIndex()
-    if (index < 0) return
-    const assistant = store.committed[index]
-    return assistant?.type === "assistant" ? assistant : undefined
-  }
-
-  return {
-    getCurrentAssistant,
-    updateAssistant(assistant) {
-      const index = activeAssistantIndex()
-      if (index < 0) return
-      const current = store.committed[index]
-      if (current?.type !== "assistant") return
-      store.committed[index] = assistant
-    },
-    appendEntry(entry) {
-      store.committed.push(entry)
-    },
-    appendPending(entry) {
-      store.deferred.push(entry)
-    },
-    finish() {
-      return store
-    },
-  }
-}
-
-describe("session-entry-stepper", () => {
-  describe("stepWith", () => {
-    test("reduces through a custom adapter", () => {
-      const store = adapterStore()
-      store.committed.push(assistant())
-
-      SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) }))
-      SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) }))
-      SessionEntryStepper.stepWith(
-        adapterFor(store),
-        SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }),
-      )
-      SessionEntryStepper.stepWith(
-        adapterFor(store),
-        SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }),
-      )
-      SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) }))
-      SessionEntryStepper.stepWith(
-        adapterFor(store),
-        SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }),
-      )
-      SessionEntryStepper.stepWith(
-        adapterFor(store),
-        SessionEvent.Step.Ended.create({
-          reason: "stop",
-          cost: 1,
-          tokens: {
-            input: 1,
-            output: 2,
-            reasoning: 3,
-            cache: {
-              read: 4,
-              write: 5,
-            },
-          },
-          timestamp: time(7),
-        }),
-      )
-
-      expect(store.deferred).toHaveLength(1)
-      expect(store.deferred[0]?.type).toBe("user")
-      expect(store.committed).toHaveLength(1)
-      expect(store.committed[0]?.type).toBe("assistant")
-      if (store.committed[0]?.type !== "assistant") return
-
-      expect(store.committed[0].content).toEqual([
-        { type: "reasoning", text: "thought" },
-        { type: "text", text: "world" },
-      ])
-      expect(store.committed[0].time.completed).toEqual(time(7))
-    })
-
-    test("aggregates retry events onto the current assistant", () => {
-      const store = adapterStore()
-      store.committed.push(assistant())
-
-      SessionEntryStepper.stepWith(
-        adapterFor(store),
-        SessionEvent.Retried.create({
-          attempt: 1,
-          error: retryError("rate limited"),
-          timestamp: time(1),
-        }),
-      )
-      SessionEntryStepper.stepWith(
-        adapterFor(store),
-        SessionEvent.Retried.create({
-          attempt: 2,
-          error: retryError("provider overloaded"),
-          timestamp: time(2),
-        }),
-      )
-
-      expect(store.committed[0]?.type).toBe("assistant")
-      if (store.committed[0]?.type !== "assistant") return
-
-      expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)])
-    })
-  })
-
-  describe("memory", () => {
-    test("tracks and replaces the current assistant", () => {
-      const state = active()
-      const adapter = SessionEntryStepper.memory(state)
-      const current = adapter.getCurrentAssistant()
-
-      expect(current?.type).toBe("assistant")
-      if (!current) return
-
-      adapter.updateAssistant(
-        new SessionEntry.Assistant({
-          ...current,
-          content: [new SessionEntry.AssistantText({ type: "text", text: "done" })],
-          time: {
-            ...current.time,
-            completed: time(1),
-          },
-        }),
-      )
-
-      expect(adapter.getCurrentAssistant()).toBeUndefined()
-      expect(state.entries[0]?.type).toBe("assistant")
-      if (state.entries[0]?.type !== "assistant") return
-
-      expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }])
-      expect(state.entries[0].time.completed).toEqual(time(1))
-    })
-
-    test("appends committed and pending entries", () => {
-      const state = memoryState()
-      const adapter = SessionEntryStepper.memory(state)
-      const committed = SessionEntry.User.fromEvent(
-        SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }),
-      )
-      const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) }))
-
-      adapter.appendEntry(committed)
-      adapter.appendPending(pending)
-
-      expect(state.entries).toEqual([committed])
-      expect(state.pending).toEqual([pending])
-    })
-
-    test("stepWith through memory records reasoning", () => {
-      const state = active()
-
-      SessionEntryStepper.stepWith(
-        SessionEntryStepper.memory(state),
-        SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
-      )
-      SessionEntryStepper.stepWith(
-        SessionEntryStepper.memory(state),
-        SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }),
-      )
-      SessionEntryStepper.stepWith(
-        SessionEntryStepper.memory(state),
-        SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }),
-      )
-
-      expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }])
-    })
-
-    test("stepWith through memory records retries", () => {
-      const state = active()
-
-      SessionEntryStepper.stepWith(
-        SessionEntryStepper.memory(state),
-        SessionEvent.Retried.create({
-          attempt: 1,
-          error: retryError("rate limited"),
-          timestamp: time(1),
-        }),
-      )
-
-      expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)])
-    })
-  })
-
-  describe("step", () => {
-    describe("seeded pending assistant", () => {
-      test("stores prompts in entries when no assistant is pending", () => {
-        FastCheck.assert(
-          FastCheck.property(word, (body) => {
-            const next = SessionEntryStepper.step(
-              memoryState(),
-              SessionEvent.Prompt.create({ text: body, timestamp: time(1) }),
-            )
-            expect(next.entries).toHaveLength(1)
-            expect(next.entries[0]?.type).toBe("user")
-            if (next.entries[0]?.type !== "user") return
-            expect(next.entries[0].text).toBe(body)
-          }),
-          { numRuns: 50 },
-        )
-      })
-
-      test("stores prompts in pending when an assistant is pending", () => {
-        FastCheck.assert(
-          FastCheck.property(word, (body) => {
-            const next = SessionEntryStepper.step(
-              active(),
-              SessionEvent.Prompt.create({ text: body, timestamp: time(1) }),
-            )
-            expect(next.pending).toHaveLength(1)
-            expect(next.pending[0]?.type).toBe("user")
-            if (next.pending[0]?.type !== "user") return
-            expect(next.pending[0].text).toBe(body)
-          }),
-          { numRuns: 50 },
-        )
-      })
-
-      test("accumulates text deltas on the latest text part", () => {
-        FastCheck.assert(
-          FastCheck.property(texts, (parts) => {
-            const next = parts.reduce(
-              (state, part, i) =>
-                SessionEntryStepper.step(
-                  state,
-                  SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) }),
-                ),
-              SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })),
-            )
-
-            expect(texts_of(next)).toEqual([
-              {
-                type: "text",
-                text: parts.join(""),
-              },
-            ])
-          }),
-          { numRuns: 100 },
-        )
-      })
-
-      test("routes later text deltas to the latest text segment", () => {
-        FastCheck.assert(
-          FastCheck.property(texts, texts, (a, b) => {
-            const next = run(
-              [
-                SessionEvent.Text.Started.create({ timestamp: time(1) }),
-                ...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })),
-                SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }),
-                ...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })),
-              ],
-              active(),
-            )
-
-            expect(texts_of(next)).toEqual([
-              { type: "text", text: a.join("") },
-              { type: "text", text: b.join("") },
-            ])
-          }),
-          { numRuns: 50 },
-        )
-      })
-
-      test("reasoning.ended replaces buffered reasoning text", () => {
-        FastCheck.assert(
-          FastCheck.property(texts, text, (parts, end) => {
-            const next = run(
-              [
-                SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
-                ...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })),
-                SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }),
-              ],
-              active(),
-            )
-
-            expect(reasons(next)).toEqual([
-              {
-                type: "reasoning",
-                text: end,
-              },
-            ])
-          }),
-          { numRuns: 100 },
-        )
-      })
-
-      test("tool.success completes the latest running tool", () => {
-        FastCheck.assert(
-          FastCheck.property(
-            word,
-            word,
-            dict,
-            maybe(text),
-            maybe(dict),
-            maybe(files),
-            texts,
-            (callID, title, input, output, metadata, attachments, parts) => {
-              const next = run(
-                [
-                  SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
-                  ...parts.map((x, i) =>
-                    SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
-                  ),
-                  SessionEvent.Tool.Called.create({
-                    callID,
-                    tool: "bash",
-                    input,
-                    provider: { executed: true },
-                    timestamp: time(parts.length + 2),
-                  }),
-                  SessionEvent.Tool.Success.create({
-                    callID,
-                    title,
-                    output,
-                    metadata,
-                    attachments,
-                    provider: { executed: true },
-                    timestamp: time(parts.length + 3),
-                  }),
-                ],
-                active(),
-              )
-
-              const match = tool(next, callID)
-              expect(match?.state.status).toBe("completed")
-              if (match?.state.status !== "completed") return
-
-              expect(match.time.ran).toEqual(time(parts.length + 2))
-              expect(match.state.input).toEqual(input)
-              expect(match.state.output).toBe(output ?? "")
-              expect(match.state.title).toBe(title)
-              expect(match.state.metadata).toEqual(metadata ?? {})
-              expect(match.state.attachments).toEqual(attachments ?? [])
-            },
-          ),
-          { numRuns: 50 },
-        )
-      })
-
-      test("tool.error completes the latest running tool with an error", () => {
-        FastCheck.assert(
-          FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
-            const next = run(
-              [
-                SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
-                SessionEvent.Tool.Called.create({
-                  callID,
-                  tool: "bash",
-                  input,
-                  provider: { executed: true },
-                  timestamp: time(2),
-                }),
-                SessionEvent.Tool.Error.create({
-                  callID,
-                  error,
-                  metadata,
-                  provider: { executed: true },
-                  timestamp: time(3),
-                }),
-              ],
-              active(),
-            )
-
-            const match = tool(next, callID)
-            expect(match?.state.status).toBe("error")
-            if (match?.state.status !== "error") return
-
-            expect(match.time.ran).toEqual(time(2))
-            expect(match.state.input).toEqual(input)
-            expect(match.state.error).toBe(error)
-            expect(match.state.metadata).toEqual(metadata ?? {})
-          }),
-          { numRuns: 50 },
-        )
-      })
-
-      test("tool.success is ignored before tool.called promotes the tool to running", () => {
-        FastCheck.assert(
-          FastCheck.property(word, word, (callID, title) => {
-            const next = run(
-              [
-                SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
-                SessionEvent.Tool.Success.create({
-                  callID,
-                  title,
-                  provider: { executed: true },
-                  timestamp: time(2),
-                }),
-              ],
-              active(),
-            )
-            const match = tool(next, callID)
-            expect(match?.state).toEqual({
-              status: "pending",
-              input: "",
-            })
-          }),
-          { numRuns: 50 },
-        )
-      })
-
-      test("step.ended copies completion fields onto the pending assistant", () => {
-        FastCheck.assert(
-          FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => {
-            const event = SessionEvent.Step.Ended.create({
-              reason: "stop",
-              cost: 1,
-              tokens: {
-                input: 1,
-                output: 2,
-                reasoning: 3,
-                cache: {
-                  read: 4,
-                  write: 5,
-                },
-              },
-              timestamp: time(n),
-            })
-            const next = SessionEntryStepper.step(active(), event)
-            const entry = last(next)
-            expect(entry).toBeDefined()
-            if (!entry) return
-
-            expect(entry.time.completed).toEqual(event.timestamp)
-            expect(entry.cost).toBe(event.cost)
-            expect(entry.tokens).toEqual(event.tokens)
-          }),
-          { numRuns: 50 },
-        )
-      })
-    })
-
-    describe("known reducer gaps", () => {
-      test("prompt appends immutably when no assistant is pending", () => {
-        FastCheck.assert(
-          FastCheck.property(word, (body) => {
-            const old = memoryState()
-            const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
-            expect(old).not.toBe(next)
-            expect(old.entries).toHaveLength(0)
-            expect(next.entries).toHaveLength(1)
-          }),
-          { numRuns: 50 },
-        )
-      })
-
-      test("prompt appends immutably when an assistant is pending", () => {
-        FastCheck.assert(
-          FastCheck.property(word, (body) => {
-            const old = active()
-            const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
-            expect(old).not.toBe(next)
-            expect(old.pending).toHaveLength(0)
-            expect(next.pending).toHaveLength(1)
-          }),
-          { numRuns: 50 },
-        )
-      })
-
-      test("step.started creates an assistant consumed by follow-up events", () => {
-        FastCheck.assert(
-          FastCheck.property(texts, (parts) => {
-            const next = run([
-              SessionEvent.Step.Started.create({
-                model: {
-                  id: "model",
-                  providerID: "provider",
-                },
-                timestamp: time(1),
-              }),
-              SessionEvent.Text.Started.create({ timestamp: time(2) }),
-              ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
-              SessionEvent.Step.Ended.create({
-                reason: "stop",
-                cost: 1,
-                tokens: {
-                  input: 1,
-                  output: 2,
-                  reasoning: 3,
-                  cache: {
-                    read: 4,
-                    write: 5,
-                  },
-                },
-                timestamp: time(parts.length + 3),
-              }),
-            ])
-            const entry = last(next)
-
-            expect(entry).toBeDefined()
-            if (!entry) return
-
-            expect(entry.content).toEqual([
-              {
-                type: "text",
-                text: parts.join(""),
-              },
-            ])
-            expect(entry.time.completed).toEqual(time(parts.length + 3))
-          }),
-          { numRuns: 100 },
-        )
-      })
-
-      test("replays prompt -> step -> text -> step.ended", () => {
-        FastCheck.assert(
-          FastCheck.property(word, texts, (body, parts) => {
-            const next = run([
-              SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
-              SessionEvent.Step.Started.create({
-                model: {
-                  id: "model",
-                  providerID: "provider",
-                },
-                timestamp: time(1),
-              }),
-              SessionEvent.Text.Started.create({ timestamp: time(2) }),
-              ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
-              SessionEvent.Step.Ended.create({
-                reason: "stop",
-                cost: 1,
-                tokens: {
-                  input: 1,
-                  output: 2,
-                  reasoning: 3,
-                  cache: {
-                    read: 4,
-                    write: 5,
-                  },
-                },
-                timestamp: time(parts.length + 3),
-              }),
-            ])
-
-            expect(next.entries).toHaveLength(2)
-            expect(next.entries[0]?.type).toBe("user")
-            expect(next.entries[1]?.type).toBe("assistant")
-            if (next.entries[1]?.type !== "assistant") return
-
-            expect(next.entries[1].content).toEqual([
-              {
-                type: "text",
-                text: parts.join(""),
-              },
-            ])
-            expect(next.entries[1].time.completed).toEqual(time(parts.length + 3))
-          }),
-          { numRuns: 50 },
-        )
-      })
-
-      test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => {
-        FastCheck.assert(
-          FastCheck.property(
-            word,
-            texts,
-            text,
-            dict,
-            word,
-            maybe(text),
-            maybe(dict),
-            maybe(files),
-            (body, reason, end, input, title, output, metadata, attachments) => {
-              const callID = "call"
-              const next = run([
-                SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
-                SessionEvent.Step.Started.create({
-                  model: {
-                    id: "model",
-                    providerID: "provider",
-                  },
-                  timestamp: time(1),
-                }),
-                SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
-                ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
-                SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
-                SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
-                SessionEvent.Tool.Called.create({
-                  callID,
-                  tool: "bash",
-                  input,
-                  provider: { executed: true },
-                  timestamp: time(reason.length + 5),
-                }),
-                SessionEvent.Tool.Success.create({
-                  callID,
-                  title,
-                  output,
-                  metadata,
-                  attachments,
-                  provider: { executed: true },
-                  timestamp: time(reason.length + 6),
-                }),
-                SessionEvent.Step.Ended.create({
-                  reason: "stop",
-                  cost: 1,
-                  tokens: {
-                    input: 1,
-                    output: 2,
-                    reasoning: 3,
-                    cache: {
-                      read: 4,
-                      write: 5,
-                    },
-                  },
-                  timestamp: time(reason.length + 7),
-                }),
-              ])
-
-              expect(next.entries.at(-1)?.type).toBe("assistant")
-              const entry = next.entries.at(-1)
-              if (entry?.type !== "assistant") return
-
-              expect(entry.content).toHaveLength(2)
-              expect(entry.content[0]).toEqual({
-                type: "reasoning",
-                text: end,
-              })
-              expect(entry.content[1]?.type).toBe("tool")
-              if (entry.content[1]?.type !== "tool") return
-              expect(entry.content[1].state.status).toBe("completed")
-              expect(entry.time.completed).toEqual(time(reason.length + 7))
-            },
-          ),
-          { numRuns: 50 },
-        )
-      })
-
-      test("starting a new step completes the old assistant and appends a new active assistant", () => {
-        const next = run(
-          [
-            SessionEvent.Step.Started.create({
-              model: {
-                id: "model",
-                providerID: "provider",
-              },
-              timestamp: time(1),
-            }),
-          ],
-          active(),
-        )
-        expect(next.entries).toHaveLength(2)
-        expect(next.entries[0]?.type).toBe("assistant")
-        expect(next.entries[1]?.type).toBe("assistant")
-        if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return
-
-        expect(next.entries[0].time.completed).toEqual(time(1))
-        expect(next.entries[1].time.created).toEqual(time(1))
-        expect(next.entries[1].time.completed).toBeUndefined()
-      })
-
-      test("handles sequential tools independently", () => {
-        FastCheck.assert(
-          FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
-            const next = run(
-              [
-                SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
-                SessionEvent.Tool.Called.create({
-                  callID: "a",
-                  tool: "bash",
-                  input: a,
-                  provider: { executed: true },
-                  timestamp: time(2),
-                }),
-                SessionEvent.Tool.Success.create({
-                  callID: "a",
-                  title,
-                  output: "done",
-                  provider: { executed: true },
-                  timestamp: time(3),
-                }),
-                SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
-                SessionEvent.Tool.Called.create({
-                  callID: "b",
-                  tool: "bash",
-                  input: b,
-                  provider: { executed: true },
-                  timestamp: time(5),
-                }),
-                SessionEvent.Tool.Error.create({
-                  callID: "b",
-                  error,
-                  provider: { executed: true },
-                  timestamp: time(6),
-                }),
-              ],
-              active(),
-            )
-
-            const first = tool(next, "a")
-            const second = tool(next, "b")
-
-            expect(first?.state.status).toBe("completed")
-            if (first?.state.status !== "completed") return
-            expect(first.state.input).toEqual(a)
-            expect(first.state.output).toBe("done")
-            expect(first.state.title).toBe(title)
-
-            expect(second?.state.status).toBe("error")
-            if (second?.state.status !== "error") return
-            expect(second.state.input).toEqual(b)
-            expect(second.state.error).toBe(error)
-          }),
-          { numRuns: 50 },
-        )
-      })
-
-      test("routes tool events by callID when tool streams interleave", () => {
-        FastCheck.assert(
-          FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => {
-            const next = run(
-              [
-                SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
-                SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }),
-                SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }),
-                SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }),
-                SessionEvent.Tool.Called.create({
-                  callID: "a",
-                  tool: "bash",
-                  input: a,
-                  provider: { executed: true },
-                  timestamp: time(5),
-                }),
-                SessionEvent.Tool.Called.create({
-                  callID: "b",
-                  tool: "grep",
-                  input: b,
-                  provider: { executed: true },
-                  timestamp: time(6),
-                }),
-                SessionEvent.Tool.Success.create({
-                  callID: "a",
-                  title: titleA,
-                  output: "done-a",
-                  provider: { executed: true },
-                  timestamp: time(7),
-                }),
-                SessionEvent.Tool.Success.create({
-                  callID: "b",
-                  title: titleB,
-                  output: "done-b",
-                  provider: { executed: true },
-                  timestamp: time(8),
-                }),
-              ],
-              active(),
-            )
-
-            const first = tool(next, "a")
-            const second = tool(next, "b")
-
-            expect(first?.state.status).toBe("completed")
-            expect(second?.state.status).toBe("completed")
-            if (first?.state.status !== "completed" || second?.state.status !== "completed") return
-
-            expect(first.state.input).toEqual(a)
-            expect(second.state.input).toEqual(b)
-            expect(first.state.title).toBe(titleA)
-            expect(second.state.title).toBe(titleB)
-          }),
-          { numRuns: 50 },
-        )
-      })
-
-      test("records synthetic events", () => {
-        FastCheck.assert(
-          FastCheck.property(word, (body) => {
-            const next = SessionEntryStepper.step(
-              memoryState(),
-              SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }),
-            )
-            expect(next.entries).toHaveLength(1)
-            expect(next.entries[0]?.type).toBe("synthetic")
-            if (next.entries[0]?.type !== "synthetic") return
-            expect(next.entries[0].text).toBe(body)
-          }),
-          { numRuns: 50 },
-        )
-      })
-
-      test("records compaction events", () => {
-        FastCheck.assert(
-          FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
-            const next = SessionEntryStepper.step(
-              memoryState(),
-              SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }),
-            )
-            expect(next.entries).toHaveLength(1)
-            expect(next.entries[0]?.type).toBe("compaction")
-            if (next.entries[0]?.type !== "compaction") return
-            expect(next.entries[0].auto).toBe(auto)
-            expect(next.entries[0].overflow).toBe(overflow)
-          }),
-          { numRuns: 50 },
-        )
-      })
-    })
-  })
-})

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

@@ -124,7 +124,7 @@ describe("SyncEvent", () => {
             yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" })
             yield* Effect.promise(() => received)
             expect(events).toHaveLength(1)
-            expect(events[0]).toEqual({
+            expect(events[0]).toMatchObject({
               type: "item.created",
               properties: {
                 id: "evt_1",

+ 203 - 0
packages/opencode/test/v2/session-message-updater.test.ts

@@ -0,0 +1,203 @@
+import { expect, test } from "bun:test"
+import * as DateTime from "effect/DateTime"
+import { SessionID } from "../../src/session/schema"
+import { EventV2 } from "../../src/v2/event"
+import { SessionEvent } from "../../src/v2/session-event"
+import { SessionMessageUpdater } from "../../src/v2/session-message-updater"
+
+test("step snapshots carry over to assistant messages", () => {
+  const state: SessionMessageUpdater.MemoryState = { messages: [] }
+  const sessionID = SessionID.make("session")
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id: EventV2.ID.create(),
+    type: "session.next.step.started",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(1),
+      agent: "build",
+      model: { id: "model", providerID: "provider" },
+      snapshot: "before",
+    },
+  } satisfies SessionEvent.Event)
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id: EventV2.ID.create(),
+    type: "session.next.step.ended",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(2),
+      finish: "stop",
+      cost: 0,
+      tokens: {
+        input: 1,
+        output: 2,
+        reasoning: 0,
+        cache: { read: 0, write: 0 },
+      },
+      snapshot: "after",
+    },
+  } satisfies SessionEvent.Event)
+
+  expect(state.messages[0]?.type).toBe("assistant")
+  if (state.messages[0]?.type !== "assistant") return
+  expect(state.messages[0].snapshot).toEqual({ start: "before", end: "after" })
+  expect(state.messages[0].finish).toBe("stop")
+})
+
+test("text ended populates assistant text content", () => {
+  const state: SessionMessageUpdater.MemoryState = { messages: [] }
+  const sessionID = SessionID.make("session")
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id: EventV2.ID.create(),
+    type: "session.next.step.started",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(1),
+      agent: "build",
+      model: { id: "model", providerID: "provider" },
+    },
+  } satisfies SessionEvent.Event)
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id: EventV2.ID.create(),
+    type: "session.next.text.started",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(2),
+    },
+  } satisfies SessionEvent.Event)
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id: EventV2.ID.create(),
+    type: "session.next.text.ended",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(3),
+      text: "hello assistant",
+    },
+  } satisfies SessionEvent.Event)
+
+  expect(state.messages[0]?.type).toBe("assistant")
+  if (state.messages[0]?.type !== "assistant") return
+  expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }])
+})
+
+test("tool completion stores completed timestamp", () => {
+  const state: SessionMessageUpdater.MemoryState = { messages: [] }
+  const sessionID = SessionID.make("session")
+  const callID = "call"
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id: EventV2.ID.create(),
+    type: "session.next.step.started",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(1),
+      agent: "build",
+      model: { id: "model", providerID: "provider" },
+    },
+  } satisfies SessionEvent.Event)
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id: EventV2.ID.create(),
+    type: "session.next.tool.input.started",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(2),
+      callID,
+      name: "bash",
+    },
+  } satisfies SessionEvent.Event)
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id: EventV2.ID.create(),
+    type: "session.next.tool.called",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(3),
+      callID,
+      tool: "bash",
+      input: { command: "pwd" },
+      provider: { executed: true, metadata: { source: "provider" } },
+    },
+  } satisfies SessionEvent.Event)
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id: EventV2.ID.create(),
+    type: "session.next.tool.success",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(4),
+      callID,
+      structured: {},
+      content: [{ type: "text", text: "/tmp" }],
+      provider: { executed: true, metadata: { status: "done" } },
+    },
+  } satisfies SessionEvent.Event)
+
+  expect(state.messages[0]?.type).toBe("assistant")
+  if (state.messages[0]?.type !== "assistant") return
+  expect(state.messages[0].content[0]?.type).toBe("tool")
+  if (state.messages[0].content[0]?.type !== "tool") return
+  expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4))
+  expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { status: "done" } })
+})
+
+test("compaction events reduce to compaction message", () => {
+  const state: SessionMessageUpdater.MemoryState = { messages: [] }
+  const sessionID = SessionID.make("session")
+  const id = EventV2.ID.create()
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id,
+    type: "session.next.compaction.started",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(1),
+      reason: "auto",
+    },
+  } satisfies SessionEvent.Event)
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id: EventV2.ID.create(),
+    type: "session.next.compaction.delta",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(2),
+      text: "hello ",
+    },
+  } satisfies SessionEvent.Event)
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id: EventV2.ID.create(),
+    type: "session.next.compaction.delta",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(3),
+      text: "summary",
+    },
+  } satisfies SessionEvent.Event)
+
+  SessionMessageUpdater.update(SessionMessageUpdater.memory(state), {
+    id: EventV2.ID.create(),
+    type: "session.next.compaction.ended",
+    data: {
+      sessionID,
+      timestamp: DateTime.makeUnsafe(4),
+      text: "final summary",
+      include: "recent context",
+    },
+  } satisfies SessionEvent.Event)
+
+  expect(state.messages).toHaveLength(1)
+  expect(state.messages[0]).toMatchObject({
+    id,
+    type: "compaction",
+    reason: "auto",
+    summary: "final summary",
+    include: "recent context",
+    time: { created: DateTime.makeUnsafe(1) },
+  })
+})

+ 1 - 1
packages/sdk/js/script/build.ts

@@ -9,7 +9,7 @@ import path from "path"
 
 import { createClient } from "@hey-api/openapi-ts"
 
-const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "httpapi" ? "httpapi" : "hono"
+const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi"
 const opencode = path.resolve(dir, "../../opencode")
 
 if (openapiSource === "httpapi") {

Разница между файлами не показана из-за своего большого размера
+ 276 - 394
packages/sdk/js/src/v2/gen/sdk.gen.ts


Разница между файлами не показана из-за своего большого размера
+ 240 - 573
packages/sdk/js/src/v2/gen/types.gen.ts


+ 131 - 0
specs/v2/session-concepts-gap.md

@@ -0,0 +1,131 @@
+# Session V2 Concept Gaps
+
+Compared with `packages/opencode/src/session/message-v2.ts` and `packages/opencode/src/session/processor.ts`, `packages/opencode/src/v2` currently captures the rough event stream for prompts, assistant steps, text, reasoning, tools, retries, and compaction, but it does not yet capture several persisted-message and processor concepts.
+
+## Message Metadata
+
+- User messages are missing selected `agent`, `model`, `system`, enabled `tools`, output `format`, and summary metadata.
+- Assistant messages are missing `parentID`, `agent`, `providerID`, `modelID`, `variant`, `path.cwd`, `path.root`, deprecated `mode`, `summary`, `structured`, `finish`, and typed `error`.
+
+## Output Format
+
+- Text output format.
+- JSON-schema output format.
+- Structured-output retry count.
+- Structured assistant result payload.
+- Structured-output error classification.
+
+## Errors
+
+- Aborted error.
+- Provider auth error.
+- API error with status, retryability, headers, body, and metadata.
+- Context-overflow error.
+- Output-length error.
+- Unknown error.
+- V2 mostly reduces assistant errors to strings, except retry errors.
+
+## Part Identity
+
+- V1 has stable `MessageID`, `PartID`, `sessionID`, and `messageID` on every part.
+- V2 assistant content does not preserve stable per-content IDs.
+- Stable content IDs matter for deltas, updates, removals, sync events, and UI reconciliation.
+
+## Part Timing And Metadata
+
+- V1 text, reasoning, and tool states carry timing and provider metadata.
+- V2 assistant text and reasoning content only store text.
+- V2 events include metadata, but `SessionEntry` currently drops most provider metadata.
+
+## Snapshots And Patches
+
+- Snapshot parts.
+- Patch parts.
+- Step-start snapshot references.
+- Step-finish snapshot references.
+- Processor behavior that tracks a snapshot before the stream and emits patches after step finish or cleanup.
+
+## Step Boundaries
+
+- V1 stores `step-start` and `step-finish` as first-class parts.
+- V2 has `step.started` and `step.ended` events, but the assistant entry only stores aggregate cost and tokens.
+- V2 does not preserve step boundary parts, finish reason, or snapshot details in the entry model.
+
+## Compaction
+
+- V1 compaction parts have `auto`, `overflow`, and `tail_start_id`.
+- V2 compacted events have `auto` and optional `overflow`, but no retained-tail marker.
+- V1 also has history filtering semantics around completed summary messages and retained tails.
+
+## Files And Sources
+
+- V1 file parts have `mime`, `filename`, `url`, and typed source information.
+- V1 source variants include file, symbol, and resource sources.
+- Symbol sources include LSP range, name, and kind.
+- Resource sources include client name and URI.
+- V2 file attachments have `uri`, `mime`, `name`, `description`, and a generic text source, but lose source type, LSP metadata, and resource metadata.
+
+## Agents And Subtasks
+
+- Agent parts.
+- Subtask parts.
+- Subtask prompt, description, agent, model, and command.
+- V2 has agent attachments on prompts, but no assistant/session content equivalent for subtask execution.
+
+## Text Flags
+
+- Synthetic text flag.
+- Ignored text flag.
+- V2 has a separate synthetic entry, but no ignored text concept.
+
+## Tool Calls
+
+- V1 pending tool state stores parsed input and raw input text separately.
+- V2 pending tool state stores a string input but does not preserve a separate raw field.
+- V1 completed tool state has `time.start`, `time.end`, and optional `time.compacted`.
+- V2 tool time has `created`, `ran`, `completed`, and `pruned`, but the stepper currently does not set `completed` or `pruned`.
+- V1 error tool state has `time.start` and `time.end`.
+- V1 supports interrupted tool errors with `metadata.interrupted` and preserved partial output.
+- V1 tracks provider execution and provider call metadata.
+- V2 events include provider info, but `SessionEntryStepper` drops it from entries.
+- V1 has tool-output compaction and truncation behavior via `time.compacted`.
+
+## Media Handling
+
+- V1 models tool attachments as file parts and has provider-specific handling for media in tool results.
+- V1 can strip media, inject synthetic user messages for unsupported providers, and uses a synthetic attachment prompt.
+- V2 has attachments but not these model-message conversion semantics.
+
+## Retries
+
+- V1 stores retries as independently addressable retry parts.
+- V2 stores retries as an assistant aggregate.
+- V2 captures some retry information, but not the independent part identity/update model.
+
+## Processor Control Flow
+
+- Session status transitions: busy, retry, and idle.
+- Retry policy integration.
+- Context-overflow-driven compaction.
+- Abort and interrupt handling.
+- Permission-denied blocking.
+- Doom-loop detection.
+- Plugin hook for `experimental.text.complete`.
+- Background summary generation after steps.
+- Cleanup semantics for open text, reasoning, and tool calls.
+
+## Sync And Bus Events
+
+- Message updated.
+- Message removed.
+- Message part updated.
+- Message part delta.
+- Message part removed.
+- V2 has domain events, but not the sync/bus event model for persisted message and part updates/removals.
+
+## History Retrieval
+
+- Cursor encoding and decoding.
+- Paged message retrieval.
+- Reverse streaming through history.
+- Compaction-aware history filtering.

Некоторые файлы не были показаны из-за большого количества измененных файлов