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

refactor(schema): extract public event definitions (#33579)

Kit Langton 2 месяцев назад
Родитель
Сommit
24b0132bc5
93 измененных файлов с 2743 добавлено и 2127 удалено
  1. 1 0
      bun.lock
  2. 2 3
      packages/core/src/catalog.ts
  3. 23 94
      packages/core/src/event.ts
  4. 2 10
      packages/core/src/filesystem.ts
  5. 3 10
      packages/core/src/filesystem/watcher.ts
  6. 1 10
      packages/core/src/integration.ts
  7. 2 6
      packages/core/src/models-dev.ts
  8. 11 35
      packages/core/src/permission.ts
  9. 4 10
      packages/core/src/plugin.ts
  10. 2 6
      packages/core/src/project/copy.ts
  11. 4 19
      packages/core/src/pty.ts
  12. 1 13
      packages/core/src/pty/schema.ts
  13. 3 0
      packages/core/src/public-event-manifest.ts
  14. 10 58
      packages/core/src/question.ts
  15. 1 3
      packages/core/src/reference.ts
  16. 2 468
      packages/core/src/session/event.ts
  17. 4 17
      packages/core/src/session/todo.ts
  18. 2 65
      packages/core/src/v1/permission.ts
  19. 43 607
      packages/core/src/v1/session.ts
  20. 126 138
      packages/core/test/event.test.ts
  21. 16 0
      packages/core/test/legacy-event-schema.test.ts
  22. 1 0
      packages/opencode/package.json
  23. 2 11
      packages/opencode/src/command/index.ts
  24. 5 21
      packages/opencode/src/control-plane/workspace.ts
  25. 3 0
      packages/opencode/src/event-manifest.ts
  26. 2 8
      packages/opencode/src/event-v2-bridge.ts
  27. 2 9
      packages/opencode/src/ide/index.ts
  28. 2 15
      packages/opencode/src/installation/index.ts
  29. 2 4
      packages/opencode/src/lsp/lsp.ts
  30. 3 14
      packages/opencode/src/mcp/index.ts
  31. 3 13
      packages/opencode/src/permission/index.ts
  32. 10 39
      packages/opencode/src/project/project.ts
  33. 3 9
      packages/opencode/src/project/vcs.ts
  34. 20 86
      packages/opencode/src/question/index.ts
  35. 3 9
      packages/opencode/src/question/schema.ts
  36. 2 5
      packages/opencode/src/server/event.ts
  37. 6 5
      packages/opencode/src/server/routes/instance/httpapi/api.ts
  38. 3 5
      packages/opencode/src/server/routes/instance/httpapi/groups/global.ts
  39. 1 53
      packages/opencode/src/server/tui-event.ts
  40. 2 9
      packages/opencode/src/session/compaction.ts
  41. 2 14
      packages/opencode/src/session/message-v2.ts
  42. 2 61
      packages/opencode/src/session/session.ts
  43. 5 44
      packages/opencode/src/session/status.ts
  44. 5 19
      packages/opencode/src/session/todo.ts
  45. 2 10
      packages/opencode/src/snapshot/index.ts
  46. 2 16
      packages/opencode/src/worktree/index.ts
  47. 24 0
      packages/opencode/test/event-manifest.test.ts
  48. 6 0
      packages/schema/src/catalog.ts
  49. 10 0
      packages/schema/src/durable-event-manifest.ts
  50. 84 0
      packages/schema/src/event-manifest.ts
  51. 125 0
      packages/schema/src/event.ts
  52. 12 0
      packages/schema/src/file-diff.ts
  53. 13 0
      packages/schema/src/filesystem-watcher.ts
  54. 7 0
      packages/schema/src/filesystem.ts
  55. 13 0
      packages/schema/src/ide-event.ts
  56. 1 0
      packages/schema/src/index.ts
  57. 20 0
      packages/schema/src/installation-event.ts
  58. 11 0
      packages/schema/src/integration.ts
  59. 18 0
      packages/schema/src/legacy-event.ts
  60. 2 2
      packages/schema/src/location.ts
  61. 7 0
      packages/schema/src/lsp-event.ts
  62. 21 0
      packages/schema/src/mcp-event.ts
  63. 9 0
      packages/schema/src/models-dev.ts
  64. 67 0
      packages/schema/src/permission-v1.ts
  65. 48 0
      packages/schema/src/permission.ts
  66. 15 0
      packages/schema/src/plugin.ts
  67. 11 0
      packages/schema/src/project-directories.ts
  68. 34 1
      packages/schema/src/project.ts
  69. 35 0
      packages/schema/src/pty.ts
  70. 66 0
      packages/schema/src/question-v1.ts
  71. 79 0
      packages/schema/src/question.ts
  72. 4 0
      packages/schema/src/reference.ts
  73. 8 0
      packages/schema/src/server-event.ts
  74. 13 0
      packages/schema/src/session-compaction-event.ts
  75. 497 0
      packages/schema/src/session-event.ts
  76. 2 4
      packages/schema/src/session-id.ts
  77. 1 1
      packages/schema/src/session-input.ts
  78. 1 1
      packages/schema/src/session-message.ts
  79. 50 0
      packages/schema/src/session-status-event.ts
  80. 24 0
      packages/schema/src/session-todo.ts
  81. 676 0
      packages/schema/src/session-v1.ts
  82. 5 2
      packages/schema/src/session.ts
  83. 58 0
      packages/schema/src/tui-event.ts
  84. 13 0
      packages/schema/src/vcs-event.ts
  85. 32 0
      packages/schema/src/workspace-event.ts
  86. 19 0
      packages/schema/src/workspace-id.ts
  87. 6 18
      packages/schema/src/workspace.ts
  88. 21 0
      packages/schema/src/worktree-event.ts
  89. 53 0
      packages/schema/test/event-manifest.test.ts
  90. 37 0
      packages/schema/test/event.test.ts
  91. 53 0
      packages/schema/test/legacy-event.test.ts
  92. 36 30
      packages/server/src/api.ts
  93. 40 17
      packages/server/src/groups/event.ts

+ 1 - 0
bun.lock

@@ -539,6 +539,7 @@
         "@openauthjs/openauth": "catalog:",
         "@opencode-ai/llm": "workspace:*",
         "@opencode-ai/plugin": "workspace:*",
+        "@opencode-ai/schema": "workspace:*",
         "@opencode-ai/script": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/server": "workspace:*",

+ 2 - 3
packages/core/src/catalog.ts

@@ -1,6 +1,7 @@
 export * as Catalog from "./catalog"
 
 import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
+import { Catalog } from "@opencode-ai/schema/catalog"
 import { ModelV2 } from "./model"
 import { ProviderV2 } from "./provider"
 import { EventV2 } from "./event"
@@ -17,9 +18,7 @@ export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
 
 export const PolicyActions = Schema.Literals(["provider.use"])
 
-export const Event = {
-  Updated: EventV2.define({ type: "catalog.updated", schema: {} }),
-}
+export const Event = Catalog.Event
 
 type Data = {
   providers: Map<ProviderV2.ID, ProviderRecord>

+ 23 - 94
packages/core/src/event.ts

@@ -1,46 +1,19 @@
 export * as EventV2 from "./event"
 
 import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
+import { Event } from "@opencode-ai/schema/event"
+import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
 import { and, asc, eq, gt } from "drizzle-orm"
 import { Database } from "./database/database"
 import { EventSequenceTable, EventTable } from "./event/sql"
 import { Location } from "./location"
-import { withStatics } from "./schema"
-import { Identifier } from "./util/identifier"
 import { LayerNode } from "./effect/layer-node"
 import { isDeepStrictEqual } from "node:util"
+import { Durable } from "@opencode-ai/schema/durable-event-manifest"
 
-export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
-  Schema.brand("Event.ID"),
-  withStatics((schema) => ({
-    create: () => schema.make("evt_" + Identifier.ascending()),
-  })),
-)
-export type ID = typeof ID.Type
-
-export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
-  readonly type: Type
-  readonly durable?: {
-    readonly version: number
-    readonly aggregate: string
-  }
-  readonly data: DataSchema
-}
-
-export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
-
-export type Payload<D extends Definition = Definition> = {
-  readonly id: ID
-  readonly type: D["type"]
-  readonly data: Data<D>
-  readonly durable?: {
-    readonly aggregateID: string
-    readonly seq: number
-    readonly version: number
-  }
-  readonly location?: Location.Ref
-  readonly metadata?: Record<string, unknown>
-}
+export const ID = Event.ID
+export type ID = import("@opencode-ai/schema/event").ID
+export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
 
 export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
 export type Unsubscribe = Effect.Effect<void>
@@ -74,52 +47,8 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
   },
 ) {}
 
-export function versionedType(type: string, version: number) {
-  return `${type}.${version}`
-}
-
-export const registry = new Map<string, Definition>()
-const durableRegistry = new Map<string, Definition>()
-
-export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
-  readonly type: Type
-  readonly durable?: {
-    readonly version: number
-    readonly aggregate: string
-  }
-  readonly schema: Fields
-}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
-  const Data = Schema.Struct(input.schema)
-  const Payload = Schema.Struct({
-    id: ID,
-    metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
-    type: Schema.Literal(input.type),
-    durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
-    location: Schema.optional(Location.Ref),
-    data: Data,
-  }).annotate({ identifier: input.type })
-
-  const definition = Object.assign(Payload, {
-    type: input.type,
-    ...(input.durable === undefined ? {} : { durable: input.durable }),
-    data: Data,
-  })
-  const existing = registry.get(input.type)
-  if (
-    input.durable === undefined ||
-    existing?.durable === undefined ||
-    input.durable.version >= existing.durable.version
-  ) {
-    registry.set(input.type, definition)
-  }
-  if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition)
-  return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
-    Definition<Type, Schema.Struct<Fields>>
-}
-
-export function definitions() {
-  return registry.values().toArray()
-}
+export const define = Event.define
+export const versionedType = Event.versionedType
 
 export interface PublishOptions {
   readonly id?: ID
@@ -169,6 +98,7 @@ export const layerWith = (options?: LayerOptions) =>
         typed: new Map<string, PubSub.PubSub<Payload>>(),
       }
       const projectors = new Map<string, Subscriber[]>()
+      // TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
       const listeners = new Array<Subscriber>()
       const { db } = yield* Database.Service
 
@@ -194,6 +124,7 @@ export const layerWith = (options?: LayerOptions) =>
       )
 
       function commitDurableEvent(
+        definition: Definition,
         event: Payload,
         input?: {
           readonly seq: number
@@ -204,7 +135,6 @@ export const layerWith = (options?: LayerOptions) =>
         commit?: (seq: number) => Effect.Effect<void>,
       ) {
         return Effect.gen(function* () {
-          const definition = registry.get(event.type)
           const durable = definition?.durable
           if (durable) {
             const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
@@ -238,9 +168,10 @@ export const layerWith = (options?: LayerOptions) =>
                             .get()
                             .pipe(Effect.orDie)
                           const latest = row?.seq ?? -1
-                          const encoded = Schema.encodeUnknownSync(
-                            definition.data as Schema.Codec<unknown, unknown, never, never>,
-                          )(event.data) as Record<string, unknown>
+                          const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
+                            string,
+                            unknown
+                          >
                           if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
                             yield* Effect.die(
                               new InvalidDurableEventError({
@@ -356,9 +287,8 @@ export const layerWith = (options?: LayerOptions) =>
         })
       }
 
-      function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
+      function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
         return Effect.gen(function* () {
-          const definition = registry.get(event.type)
           if (!definition?.durable && commit)
             return yield* Effect.die(
               new InvalidDurableEventError({
@@ -367,7 +297,7 @@ export const layerWith = (options?: LayerOptions) =>
               }),
             )
           if (definition?.durable) {
-            const committed = yield* commitDurableEvent(event as Payload, undefined, commit)
+            const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit)
             if (committed) {
               event = {
                 ...event,
@@ -416,6 +346,7 @@ export const layerWith = (options?: LayerOptions) =>
               ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
               : undefined)
           return yield* publishEvent(
+            definition,
             {
               id: options?.id ?? ID.create(),
               ...(options?.metadata ? { metadata: options.metadata } : {}),
@@ -433,7 +364,7 @@ export const layerWith = (options?: LayerOptions) =>
         options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
       ) {
         return Effect.gen(function* () {
-          const definition = durableRegistry.get(event.type)
+          const definition = Durable.get(event.type)
           if (!definition?.durable) {
             yield* Effect.die(
               new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
@@ -442,11 +373,9 @@ export const layerWith = (options?: LayerOptions) =>
             const payload = {
               id: event.id,
               type: definition.type,
-              data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(
-                event.data,
-              ),
+              data: Schema.decodeUnknownSync(definition.data)(event.data),
             } as Payload
-            const committed = yield* commitDurableEvent(payload, {
+            const committed = yield* commitDurableEvent(definition, payload, {
               seq: event.seq,
               aggregateID: event.aggregateID,
               ownerID: options?.ownerID,
@@ -530,8 +459,8 @@ export const layerWith = (options?: LayerOptions) =>
 
       const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
 
-      const decodeSerializedEvent = (event: SerializedEvent): Payload => {
-        const definition = durableRegistry.get(event.type)
+      const decodeSerializedEvent = (event: SerializedEvent) => {
+        const definition = Durable.get(event.type)
         if (!definition?.durable) {
           throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
         }
@@ -539,7 +468,7 @@ export const layerWith = (options?: LayerOptions) =>
           id: event.id,
           type: definition.type,
           durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
-          data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
+          data: Schema.decodeUnknownSync(definition.data)(event.data),
         }
       }
 

+ 2 - 10
packages/core/src/filesystem.ts

@@ -2,12 +2,11 @@ export * as FileSystem from "./filesystem"
 
 import path from "path"
 import { Context, Effect, Layer, Schema } from "effect"
-import { EventV2 } from "./event"
 import { FSUtil } from "./fs-util"
 import { Location } from "./location"
 import { PositiveInt, RelativePath } from "./schema"
 import { FileSystemSearch } from "./filesystem/search"
-import { Entry, Match } from "@opencode-ai/schema/filesystem"
+import { Entry, FileSystem, Match } from "@opencode-ai/schema/filesystem"
 export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
 
 export const ReadInput = Schema.Struct({
@@ -48,14 +47,7 @@ export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
   limit: PositiveInt.pipe(Schema.optional),
 }) {}
 
-export const Event = {
-  Edited: EventV2.define({
-    type: "file.edited",
-    schema: {
-      file: Schema.String,
-    },
-  }),
-}
+export const Event = FileSystem.Event
 
 export interface Interface {
   readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>

+ 3 - 10
packages/core/src/filesystem/watcher.ts

@@ -3,7 +3,8 @@ export * as Watcher from "./watcher"
 // @ts-ignore
 import { createWrapper } from "@parcel/watcher/wrapper"
 import type ParcelWatcher from "@parcel/watcher"
-import { Cause, Context, Effect, Layer, Schema } from "effect"
+import { Cause, Context, Effect, Layer } from "effect"
+import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
 import path from "path"
 import { Config } from "../config"
 import { EventV2 } from "../event"
@@ -19,15 +20,7 @@ declare const OPENCODE_LIBC: string | undefined
 
 const SUBSCRIBE_TIMEOUT_MS = 10_000
 
-export const Event = {
-  Updated: EventV2.define({
-    type: "file.watcher.updated",
-    schema: {
-      file: Schema.String,
-      event: Schema.Literals(["add", "change", "unlink"]),
-    },
-  }),
-}
+export const Event = FileSystemWatcher.Event
 
 const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
   try {

+ 1 - 10
packages/core/src/integration.ts

@@ -136,16 +136,7 @@ export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationErr
 
 export type Error = CodeRequiredError | AuthorizationError
 
-export const Event = {
-  Updated: EventV2.define({
-    type: "integration.updated",
-    schema: {},
-  }),
-  ConnectionUpdated: EventV2.define({
-    type: "integration.connection.updated",
-    schema: { integrationID: ID },
-  }),
-}
+export const Event = Integration.Event
 
 export const Ref = Integration.Ref
 export type Ref = Integration.Ref

+ 2 - 6
packages/core/src/models-dev.ts

@@ -1,6 +1,7 @@
 import path from "path"
 import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
+import { ModelsDev } from "@opencode-ai/schema/models-dev"
 import { Global } from "./global"
 import { Flag } from "./flag/flag"
 import { Flock } from "./util/flock"
@@ -108,12 +109,7 @@ export const Provider = Schema.Struct({
 
 export type Provider = Schema.Schema.Type<typeof Provider>
 
-export const Event = {
-  Refreshed: EventV2.define({
-    type: "models-dev.refreshed",
-    schema: {},
-  }),
-}
+export const Event = ModelsDev.Event
 
 declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
 

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

@@ -7,45 +7,31 @@ import { Location } from "./location"
 import { AgentV2 } from "./agent"
 import { SessionV2 } from "./session"
 import { SessionStore } from "./session/store"
-import { withStatics } from "./schema"
-import { Identifier } from "./util/identifier"
 import { Wildcard } from "./util/wildcard"
 import { PermissionSaved } from "./permission/saved"
 
 export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission"
 const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
 
-export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
-  Schema.brand("PermissionV2.ID"),
-  withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
-)
+export const ID = Permission.ID
 export type ID = typeof ID.Type
 
-export const Source = Schema.Union([
-  Schema.Struct({
-    type: Schema.Literal("tool"),
-    messageID: Schema.String,
-    callID: Schema.String,
-  }),
-]).annotate({ identifier: "PermissionV2.Source" })
+export const Source = Permission.Source
 export type Source = typeof Source.Type
 
 const RequestFields = {
-  sessionID: SessionV2.ID,
-  action: Schema.String,
-  resources: Schema.Array(Schema.String),
-  save: Schema.Array(Schema.String).pipe(Schema.optional),
-  metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
-  source: Source.pipe(Schema.optional),
+  sessionID: Permission.Request.fields.sessionID,
+  action: Permission.Request.fields.action,
+  resources: Permission.Request.fields.resources,
+  save: Permission.Request.fields.save,
+  metadata: Permission.Request.fields.metadata,
+  source: Permission.Request.fields.source,
 }
 
-export const Request = Schema.Struct({
-  id: ID,
-  ...RequestFields,
-}).annotate({ identifier: "PermissionV2.Request" })
+export const Request = Permission.Request
 export type Request = typeof Request.Type
 
-export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" })
+export const Reply = Permission.Reply
 export type Reply = typeof Reply.Type
 
 export const AssertInput = Schema.Struct({
@@ -68,17 +54,7 @@ export const AskResult = Schema.Struct({
 }).annotate({ identifier: "PermissionV2.AskResult" })
 export type AskResult = typeof AskResult.Type
 
-export const Event = {
-  Asked: EventV2.define({ type: "permission.v2.asked", schema: Request.fields }),
-  Replied: EventV2.define({
-    type: "permission.v2.replied",
-    schema: {
-      sessionID: SessionV2.ID,
-      requestID: ID,
-      reply: Reply,
-    },
-  }),
-}
+export const Event = Permission.Event
 
 export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
 

+ 4 - 10
packages/core/src/plugin.ts

@@ -1,7 +1,8 @@
 export * as PluginV2 from "./plugin"
 
-import { Context, Deferred, Effect, Exit, Layer, Schema, Scope } from "effect"
+import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
 import type { Plugin } from "@opencode-ai/plugin/v2/effect"
+import { PluginEvent, PluginID } from "@opencode-ai/schema/plugin"
 import { AgentV2 } from "./agent"
 import { AISDK } from "./aisdk"
 import { Catalog } from "./catalog"
@@ -14,17 +15,10 @@ import { Reference } from "./reference"
 import { SkillV2 } from "./skill"
 import { State } from "./state"
 
-export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
+export const ID = PluginID
 export type ID = typeof ID.Type
 
-export const Event = {
-  Added: EventV2.define({
-    type: "plugin.added",
-    schema: {
-      id: ID,
-    },
-  }),
-}
+export const Event = PluginEvent
 
 export interface Interface {
   readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void>

+ 2 - 6
packages/core/src/project/copy.ts

@@ -13,6 +13,7 @@ import { Slug } from "../util/slug"
 import { EventV2 } from "../event"
 import { Database } from "../database/database"
 import { Location } from "../location"
+import { ProjectDirectoriesEvent } from "@opencode-ai/schema/project-directories"
 
 export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
 export type StrategyID = typeof StrategyID.Type
@@ -106,12 +107,7 @@ export interface Strategy {
   readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
 }
 
-export const Event = {
-  Updated: EventV2.define({
-    type: "project.directories.updated",
-    schema: { projectID: Project.ID },
-  }),
-}
+export const Event = ProjectDirectoriesEvent
 
 export interface Interface {
   readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>

+ 4 - 19
packages/core/src/pty.ts

@@ -2,10 +2,11 @@ export * as Pty from "./pty"
 
 import type { Disp, Proc } from "#pty"
 import { Context, Effect, Layer, Schema, Types } from "effect"
+import { PtyEvent, PtyInfo } from "@opencode-ai/schema/pty"
 import { Config } from "./config"
 import { EventV2 } from "./event"
 import { Location } from "./location"
-import { NonNegativeInt, PositiveInt } from "./schema"
+import { PositiveInt } from "./schema"
 import { PtyID } from "./pty/schema"
 import { Shell } from "./shell"
 import { lazy } from "./util/lazy"
@@ -35,18 +36,7 @@ type Active = {
   listeners: Disp[]
 }
 
-export const Info = Schema.Struct({
-  id: PtyID,
-  title: Schema.String,
-  command: Schema.String,
-  args: Schema.Array(Schema.String),
-  cwd: Schema.String,
-  status: Schema.Literals(["running", "exited"]),
-  // Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time.
-  pid: NonNegativeInt,
-  // Present once status is "exited".
-  exitCode: Schema.optional(NonNegativeInt),
-}).annotate({ identifier: "Pty" })
+export const Info = PtyInfo
 
 export type Info = Types.DeepMutable<typeof Info.Type>
 
@@ -100,12 +90,7 @@ export class ExitedError extends Schema.TaggedErrorClass<ExitedError>()("Pty.Exi
   ptyID: PtyID,
 }) {}
 
-export const Event = {
-  Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
-  Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
-  Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }),
-  Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }),
-}
+export const Event = PtyEvent
 
 export interface Interface {
   readonly list: () => Effect.Effect<Info[]>

+ 1 - 13
packages/core/src/pty/schema.ts

@@ -1,13 +1 @@
-import { Schema } from "effect"
-import { Identifier } from "../id/id"
-import { withStatics } from "../schema"
-
-const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
-
-export type PtyID = typeof ptyIdSchema.Type
-
-export const PtyID = ptyIdSchema.pipe(
-  withStatics((schema: typeof ptyIdSchema) => ({
-    ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)),
-  })),
-)
+export { ID as PtyID } from "@opencode-ai/schema/pty"

+ 3 - 0
packages/core/src/public-event-manifest.ts

@@ -0,0 +1,3 @@
+export * as PublicEventManifest from "./public-event-manifest"
+
+export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest"

+ 10 - 58
packages/core/src/question.ts

@@ -1,83 +1,35 @@
 export * as QuestionV2 from "./question"
 
 import { Context, Deferred, Effect, Layer, Schema } from "effect"
+import { Question } from "@opencode-ai/schema/question"
 import { EventV2 } from "./event"
-import { Identifier } from "./id/id"
-import { withStatics } from "./schema"
 import { SessionSchema } from "./session/schema"
 
-export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe(
-  Schema.brand("QuestionV2.ID"),
-  withStatics((schema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("question", id)) })),
-)
+export const ID = Question.ID
 export type ID = typeof ID.Type
 
-export const Option = Schema.Struct({
-  label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
-  description: Schema.String.annotate({ description: "Explanation of choice" }),
-}).annotate({ identifier: "QuestionV2.Option" })
+export const Option = Question.Option
 export type Option = typeof Option.Type
 
-const base = {
-  question: Schema.String.annotate({ description: "Complete question" }),
-  header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }),
-  options: Schema.Array(Option).annotate({ description: "Available choices" }),
-  multiple: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Allow selecting multiple choices" }),
-}
-
-export const Info = Schema.Struct({
-  ...base,
-  custom: Schema.Boolean.pipe(Schema.optional).annotate({
-    description: "Allow typing a custom answer (default: true)",
-  }),
-}).annotate({ identifier: "QuestionV2.Info" })
+export const Info = Question.Info
 export type Info = typeof Info.Type
 
-export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" })
+export const Prompt = Question.Prompt
 export type Prompt = typeof Prompt.Type
 
-export const Tool = Schema.Struct({
-  messageID: Schema.String,
-  callID: Schema.String,
-}).annotate({ identifier: "QuestionV2.Tool" })
+export const Tool = Question.Tool
 export type Tool = typeof Tool.Type
 
-export const Request = Schema.Struct({
-  id: ID,
-  sessionID: SessionSchema.ID,
-  questions: Schema.Array(Info).annotate({ description: "Questions to ask" }),
-  tool: Tool.pipe(Schema.optional),
-}).annotate({ identifier: "QuestionV2.Request" })
+export const Request = Question.Request
 export type Request = typeof Request.Type
 
-export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" })
+export const Answer = Question.Answer
 export type Answer = typeof Answer.Type
 
-export const Reply = Schema.Struct({
-  answers: Schema.Array(Answer).annotate({
-    description: "User answers in order of questions (each answer is an array of selected labels)",
-  }),
-}).annotate({ identifier: "QuestionV2.Reply" })
+export const Reply = Question.Reply
 export type Reply = typeof Reply.Type
 
-export const Event = {
-  Asked: EventV2.define({ type: "question.v2.asked", schema: Request.fields }),
-  Replied: EventV2.define({
-    type: "question.v2.replied",
-    schema: {
-      sessionID: SessionSchema.ID,
-      requestID: ID,
-      answers: Schema.Array(Answer),
-    },
-  }),
-  Rejected: EventV2.define({
-    type: "question.v2.rejected",
-    schema: {
-      sessionID: SessionSchema.ID,
-      requestID: ID,
-    },
-  }),
-}
+export const Event = Question.Event
 
 export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionV2.RejectedError", {}) {
   override get message() {

+ 1 - 3
packages/core/src/reference.ts

@@ -18,9 +18,7 @@ export type GitSource = Reference.GitSource
 export const Source = Reference.Source
 export type Source = Reference.Source
 
-export const Event = {
-  Updated: EventV2.define({ type: "reference.updated", schema: {} }),
-}
+export const Event = Reference.Event
 
 export class Info extends Schema.Class<Info>("Reference.Info")({
   name: Schema.String,

+ 2 - 468
packages/core/src/session/event.ts

@@ -1,468 +1,2 @@
-import { Schema } from "effect"
-import { ProviderMetadata, ToolContent } from "@opencode-ai/schema/llm"
-import { Delivery } from "@opencode-ai/schema/session-delivery"
-import { EventV2 } from "../event"
-import { ModelV2 } from "../model"
-import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "../schema"
-import { FileAttachment, Prompt } from "./prompt"
-import { SessionSchema } from "./schema"
-import { Location } from "../location"
-import { SessionMessageID } from "./message-id"
-import { SessionMessage } from "./message"
-
-export { FileAttachment }
-
-export const Source = Schema.Struct({
-  start: NonNegativeInt,
-  end: NonNegativeInt,
-  text: Schema.String,
-}).annotate({
-  identifier: "session.next.event.source",
-})
-export type Source = typeof Source.Type
-
-const Base = {
-  timestamp: DateTimeUtcFromMillis,
-  sessionID: SessionSchema.ID,
-}
-const PromptFields = {
-  ...Base,
-  messageID: SessionMessageID.ID,
-  prompt: Prompt,
-  delivery: Delivery,
-}
-
-const options = {
-  durable: {
-    aggregate: "sessionID",
-    version: 1,
-  },
-} as const
-const stepSettlementOptions = {
-  durable: {
-    aggregate: "sessionID",
-    version: 2,
-  },
-} as const
-
-export const UnknownError = SessionMessage.UnknownError
-export type UnknownError = SessionMessage.UnknownError
-
-export const AgentSwitched = EventV2.define({
-  type: "session.next.agent.switched",
-  ...options,
-  schema: {
-    ...Base,
-    messageID: SessionMessageID.ID,
-    agent: Schema.String,
-  },
-})
-export type AgentSwitched = typeof AgentSwitched.Type
-
-export const ModelSwitched = EventV2.define({
-  type: "session.next.model.switched",
-  ...options,
-  schema: {
-    ...Base,
-    messageID: SessionMessageID.ID,
-    model: ModelV2.Ref,
-  },
-})
-export type ModelSwitched = typeof ModelSwitched.Type
-
-export const Moved = EventV2.define({
-  type: "session.next.moved",
-  ...options,
-  schema: {
-    ...Base,
-    location: Location.Ref,
-    subdirectory: RelativePath.pipe(Schema.optional),
-  },
-})
-export type Moved = typeof Moved.Type
-
-export const Prompted = EventV2.define({
-  type: "session.next.prompted",
-  ...options,
-  schema: PromptFields,
-})
-export type Prompted = typeof Prompted.Type
-
-export const PromptAdmitted = EventV2.define({
-  type: "session.next.prompt.admitted",
-  ...options,
-  schema: PromptFields,
-})
-export type PromptAdmitted = typeof PromptAdmitted.Type
-
-export const ContextUpdated = EventV2.define({
-  type: "session.next.context.updated",
-  ...options,
-  schema: {
-    ...Base,
-    messageID: SessionMessageID.ID,
-    text: Schema.String,
-  },
-})
-export type ContextUpdated = typeof ContextUpdated.Type
-
-export const Synthetic = EventV2.define({
-  type: "session.next.synthetic",
-  ...options,
-  schema: {
-    ...Base,
-    messageID: SessionMessageID.ID,
-    text: Schema.String,
-  },
-})
-export type Synthetic = typeof Synthetic.Type
-
-export namespace Shell {
-  export const Started = EventV2.define({
-    type: "session.next.shell.started",
-    ...options,
-    schema: {
-      ...Base,
-      messageID: SessionMessageID.ID,
-      callID: Schema.String,
-      command: Schema.String,
-    },
-  })
-  export type Started = typeof Started.Type
-
-  export const Ended = EventV2.define({
-    type: "session.next.shell.ended",
-    ...options,
-    schema: {
-      ...Base,
-      callID: Schema.String,
-      output: Schema.String,
-    },
-  })
-  export type Ended = typeof Ended.Type
-}
-
-export namespace Step {
-  export const Started = EventV2.define({
-    type: "session.next.step.started",
-    ...options,
-    schema: {
-      ...Base,
-      assistantMessageID: SessionMessageID.ID,
-      agent: Schema.String,
-      model: ModelV2.Ref,
-      snapshot: Schema.String.pipe(Schema.optional),
-    },
-  })
-  export type Started = typeof Started.Type
-
-  export const Ended = EventV2.define({
-    type: "session.next.step.ended",
-    ...stepSettlementOptions,
-    schema: {
-      ...Base,
-      assistantMessageID: SessionMessageID.ID,
-      finish: Schema.String,
-      cost: Schema.Finite,
-      tokens: Schema.Struct({
-        input: Schema.Finite,
-        output: Schema.Finite,
-        reasoning: Schema.Finite,
-        cache: Schema.Struct({
-          read: Schema.Finite,
-          write: Schema.Finite,
-        }),
-      }),
-      snapshot: Schema.String.pipe(Schema.optional),
-    },
-  })
-  export type Ended = typeof Ended.Type
-
-  export const Failed = EventV2.define({
-    type: "session.next.step.failed",
-    ...stepSettlementOptions,
-    schema: {
-      ...Base,
-      assistantMessageID: SessionMessageID.ID,
-      error: UnknownError,
-    },
-  })
-  export type Failed = typeof Failed.Type
-}
-
-export namespace Text {
-  export const Started = EventV2.define({
-    type: "session.next.text.started",
-    ...options,
-    schema: {
-      ...Base,
-      assistantMessageID: SessionMessageID.ID,
-      textID: Schema.String,
-    },
-  })
-  export type Started = typeof Started.Type
-
-  // Stream fragments are live-only; Text.Ended is the replayable full-value boundary.
-  export const Delta = EventV2.define({
-    type: "session.next.text.delta",
-    schema: {
-      ...Base,
-      assistantMessageID: SessionMessageID.ID,
-      textID: Schema.String,
-      delta: Schema.String,
-    },
-  })
-  export type Delta = typeof Delta.Type
-
-  export const Ended = EventV2.define({
-    type: "session.next.text.ended",
-    ...options,
-    schema: {
-      ...Base,
-      assistantMessageID: SessionMessageID.ID,
-      textID: Schema.String,
-      text: Schema.String,
-    },
-  })
-  export type Ended = typeof Ended.Type
-}
-
-export namespace Reasoning {
-  export const Started = EventV2.define({
-    type: "session.next.reasoning.started",
-    ...options,
-    schema: {
-      ...Base,
-      assistantMessageID: SessionMessageID.ID,
-      reasoningID: Schema.String,
-      providerMetadata: ProviderMetadata.pipe(Schema.optional),
-    },
-  })
-  export type Started = typeof Started.Type
-
-  // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary.
-  export const Delta = EventV2.define({
-    type: "session.next.reasoning.delta",
-    schema: {
-      ...Base,
-      assistantMessageID: SessionMessageID.ID,
-      reasoningID: Schema.String,
-      delta: Schema.String,
-    },
-  })
-  export type Delta = typeof Delta.Type
-
-  export const Ended = EventV2.define({
-    type: "session.next.reasoning.ended",
-    ...options,
-    schema: {
-      ...Base,
-      assistantMessageID: SessionMessageID.ID,
-      reasoningID: Schema.String,
-      text: Schema.String,
-      providerMetadata: ProviderMetadata.pipe(Schema.optional),
-    },
-  })
-  export type Ended = typeof Ended.Type
-}
-
-export namespace Tool {
-  const ToolBase = {
-    ...Base,
-    assistantMessageID: SessionMessageID.ID,
-    callID: Schema.String,
-  }
-
-  export namespace Input {
-    export const Started = EventV2.define({
-      type: "session.next.tool.input.started",
-      ...options,
-      schema: {
-        ...ToolBase,
-        name: Schema.String,
-      },
-    })
-    export type Started = typeof Started.Type
-
-    // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary.
-    export const Delta = EventV2.define({
-      type: "session.next.tool.input.delta",
-      schema: {
-        ...ToolBase,
-        delta: Schema.String,
-      },
-    })
-    export type Delta = typeof Delta.Type
-
-    export const Ended = EventV2.define({
-      type: "session.next.tool.input.ended",
-      ...options,
-      schema: {
-        ...ToolBase,
-        text: Schema.String,
-      },
-    })
-    export type Ended = typeof Ended.Type
-  }
-
-  export const Called = EventV2.define({
-    type: "session.next.tool.called",
-    ...options,
-    schema: {
-      ...ToolBase,
-      tool: Schema.String,
-      input: Schema.Record(Schema.String, Schema.Unknown),
-      provider: Schema.Struct({
-        executed: Schema.Boolean,
-        metadata: ProviderMetadata.pipe(Schema.optional),
-      }),
-    },
-  })
-  export type Called = typeof Called.Type
-
-  /**
-   * Replayable bounded running-tool state. Tools should checkpoint semantic
-   * transitions or at a bounded cadence, not persist every stdout/stderr chunk.
-   */
-  export const Progress = EventV2.define({
-    type: "session.next.tool.progress",
-    ...options,
-    schema: {
-      ...ToolBase,
-      structured: Schema.Record(Schema.String, Schema.Any),
-      content: Schema.Array(ToolContent),
-    },
-  })
-  export type Progress = typeof Progress.Type
-
-  export const Success = EventV2.define({
-    type: "session.next.tool.success",
-    ...options,
-    schema: {
-      ...ToolBase,
-      structured: Schema.Record(Schema.String, Schema.Any),
-      content: Schema.Array(ToolContent),
-      outputPaths: Schema.Array(Schema.String).pipe(Schema.optional),
-      result: Schema.Unknown.pipe(Schema.optional),
-      provider: Schema.Struct({
-        executed: Schema.Boolean,
-        metadata: ProviderMetadata.pipe(Schema.optional),
-      }),
-    },
-  })
-  export type Success = typeof Success.Type
-
-  export const Failed = EventV2.define({
-    type: "session.next.tool.failed",
-    ...options,
-    schema: {
-      ...ToolBase,
-      error: UnknownError,
-      result: Schema.Unknown.pipe(Schema.optional),
-      provider: Schema.Struct({
-        executed: Schema.Boolean,
-        metadata: ProviderMetadata.pipe(Schema.optional),
-      }),
-    },
-  })
-  export type Failed = typeof Failed.Type
-}
-
-export const RetryError = Schema.Struct({
-  message: Schema.String,
-  statusCode: Schema.Finite.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 = typeof RetryError.Type
-
-export const Retried = EventV2.define({
-  type: "session.next.retried",
-  ...options,
-  schema: {
-    ...Base,
-    attempt: Schema.Finite,
-    error: RetryError,
-  },
-})
-export type Retried = typeof Retried.Type
-
-export namespace Compaction {
-  export const Started = EventV2.define({
-    type: "session.next.compaction.started",
-    ...options,
-    schema: {
-      ...Base,
-      messageID: SessionMessageID.ID,
-      reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
-    },
-  })
-  export type Started = typeof Started.Type
-
-  export const Delta = EventV2.define({
-    type: "session.next.compaction.delta",
-    schema: {
-      ...Base,
-      messageID: SessionMessageID.ID,
-      text: Schema.String,
-    },
-  })
-  export type Delta = typeof Delta.Type
-
-  export const Ended = EventV2.define({
-    type: "session.next.compaction.ended",
-    ...options,
-    schema: {
-      ...Base,
-      messageID: SessionMessageID.ID,
-      reason: Started.data.fields.reason,
-      text: Schema.String,
-      recent: Schema.String,
-    },
-  })
-  export type Ended = typeof Ended.Type
-}
-
-const DurableDefinitions = [
-  AgentSwitched,
-  ModelSwitched,
-  Moved,
-  Prompted,
-  PromptAdmitted,
-  ContextUpdated,
-  Synthetic,
-  Shell.Started,
-  Shell.Ended,
-  Step.Started,
-  Step.Ended,
-  Step.Failed,
-  Text.Started,
-  Text.Ended,
-  Tool.Input.Started,
-  Tool.Input.Ended,
-  Tool.Called,
-  Tool.Progress,
-  Tool.Success,
-  Tool.Failed,
-  Reasoning.Started,
-  Reasoning.Ended,
-  Retried,
-  Compaction.Started,
-  Compaction.Ended,
-] as const
-const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta, Compaction.Delta] as const
-
-export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
-export type DurableEvent = typeof Durable.Type
-
-export const All = Schema.Union([...DurableDefinitions, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
-  Schema.toTaggedUnion("type"),
-)
-export type Event = typeof All.Type
-export type Type = Event["type"]
-
-export * as SessionEvent from "./event"
+export * from "@opencode-ai/schema/session-event"
+export * as SessionEvent from "@opencode-ai/schema/session-event"

+ 4 - 17
packages/core/src/session/todo.ts

@@ -1,30 +1,17 @@
 export * as SessionTodo from "./todo"
 
 import { asc, eq } from "drizzle-orm"
-import { Context, Effect, Layer, Schema } from "effect"
+import { Context, Effect, Layer } from "effect"
+import { SessionTodo, SessionTodoInfo } from "@opencode-ai/schema/session-todo"
 import { Database } from "../database/database"
 import { EventV2 } from "../event"
 import { SessionSchema } from "./schema"
 import { TodoTable } from "./sql"
 
-export const Info = Schema.Struct({
-  content: Schema.String.annotate({ description: "Brief description of the task" }),
-  status: Schema.String.annotate({
-    description: "Current status of the task: pending, in_progress, completed, cancelled",
-  }),
-  priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
-}).annotate({ identifier: "SessionTodo.Info" })
+export const Info = SessionTodoInfo
 export type Info = typeof Info.Type
 
-export const Event = {
-  Updated: EventV2.define({
-    type: "todo.updated",
-    schema: {
-      sessionID: SessionSchema.ID,
-      todos: Schema.Array(Info),
-    },
-  }),
-}
+export const Event = SessionTodo.Event
 
 export interface Interface {
   readonly update: (input: {

+ 2 - 65
packages/core/src/v1/permission.ts

@@ -1,71 +1,8 @@
 export * as PermissionV1 from "./permission"
 
 import { Schema } from "effect"
-import { ProjectV2 } from "../project"
-import { withStatics } from "../schema"
-import { SessionSchema } from "../session/schema"
-import { Identifier } from "../util/identifier"
-
-export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
-  Schema.brand("PermissionID"),
-  withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
-)
-export type ID = typeof ID.Type
-
-export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" })
-export type Action = typeof Action.Type
-
-export const Rule = Schema.Struct({
-  permission: Schema.String,
-  pattern: Schema.String,
-  action: Action,
-}).annotate({ identifier: "PermissionRule" })
-export type Rule = typeof Rule.Type
-
-export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
-export type Ruleset = typeof Ruleset.Type
-
-export const Request = Schema.Struct({
-  id: ID,
-  sessionID: SessionSchema.ID,
-  permission: Schema.String,
-  patterns: Schema.Array(Schema.String),
-  metadata: Schema.Record(Schema.String, Schema.Unknown),
-  always: Schema.Array(Schema.String),
-  tool: Schema.Struct({
-    messageID: Schema.String,
-    callID: Schema.String,
-  }).pipe(Schema.optional),
-}).annotate({ identifier: "PermissionRequest" })
-export type Request = typeof Request.Type
-
-export const Reply = Schema.Literals(["once", "always", "reject"])
-export type Reply = typeof Reply.Type
-
-export const ReplyBody = Schema.Struct({
-  reply: Reply,
-  message: Schema.String.pipe(Schema.optional),
-}).annotate({ identifier: "PermissionReplyBody" })
-export type ReplyBody = typeof ReplyBody.Type
-
-export const Approval = Schema.Struct({
-  projectID: ProjectV2.ID,
-  patterns: Schema.Array(Schema.String),
-}).annotate({ identifier: "PermissionApproval" })
-export type Approval = typeof Approval.Type
-
-export const AskInput = Schema.Struct({
-  ...Request.fields,
-  id: ID.pipe(Schema.optional),
-  ruleset: Ruleset,
-}).annotate({ identifier: "PermissionAskInput" })
-export type AskInput = typeof AskInput.Type
-
-export const ReplyInput = Schema.Struct({
-  requestID: ID,
-  ...ReplyBody.fields,
-}).annotate({ identifier: "PermissionReplyInput" })
-export type ReplyInput = typeof ReplyInput.Type
+export * from "@opencode-ai/schema/permission-v1"
+import { ID } from "@opencode-ai/schema/permission-v1"
 
 export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
   override get message() {

+ 43 - 607
packages/core/src/v1/session.ts

@@ -1,39 +1,52 @@
 export * as SessionV1 from "./session"
 
-import { Effect, Schema, Types } from "effect"
-import { EventV2 } from "../event"
-import { PermissionV1 } from "./permission"
-import { ProjectV2 } from "../project"
-import { ProviderV2 } from "../provider"
-import { ModelV2 } from "../model"
-import { optionalOmitUndefined, withStatics } from "../schema"
-import { Identifier } from "../util/identifier"
+import { Schema } from "effect"
 import { NonNegativeInt } from "../schema"
 import { NamedError } from "../util/error"
-import { SessionSchema } from "../session/schema"
-import { WorkspaceV2 } from "../workspace"
 
-const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))
-
-export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
-  Schema.brand("MessageID"),
-  withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + Identifier.ascending()) })),
-)
-export type MessageID = typeof MessageID.Type
-
-export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
-  Schema.brand("PartID"),
-  withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + Identifier.ascending()) })),
-)
-export type PartID = typeof PartID.Type
+export {
+  AgentPart,
+  AgentPartInput,
+  Assistant,
+  CompactionPart,
+  Event,
+  FilePart,
+  FilePartInput,
+  FilePartSource,
+  FileSource,
+  Format,
+  Info,
+  MessageID,
+  OutputFormatJsonSchema,
+  OutputFormatText,
+  Part,
+  PartID,
+  PatchPart,
+  Range,
+  ReasoningPart,
+  ResourceSource,
+  RetryPart,
+  SessionInfo,
+  SnapshotPart,
+  StepFinishPart,
+  StepStartPart,
+  SubtaskPart,
+  SubtaskPartInput,
+  SymbolSource,
+  TextPart,
+  TextPartInput,
+  ToolPart,
+  ToolState,
+  ToolStateCompleted,
+  ToolStateError,
+  ToolStatePending,
+  ToolStateRunning,
+  User,
+  WithParts,
+} from "@opencode-ai/schema/session-v1"
 
 export const OutputLengthError = NamedError.create("MessageOutputLengthError", {})
-
-export const AuthError = NamedError.create("ProviderAuthError", {
-  providerID: Schema.String,
-  message: Schema.String,
-})
-
+export const AuthError = NamedError.create("ProviderAuthError", { providerID: Schema.String, message: Schema.String })
 export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String })
 export const StructuredOutputError = NamedError.create("StructuredOutputError", {
   message: Schema.String,
@@ -52,581 +65,4 @@ export const ContextOverflowError = NamedError.create("ContextOverflowError", {
   message: Schema.String,
   responseBody: Schema.optional(Schema.String),
 })
-export const ContentFilterError = NamedError.create("ContentFilterError", {
-  message: Schema.String,
-})
-
-export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({
-  type: Schema.Literal("text"),
-}) {}
-
-export class OutputFormatJsonSchema extends Schema.Class<OutputFormatJsonSchema>("OutputFormatJsonSchema")({
-  type: Schema.Literal("json_schema"),
-  schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }),
-  retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
-}) {}
-
-export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({
-  discriminator: "type",
-  identifier: "OutputFormat",
-})
-export type OutputFormat = Schema.Schema.Type<typeof Format>
-
-const partBase = {
-  id: PartID,
-  sessionID: SessionSchema.ID,
-  messageID: MessageID,
-}
-
-export const SnapshotPart = Schema.Struct({
-  ...partBase,
-  type: Schema.Literal("snapshot"),
-  snapshot: Schema.String,
-}).annotate({ identifier: "SnapshotPart" })
-export type SnapshotPart = Types.DeepMutable<Schema.Schema.Type<typeof SnapshotPart>>
-
-export const PatchPart = Schema.Struct({
-  ...partBase,
-  type: Schema.Literal("patch"),
-  hash: Schema.String,
-  files: Schema.Array(Schema.String),
-}).annotate({ identifier: "PatchPart" })
-export type PatchPart = Types.DeepMutable<Schema.Schema.Type<typeof PatchPart>>
-
-export const TextPart = Schema.Struct({
-  ...partBase,
-  type: Schema.Literal("text"),
-  text: Schema.String,
-  synthetic: Schema.optional(Schema.Boolean),
-  ignored: Schema.optional(Schema.Boolean),
-  time: Schema.optional(
-    Schema.Struct({
-      start: NonNegativeInt,
-      end: Schema.optional(NonNegativeInt),
-    }),
-  ),
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
-}).annotate({ identifier: "TextPart" })
-export type TextPart = Types.DeepMutable<Schema.Schema.Type<typeof TextPart>>
-
-export const ReasoningPart = Schema.Struct({
-  ...partBase,
-  type: Schema.Literal("reasoning"),
-  text: Schema.String,
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
-  time: Schema.Struct({
-    start: NonNegativeInt,
-    end: Schema.optional(NonNegativeInt),
-  }),
-}).annotate({ identifier: "ReasoningPart" })
-export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof ReasoningPart>>
-
-const filePartSourceBase = {
-  text: Schema.Struct({
-    value: Schema.String,
-    start: Schema.Finite,
-    end: Schema.Finite,
-  }).annotate({ identifier: "FilePartSourceText" }),
-}
-
-export const Range = Schema.Struct({
-  start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }),
-  end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }),
-}).annotate({ identifier: "Range" })
-export type Range = typeof Range.Type
-
-export const FileSource = Schema.Struct({
-  ...filePartSourceBase,
-  type: Schema.Literal("file"),
-  path: Schema.String,
-}).annotate({ identifier: "FileSource" })
-
-export const SymbolSource = Schema.Struct({
-  ...filePartSourceBase,
-  type: Schema.Literal("symbol"),
-  path: Schema.String,
-  range: Range,
-  name: Schema.String,
-  kind: NonNegativeInt,
-}).annotate({ identifier: "SymbolSource" })
-
-export const ResourceSource = Schema.Struct({
-  ...filePartSourceBase,
-  type: Schema.Literal("resource"),
-  clientName: Schema.String,
-  uri: Schema.String,
-}).annotate({ identifier: "ResourceSource" })
-
-export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({
-  discriminator: "type",
-  identifier: "FilePartSource",
-})
-
-export const FilePart = Schema.Struct({
-  ...partBase,
-  type: Schema.Literal("file"),
-  mime: Schema.String,
-  filename: Schema.optional(Schema.String),
-  url: Schema.String,
-  source: Schema.optional(FilePartSource),
-}).annotate({ identifier: "FilePart" })
-export type FilePart = Types.DeepMutable<Schema.Schema.Type<typeof FilePart>>
-
-export const AgentPart = Schema.Struct({
-  ...partBase,
-  type: Schema.Literal("agent"),
-  name: Schema.String,
-  source: Schema.optional(
-    Schema.Struct({
-      value: Schema.String,
-      start: NonNegativeInt,
-      end: NonNegativeInt,
-    }),
-  ),
-}).annotate({ identifier: "AgentPart" })
-export type AgentPart = Types.DeepMutable<Schema.Schema.Type<typeof AgentPart>>
-
-export const CompactionPart = Schema.Struct({
-  ...partBase,
-  type: Schema.Literal("compaction"),
-  auto: Schema.Boolean,
-  overflow: Schema.optional(Schema.Boolean),
-  tail_start_id: Schema.optional(MessageID),
-}).annotate({ identifier: "CompactionPart" })
-export type CompactionPart = Types.DeepMutable<Schema.Schema.Type<typeof CompactionPart>>
-
-export const SubtaskPart = Schema.Struct({
-  ...partBase,
-  type: Schema.Literal("subtask"),
-  prompt: Schema.String,
-  description: Schema.String,
-  agent: Schema.String,
-  model: Schema.optional(
-    Schema.Struct({
-      providerID: ProviderV2.ID,
-      modelID: ModelV2.ID,
-    }),
-  ),
-  command: Schema.optional(Schema.String),
-}).annotate({ identifier: "SubtaskPart" })
-export type SubtaskPart = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPart>>
-
-export const RetryPart = Schema.Struct({
-  ...partBase,
-  type: Schema.Literal("retry"),
-  attempt: NonNegativeInt,
-  error: APIError.EffectSchema,
-  time: Schema.Struct({
-    created: NonNegativeInt,
-  }),
-}).annotate({ identifier: "RetryPart" })
-export type RetryPart = Omit<Types.DeepMutable<Schema.Schema.Type<typeof RetryPart>>, "error"> & {
-  error: APIError
-}
-
-export const StepStartPart = Schema.Struct({
-  ...partBase,
-  type: Schema.Literal("step-start"),
-  snapshot: Schema.optional(Schema.String),
-}).annotate({ identifier: "StepStartPart" })
-export type StepStartPart = Types.DeepMutable<Schema.Schema.Type<typeof StepStartPart>>
-
-export const StepFinishPart = Schema.Struct({
-  ...partBase,
-  type: Schema.Literal("step-finish"),
-  reason: Schema.String,
-  snapshot: Schema.optional(Schema.String),
-  cost: Schema.Finite,
-  tokens: Schema.Struct({
-    total: Schema.optional(Schema.Finite),
-    input: Schema.Finite,
-    output: Schema.Finite,
-    reasoning: Schema.Finite,
-    cache: Schema.Struct({
-      read: Schema.Finite,
-      write: Schema.Finite,
-    }),
-  }),
-}).annotate({ identifier: "StepFinishPart" })
-export type StepFinishPart = Types.DeepMutable<Schema.Schema.Type<typeof StepFinishPart>>
-
-export const ToolStatePending = Schema.Struct({
-  status: Schema.Literal("pending"),
-  input: Schema.Record(Schema.String, Schema.Any),
-  raw: Schema.String,
-}).annotate({ identifier: "ToolStatePending" })
-export type ToolStatePending = Types.DeepMutable<Schema.Schema.Type<typeof ToolStatePending>>
-
-export const ToolStateRunning = Schema.Struct({
-  status: Schema.Literal("running"),
-  input: Schema.Record(Schema.String, Schema.Any),
-  title: Schema.optional(Schema.String),
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
-  time: Schema.Struct({
-    start: NonNegativeInt,
-  }),
-}).annotate({ identifier: "ToolStateRunning" })
-export type ToolStateRunning = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateRunning>>
-
-export const ToolStateCompleted = Schema.Struct({
-  status: Schema.Literal("completed"),
-  input: Schema.Record(Schema.String, Schema.Any),
-  output: Schema.String,
-  title: Schema.String,
-  metadata: Schema.Record(Schema.String, Schema.Any),
-  time: Schema.Struct({
-    start: NonNegativeInt,
-    end: NonNegativeInt,
-    compacted: Schema.optional(NonNegativeInt),
-  }),
-  attachments: Schema.optional(Schema.Array(FilePart)),
-}).annotate({ identifier: "ToolStateCompleted" })
-export type ToolStateCompleted = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateCompleted>>
-
-export const ToolStateError = Schema.Struct({
-  status: Schema.Literal("error"),
-  input: Schema.Record(Schema.String, Schema.Any),
-  error: Schema.String,
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
-  time: Schema.Struct({
-    start: NonNegativeInt,
-    end: NonNegativeInt,
-  }),
-}).annotate({ identifier: "ToolStateError" })
-export type ToolStateError = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateError>>
-
-export const ToolState = Schema.Union([
-  ToolStatePending,
-  ToolStateRunning,
-  ToolStateCompleted,
-  ToolStateError,
-]).annotate({
-  discriminator: "status",
-  identifier: "ToolState",
-})
-export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
-
-export const ToolPart = Schema.Struct({
-  ...partBase,
-  type: Schema.Literal("tool"),
-  callID: Schema.String,
-  tool: Schema.String,
-  state: ToolState,
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
-}).annotate({ identifier: "ToolPart" })
-export type ToolPart = Omit<Types.DeepMutable<Schema.Schema.Type<typeof ToolPart>>, "state"> & {
-  state: ToolState
-}
-
-const messageBase = {
-  id: MessageID,
-  sessionID: partBase.sessionID,
-}
-
-const FileDiff = Schema.Struct({
-  file: Schema.optional(Schema.String),
-  patch: Schema.optional(Schema.String),
-  additions: Schema.Finite,
-  deletions: Schema.Finite,
-  status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
-}).annotate({ identifier: "SnapshotFileDiff" })
-
-export const User = Schema.Struct({
-  ...messageBase,
-  role: Schema.Literal("user"),
-  time: Schema.Struct({
-    created: Timestamp,
-  }),
-  format: Schema.optional(Format),
-  summary: Schema.optional(
-    Schema.Struct({
-      title: Schema.optional(Schema.String),
-      body: Schema.optional(Schema.String),
-      diffs: Schema.Array(FileDiff),
-    }),
-  ),
-  agent: Schema.String,
-  model: Schema.Struct({
-    providerID: ProviderV2.ID,
-    modelID: ModelV2.ID,
-    variant: Schema.optional(Schema.String),
-  }),
-  system: Schema.optional(Schema.String),
-  tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
-}).annotate({ identifier: "UserMessage" })
-export type User = Types.DeepMutable<Schema.Schema.Type<typeof User>>
-
-export const Part = Schema.Union([
-  TextPart,
-  SubtaskPart,
-  ReasoningPart,
-  FilePart,
-  ToolPart,
-  StepStartPart,
-  StepFinishPart,
-  SnapshotPart,
-  PatchPart,
-  AgentPart,
-  RetryPart,
-  CompactionPart,
-]).annotate({ discriminator: "type", identifier: "Part" })
-export type Part =
-  | TextPart
-  | SubtaskPart
-  | ReasoningPart
-  | FilePart
-  | ToolPart
-  | StepStartPart
-  | StepFinishPart
-  | SnapshotPart
-  | PatchPart
-  | AgentPart
-  | RetryPart
-  | CompactionPart
-
-const AssistantErrorSchema = Schema.Union([
-  AuthError.EffectSchema,
-  NamedError.Unknown.EffectSchema,
-  OutputLengthError.EffectSchema,
-  AbortedError.EffectSchema,
-  StructuredOutputError.EffectSchema,
-  ContextOverflowError.EffectSchema,
-  ContentFilterError.EffectSchema,
-  APIError.EffectSchema,
-]).annotate({ discriminator: "name" })
-type AssistantError = Schema.Schema.Type<typeof AssistantErrorSchema>
-
-export const TextPartInput = Schema.Struct({
-  id: Schema.optional(PartID),
-  type: Schema.Literal("text"),
-  text: Schema.String,
-  synthetic: Schema.optional(Schema.Boolean),
-  ignored: Schema.optional(Schema.Boolean),
-  time: Schema.optional(
-    Schema.Struct({
-      start: NonNegativeInt,
-      end: Schema.optional(NonNegativeInt),
-    }),
-  ),
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
-}).annotate({ identifier: "TextPartInput" })
-export type TextPartInput = Types.DeepMutable<Schema.Schema.Type<typeof TextPartInput>>
-
-export const FilePartInput = Schema.Struct({
-  id: Schema.optional(PartID),
-  type: Schema.Literal("file"),
-  mime: Schema.String,
-  filename: Schema.optional(Schema.String),
-  url: Schema.String,
-  source: Schema.optional(FilePartSource),
-}).annotate({ identifier: "FilePartInput" })
-export type FilePartInput = Types.DeepMutable<Schema.Schema.Type<typeof FilePartInput>>
-
-export const AgentPartInput = Schema.Struct({
-  id: Schema.optional(PartID),
-  type: Schema.Literal("agent"),
-  name: Schema.String,
-  source: Schema.optional(
-    Schema.Struct({
-      value: Schema.String,
-      start: NonNegativeInt,
-      end: NonNegativeInt,
-    }),
-  ),
-}).annotate({ identifier: "AgentPartInput" })
-export type AgentPartInput = Types.DeepMutable<Schema.Schema.Type<typeof AgentPartInput>>
-
-export const SubtaskPartInput = Schema.Struct({
-  id: Schema.optional(PartID),
-  type: Schema.Literal("subtask"),
-  prompt: Schema.String,
-  description: Schema.String,
-  agent: Schema.String,
-  model: Schema.optional(
-    Schema.Struct({
-      providerID: ProviderV2.ID,
-      modelID: ModelV2.ID,
-    }),
-  ),
-  command: Schema.optional(Schema.String),
-}).annotate({ identifier: "SubtaskPartInput" })
-export type SubtaskPartInput = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPartInput>>
-
-export const Assistant = Schema.Struct({
-  ...messageBase,
-  role: Schema.Literal("assistant"),
-  time: Schema.Struct({
-    created: NonNegativeInt,
-    completed: Schema.optional(NonNegativeInt),
-  }),
-  error: Schema.optional(AssistantErrorSchema),
-  parentID: MessageID,
-  modelID: ModelV2.ID,
-  providerID: ProviderV2.ID,
-  mode: Schema.String,
-  agent: Schema.String,
-  path: Schema.Struct({
-    cwd: Schema.String,
-    root: Schema.String,
-  }),
-  summary: Schema.optional(Schema.Boolean),
-  cost: Schema.Finite,
-  tokens: Schema.Struct({
-    total: Schema.optional(Schema.Finite),
-    input: Schema.Finite,
-    output: Schema.Finite,
-    reasoning: Schema.Finite,
-    cache: Schema.Struct({
-      read: Schema.Finite,
-      write: Schema.Finite,
-    }),
-  }),
-  structured: Schema.optional(Schema.Any),
-  variant: Schema.optional(Schema.String),
-  finish: Schema.optional(Schema.String),
-}).annotate({ identifier: "AssistantMessage" })
-export type Assistant = Omit<Types.DeepMutable<Schema.Schema.Type<typeof Assistant>>, "error"> & {
-  error?: AssistantError
-}
-
-export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" })
-export type Info = User | Assistant
-
-export const WithParts = Schema.Struct({
-  info: Info,
-  parts: Schema.Array(Part),
-})
-export type WithParts = {
-  info: Info
-  parts: Part[]
-}
-
-const options = {
-  durable: {
-    aggregate: "sessionID",
-    version: 1,
-  },
-} as const
-
-const SessionSummary = Schema.Struct({
-  additions: Schema.Finite,
-  deletions: Schema.Finite,
-  files: Schema.Finite,
-  diffs: optionalOmitUndefined(Schema.Array(FileDiff)),
-})
-
-const SessionTokens = Schema.Struct({
-  input: Schema.Finite,
-  output: Schema.Finite,
-  reasoning: Schema.Finite,
-  cache: Schema.Struct({
-    read: Schema.Finite,
-    write: Schema.Finite,
-  }),
-})
-
-const SessionShare = Schema.Struct({
-  url: Schema.String,
-})
-
-const SessionRevert = Schema.Struct({
-  messageID: MessageID,
-  partID: optionalOmitUndefined(PartID),
-  snapshot: optionalOmitUndefined(Schema.String),
-  diff: optionalOmitUndefined(Schema.String),
-})
-
-const SessionModel = Schema.Struct({
-  id: ModelV2.ID,
-  providerID: ProviderV2.ID,
-  variant: optionalOmitUndefined(Schema.String),
-})
-
-export const SessionInfo = Schema.Struct({
-  id: SessionSchema.ID,
-  slug: Schema.String,
-  projectID: ProjectV2.ID,
-  workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
-  directory: Schema.String,
-  path: optionalOmitUndefined(Schema.String),
-  parentID: optionalOmitUndefined(SessionSchema.ID),
-  summary: optionalOmitUndefined(SessionSummary),
-  cost: optionalOmitUndefined(Schema.Finite),
-  tokens: optionalOmitUndefined(SessionTokens),
-  share: optionalOmitUndefined(SessionShare),
-  title: Schema.String,
-  agent: optionalOmitUndefined(Schema.String),
-  model: optionalOmitUndefined(SessionModel),
-  version: Schema.String,
-  metadata: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Any)),
-  time: Schema.Struct({
-    created: NonNegativeInt,
-    updated: NonNegativeInt,
-    compacting: optionalOmitUndefined(NonNegativeInt),
-    archived: optionalOmitUndefined(Schema.Finite),
-  }),
-  permission: optionalOmitUndefined(PermissionV1.Ruleset),
-  revert: optionalOmitUndefined(SessionRevert),
-}).annotate({ identifier: "Session" })
-export type SessionInfo = typeof SessionInfo.Type
-
-export const Event = {
-  Created: EventV2.define({
-    type: "session.created",
-    ...options,
-    schema: {
-      sessionID: SessionSchema.ID,
-      info: SessionInfo,
-    },
-  }),
-  Updated: EventV2.define({
-    type: "session.updated",
-    ...options,
-    schema: {
-      sessionID: SessionSchema.ID,
-      info: SessionInfo,
-    },
-  }),
-  Deleted: EventV2.define({
-    type: "session.deleted",
-    ...options,
-    schema: {
-      sessionID: SessionSchema.ID,
-      info: SessionInfo,
-    },
-  }),
-  MessageUpdated: EventV2.define({
-    type: "message.updated",
-    ...options,
-    schema: {
-      sessionID: SessionSchema.ID,
-      info: Info,
-    },
-  }),
-  MessageRemoved: EventV2.define({
-    type: "message.removed",
-    ...options,
-    schema: {
-      sessionID: SessionSchema.ID,
-      messageID: MessageID,
-    },
-  }),
-  PartUpdated: EventV2.define({
-    type: "message.part.updated",
-    ...options,
-    schema: {
-      sessionID: SessionSchema.ID,
-      part: Part,
-      time: Schema.Finite,
-    },
-  }),
-  PartRemoved: EventV2.define({
-    type: "message.part.removed",
-    ...options,
-    schema: {
-      sessionID: SessionSchema.ID,
-      messageID: MessageID,
-      partID: PartID,
-    },
-  }),
-}
+export const ContentFilterError = NamedError.create("ContentFilterError", { message: Schema.String })

+ 126 - 138
packages/core/test/event.test.ts

@@ -1,10 +1,14 @@
 import { describe, expect } from "bun:test"
 import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
 import { EventV2 } from "@opencode-ai/core/event"
+import { Event } from "@opencode-ai/schema/event"
+import { Session } from "@opencode-ai/schema/session"
+import { SessionEvent } from "@opencode-ai/schema/session-event"
+import { SessionV1 } from "@opencode-ai/schema/session-v1"
 import { Database } from "@opencode-ai/core/database/database"
 import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
 import { Location } from "@opencode-ai/core/location"
-import { AbsolutePath, DateTimeUtcFromMillis } from "@opencode-ai/core/schema"
+import { AbsolutePath } from "@opencode-ai/core/schema"
 import { WorkspaceV2 } from "@opencode-ai/core/workspace"
 import { eq } from "drizzle-orm"
 import { location } from "./fixture/location"
@@ -16,10 +20,6 @@ const locationLayer = Layer.succeed(
     location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") }),
   ),
 )
-const eventLayer = Layer.mergeAll(EventV2.defaultLayer, Database.defaultLayer)
-const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
-const itWithoutLocation = testEffect(eventLayer)
-
 const Message = EventV2.define({
   type: "test.message",
   schema: {
@@ -70,18 +70,16 @@ const VersionedMessage = EventV2.define({
   },
 })
 
-const SyncTimestamp = EventV2.define({
-  type: "test.timestamp",
-  durable: {
-    version: 1,
-    aggregate: "id",
-  },
-  schema: {
-    id: Schema.String,
-    timestamp: DateTimeUtcFromMillis,
-  },
+const DurableMessage = SessionV1.Event.MessageRemoved
+const durableData = (sessionID: Session.ID, text: string) => ({
+  sessionID,
+  messageID: SessionV1.MessageID.ascending(`msg_${text}`),
 })
 
+const eventLayer = Layer.mergeAll(EventV2.layerWith().pipe(Layer.provide(Database.defaultLayer)), Database.defaultLayer)
+const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
+const itWithoutLocation = testEffect(eventLayer)
+
 describe("EventV2", () => {
   it.effect("publishes events with the current location", () =>
     Effect.gen(function* () {
@@ -122,26 +120,21 @@ describe("EventV2", () => {
     }),
   )
 
-  it.effect("stores definitions in the exported registry", () =>
-    Effect.sync(() => {
-      expect(EventV2.registry.get(Message.type)).toBe(Message)
-    }),
-  )
-
-  it.effect("keeps the latest sync definition in the registry", () =>
+  it.effect("selects the latest durable definition independent of declaration order", () =>
     Effect.sync(() => {
       const latest = EventV2.define({
         type: "test.out-of-order",
         durable: { version: 2, aggregate: "id" },
         schema: { id: Schema.String },
       })
-      EventV2.define({
+      const historical = EventV2.define({
         type: "test.out-of-order",
         durable: { version: 1, aggregate: "id" },
         schema: { id: Schema.String },
       })
 
-      expect(EventV2.registry.get("test.out-of-order")).toBe(latest)
+      expect(Event.latest([latest, historical]).get("test.out-of-order")).toBe(latest)
+      expect(Event.latest([historical, latest]).get("test.out-of-order")).toBe(latest)
     }),
   )
 
@@ -363,19 +356,19 @@ describe("EventV2", () => {
   it.effect("replays durable aggregate events after a sequence and tails new events", () =>
     Effect.gen(function* () {
       const events = yield* EventV2.Service
-      const aggregateID = EventV2.ID.create()
-      yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
-      yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
+      const aggregateID = Session.ID.create()
+      yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
+      yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
       const fiber = yield* events
         .durable({ aggregateID, after: 0 })
         .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
       yield* Effect.yieldNow
 
-      yield* events.publish(SyncMessage, { id: aggregateID, text: "two" })
+      yield* events.publish(DurableMessage, durableData(aggregateID, "two"))
 
       expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
-        [1, { id: aggregateID, text: "one" }],
-        [2, { id: aggregateID, text: "two" }],
+        [1, durableData(aggregateID, "one")],
+        [2, durableData(aggregateID, "two")],
       ])
     }),
   )
@@ -383,20 +376,15 @@ describe("EventV2", () => {
   it.effect("catches durable aggregate events published during replay handoff", () =>
     Effect.gen(function* () {
       const events = yield* EventV2.Service
-      const aggregateID = EventV2.ID.create()
-      yield* events.publish(SyncMessage, { id: aggregateID, text: "zero" })
+      const aggregateID = Session.ID.create()
+      yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
       const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
 
-      yield* events.publish(SyncMessage, { id: aggregateID, text: "one" })
+      yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
 
-      expect(
-        Array.from(yield* Fiber.join(fiber)).map((event) => [
-          event.durable?.seq,
-          (event.data as { text: string }).text,
-        ]),
-      ).toEqual([
-        [0, "zero"],
-        [1, "one"],
+      expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
+        [0, durableData(aggregateID, "zero")],
+        [1, durableData(aggregateID, "one")],
       ])
     }),
   )
@@ -415,16 +403,16 @@ describe("EventV2", () => {
 
       yield* Effect.gen(function* () {
         const events = yield* EventV2.Service
-        const aggregateID = EventV2.ID.create()
+        const aggregateID = Session.ID.create()
         const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
         yield* Deferred.await(readStarted)
 
         pause = false
-        yield* events.publish(SyncMessage, { id: aggregateID, text: "during handoff" })
+        yield* events.publish(DurableMessage, durableData(aggregateID, "during handoff"))
         yield* Deferred.succeed(continueRead, undefined)
 
         expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
-          [0, { id: aggregateID, text: "during handoff" }],
+          [0, durableData(aggregateID, "during handoff")],
         ])
       }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
     }),
@@ -433,7 +421,7 @@ describe("EventV2", () => {
   it.effect("coalesces durable aggregate wakes while draining every committed event", () =>
     Effect.gen(function* () {
       const events = yield* EventV2.Service
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
       const count = 64
       const fiber = yield* events
         .durable({ aggregateID })
@@ -441,11 +429,11 @@ describe("EventV2", () => {
       yield* Effect.yieldNow
 
       for (let index = 0; index < count; index++) {
-        yield* events.publish(SyncMessage, { id: aggregateID, text: String(index) })
+        yield* events.publish(DurableMessage, durableData(aggregateID, String(index)))
       }
 
       expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
-        Array.from({ length: count }, (_, index) => [index, { id: aggregateID, text: String(index) }]),
+        Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]),
       )
     }),
   )
@@ -453,14 +441,14 @@ describe("EventV2", () => {
   it.effect("omits live-only events from durable aggregate streams", () =>
     Effect.gen(function* () {
       const events = yield* EventV2.Service
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
       const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
       yield* Effect.yieldNow
 
       yield* events.publish(Message, { text: "live only" })
-      yield* events.publish(SyncMessage, { id: aggregateID, text: "durable" })
+      yield* events.publish(DurableMessage, durableData(aggregateID, "durable"))
 
-      expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([SyncMessage.type])
+      expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
     }),
   )
 
@@ -487,23 +475,23 @@ describe("EventV2", () => {
     Effect.gen(function* () {
       const events = yield* EventV2.Service
       const received = new Array<EventV2.Payload>()
-      yield* events.project(SyncMessage, (event) =>
+      yield* events.project(DurableMessage, (event) =>
         Effect.sync(() => {
           received.push(event)
         }),
       )
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
 
       yield* events.replay({
         id: EventV2.ID.create(),
-        type: EventV2.versionedType(SyncMessage.type, 1),
+        type: EventV2.versionedType(DurableMessage.type, 1),
         seq: 0,
         aggregateID,
-        data: { id: aggregateID, text: "hello" },
+        data: durableData(aggregateID, "hello"),
       })
 
-      expect(received[0]?.type).toBe(SyncMessage.type)
-      expect(received[0]?.data).toEqual({ id: aggregateID, text: "hello" })
+      expect(received[0]?.type).toBe(DurableMessage.type)
+      expect(received[0]?.data).toEqual(durableData(aggregateID, "hello"))
     }),
   )
 
@@ -511,14 +499,14 @@ describe("EventV2", () => {
     Effect.gen(function* () {
       const events = yield* EventV2.Service
       const { db } = yield* Database.Service
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
 
       yield* events.replay({
         id: EventV2.ID.create(),
-        type: EventV2.versionedType(SyncMessage.type, 1),
+        type: EventV2.versionedType(DurableMessage.type, 1),
         seq: 0,
         aggregateID,
-        data: { id: aggregateID, text: "replayed" },
+        data: durableData(aggregateID, "replayed"),
       })
       const rows = yield* db
         .select()
@@ -538,11 +526,11 @@ describe("EventV2", () => {
       Effect.gen(function* () {
         const events = yield* EventV2.Service
         const { db } = yield* Database.Service
-        const envelopeAggregateID = EventV2.ID.create()
-        const payloadAggregateID = EventV2.ID.create()
+        const envelopeAggregateID = Session.ID.create()
+        const payloadAggregateID = Session.ID.create()
         const received = new Array<EventV2.Payload>()
-        yield* events.publish(SyncMessage, { id: payloadAggregateID, text: "seed" })
-        yield* events.project(SyncMessage, (event) =>
+        yield* events.publish(DurableMessage, durableData(payloadAggregateID, "seed"))
+        yield* events.project(DurableMessage, (event) =>
           Effect.sync(() => {
             received.push(event)
           }),
@@ -551,10 +539,10 @@ describe("EventV2", () => {
         const exit = yield* events
           .replay({
             id: EventV2.ID.create(),
-            type: EventV2.versionedType(SyncMessage.type, 1),
+            type: EventV2.versionedType(DurableMessage.type, 1),
             seq: 1,
             aggregateID: envelopeAggregateID,
-            data: { id: payloadAggregateID, text: "replayed" },
+            data: durableData(payloadAggregateID, "replayed"),
           })
           .pipe(Effect.exit)
         const rows = yield* db
@@ -580,22 +568,22 @@ describe("EventV2", () => {
   it.effect("replay defects on sequence mismatch", () =>
     Effect.gen(function* () {
       const events = yield* EventV2.Service
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
 
       yield* events.replay({
         id: EventV2.ID.create(),
-        type: EventV2.versionedType(SyncMessage.type, 1),
+        type: EventV2.versionedType(DurableMessage.type, 1),
         seq: 0,
         aggregateID,
-        data: { id: aggregateID, text: "first" },
+        data: durableData(aggregateID, "first"),
       })
       const exit = yield* events
         .replay({
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 5,
           aggregateID,
-          data: { id: aggregateID, text: "bad" },
+          data: durableData(aggregateID, "bad"),
         })
         .pipe(Effect.exit)
 
@@ -606,9 +594,9 @@ describe("EventV2", () => {
   it.effect("replay decodes synchronized transformed values before projection", () =>
     Effect.gen(function* () {
       const events = yield* EventV2.Service
-      const aggregateID = EventV2.ID.create()
-      const received = new Array<typeof SyncTimestamp.Type>()
-      yield* events.project(SyncTimestamp, (event) =>
+      const aggregateID = Session.ID.create()
+      const received = new Array<typeof SessionEvent.ContextUpdated.Type>()
+      yield* events.project(SessionEvent.ContextUpdated, (event) =>
         Effect.sync(() => {
           received.push(event)
         }),
@@ -616,10 +604,10 @@ describe("EventV2", () => {
 
       yield* events.replay({
         id: EventV2.ID.create(),
-        type: EventV2.versionedType(SyncTimestamp.type, 1),
+        type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1),
         seq: 0,
         aggregateID,
-        data: { id: aggregateID, timestamp: 0 },
+        data: { sessionID: aggregateID, messageID: "msg_context", timestamp: 0, text: "context" },
       })
 
       expect(received[0]?.data.timestamp).toEqual(DateTime.makeUnsafe(0))
@@ -646,21 +634,21 @@ describe("EventV2", () => {
   it.effect("replayAll validates contiguous aggregate events", () =>
     Effect.gen(function* () {
       const events = yield* EventV2.Service
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
       const source = yield* events.replayAll([
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 0,
           aggregateID,
-          data: { id: aggregateID, text: "one" },
+          data: durableData(aggregateID, "one"),
         },
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 1,
           aggregateID,
-          data: { id: aggregateID, text: "two" },
+          data: durableData(aggregateID, "two"),
         },
       ])
 
@@ -672,38 +660,38 @@ describe("EventV2", () => {
     Effect.gen(function* () {
       const events = yield* EventV2.Service
       const { db } = yield* Database.Service
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
 
       const one = yield* events.replayAll([
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 0,
           aggregateID,
-          data: { id: aggregateID, text: "one" },
+          data: durableData(aggregateID, "one"),
         },
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 1,
           aggregateID,
-          data: { id: aggregateID, text: "two" },
+          data: durableData(aggregateID, "two"),
         },
       ])
       const two = yield* events.replayAll([
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 2,
           aggregateID,
-          data: { id: aggregateID, text: "three" },
+          data: durableData(aggregateID, "three"),
         },
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 3,
           aggregateID,
-          data: { id: aggregateID, text: "four" },
+          data: durableData(aggregateID, "four"),
         },
       ])
       const rows = yield* db
@@ -723,10 +711,10 @@ describe("EventV2", () => {
     Effect.gen(function* () {
       const events = yield* EventV2.Service
       const received = new Array<EventV2.Payload>()
-      const aggregateID = EventV2.ID.create()
-      yield* events.publish(SyncMessage, { id: aggregateID, text: "seed" })
+      const aggregateID = Session.ID.create()
+      yield* events.publish(DurableMessage, durableData(aggregateID, "seed"))
       yield* events.claim(aggregateID, "owner-a")
-      yield* events.project(SyncMessage, (event) =>
+      yield* events.project(DurableMessage, (event) =>
         Effect.sync(() => {
           received.push(event)
         }),
@@ -735,10 +723,10 @@ describe("EventV2", () => {
       yield* events.replay(
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 1,
           aggregateID,
-          data: { id: aggregateID, text: "ignored" },
+          data: durableData(aggregateID, "ignored"),
         },
         { ownerID: "owner-b" },
       )
@@ -750,14 +738,14 @@ describe("EventV2", () => {
   it.effect("strict owner fences exact replay", () =>
     Effect.gen(function* () {
       const events = yield* EventV2.Service
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
       const id = EventV2.ID.create()
       const replayed = {
         id,
-        type: EventV2.versionedType(SyncMessage.type, 1),
+        type: EventV2.versionedType(DurableMessage.type, 1),
         seq: 0,
         aggregateID,
-        data: { id: aggregateID, text: "owned" },
+        data: durableData(aggregateID, "owned"),
       }
       yield* events.replay(replayed, { ownerID: "owner-a" })
 
@@ -771,11 +759,11 @@ describe("EventV2", () => {
     Effect.gen(function* () {
       const events = yield* EventV2.Service
       const { db } = yield* Database.Service
-      const aggregateID = EventV2.ID.create()
-      const published = yield* events.publish(SyncMessage, { id: aggregateID, text: "owned" })
+      const aggregateID = Session.ID.create()
+      const published = yield* events.publish(DurableMessage, durableData(aggregateID, "owned"))
       const replayed = {
         id: published.id,
-        type: EventV2.versionedType(SyncMessage.type, 1),
+        type: EventV2.versionedType(DurableMessage.type, 1),
         seq: published.durable!.seq,
         aggregateID,
         data: published.data,
@@ -792,7 +780,7 @@ describe("EventV2", () => {
       expect(row?.ownerID).toBe("owner-a")
       const exit = yield* events
         .replay(
-          { ...replayed, id: EventV2.ID.create(), seq: 1, data: { id: aggregateID, text: "conflict" } },
+          { ...replayed, id: EventV2.ID.create(), seq: 1, data: durableData(aggregateID, "conflict") },
           { ownerID: "owner-b", strictOwner: true },
         )
         .pipe(Effect.exit)
@@ -804,15 +792,15 @@ describe("EventV2", () => {
     Effect.gen(function* () {
       const events = yield* EventV2.Service
       const { db } = yield* Database.Service
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
 
       yield* events.replay(
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 0,
           aggregateID,
-          data: { id: aggregateID, text: "owned" },
+          data: durableData(aggregateID, "owned"),
         },
         { ownerID: "owner-1" },
       )
@@ -831,26 +819,26 @@ describe("EventV2", () => {
     Effect.gen(function* () {
       const events = yield* EventV2.Service
       const { db } = yield* Database.Service
-      const aggregateID = EventV2.ID.create()
-      yield* events.publish(SyncMessage, { id: aggregateID, text: "local" })
+      const aggregateID = Session.ID.create()
+      yield* events.publish(DurableMessage, durableData(aggregateID, "local"))
 
       yield* events.replay(
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 1,
           aggregateID,
-          data: { id: aggregateID, text: "claimed" },
+          data: durableData(aggregateID, "claimed"),
         },
         { ownerID: "owner-1" },
       )
       yield* events.replay(
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 2,
           aggregateID,
-          data: { id: aggregateID, text: "fenced" },
+          data: durableData(aggregateID, "fenced"),
         },
         { ownerID: "owner-2" },
       )
@@ -875,14 +863,14 @@ describe("EventV2", () => {
   it.effect("strict replay rejects an owner conflict instead of silently skipping it", () =>
     Effect.gen(function* () {
       const events = yield* EventV2.Service
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
       yield* events.replay(
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 0,
           aggregateID,
-          data: { id: aggregateID, text: "claimed" },
+          data: durableData(aggregateID, "claimed"),
         },
         { ownerID: "owner-1" },
       )
@@ -891,10 +879,10 @@ describe("EventV2", () => {
         .replay(
           {
             id: EventV2.ID.create(),
-            type: EventV2.versionedType(SyncMessage.type, 1),
+            type: EventV2.versionedType(DurableMessage.type, 1),
             seq: 1,
             aggregateID,
-            data: { id: aggregateID, text: "conflict" },
+            data: durableData(aggregateID, "conflict"),
           },
           { ownerID: "owner-2", strictOwner: true },
         )
@@ -908,14 +896,14 @@ describe("EventV2", () => {
     Effect.gen(function* () {
       const events = yield* EventV2.Service
       const received = new Array<EventV2.Payload>()
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
       yield* events.listen((event) => Effect.sync(() => received.push(event)))
       const replayed = {
         id: EventV2.ID.create(),
-        type: EventV2.versionedType(SyncMessage.type, 1),
+        type: EventV2.versionedType(DurableMessage.type, 1),
         seq: 0,
         aggregateID,
-        data: { id: aggregateID, text: "replayed" },
+        data: durableData(aggregateID, "replayed"),
       }
 
       yield* events.replay(replayed, { publish: true })
@@ -929,19 +917,19 @@ describe("EventV2", () => {
     Effect.gen(function* () {
       const events = yield* EventV2.Service
       const received = new Array<EventV2.Payload>()
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
       const replayed = {
         id: EventV2.ID.create(),
-        type: EventV2.versionedType(SyncMessage.type, 1),
+        type: EventV2.versionedType(DurableMessage.type, 1),
         seq: 0,
         aggregateID,
-        data: { id: aggregateID, text: "original" },
+        data: durableData(aggregateID, "original"),
       }
       yield* events.listen((event) => Effect.sync(() => received.push(event)))
       yield* events.replay(replayed, { publish: true })
 
       const exit = yield* events
-        .replay({ ...replayed, data: { id: aggregateID, text: "divergent" } }, { publish: true })
+        .replay({ ...replayed, data: durableData(aggregateID, "divergent") }, { publish: true })
         .pipe(Effect.exit)
 
       expect(String(exit)).toContain("Replay diverged")
@@ -952,23 +940,23 @@ describe("EventV2", () => {
   it.effect("rejects an event ID reused at another aggregate position", () =>
     Effect.gen(function* () {
       const events = yield* EventV2.Service
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
       const id = EventV2.ID.create()
       yield* events.replay({
         id,
-        type: EventV2.versionedType(SyncMessage.type, 1),
+        type: EventV2.versionedType(DurableMessage.type, 1),
         seq: 0,
         aggregateID,
-        data: { id: aggregateID, text: "first" },
+        data: durableData(aggregateID, "first"),
       })
 
       const exit = yield* events
         .replay({
           id,
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 1,
           aggregateID,
-          data: { id: aggregateID, text: "second" },
+          data: durableData(aggregateID, "second"),
         })
         .pipe(Effect.exit)
 
@@ -980,27 +968,27 @@ describe("EventV2", () => {
     Effect.gen(function* () {
       const events = yield* EventV2.Service
       const { db } = yield* Database.Service
-      const aggregateID = EventV2.ID.create()
+      const aggregateID = Session.ID.create()
       const received = new Array<EventV2.Payload>()
       yield* events.listen((event) => Effect.sync(() => received.push(event)))
 
       yield* events.replay(
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 0,
           aggregateID,
-          data: { id: aggregateID, text: "first" },
+          data: durableData(aggregateID, "first"),
         },
         { ownerID: "owner-1" },
       )
       yield* events.replay(
         {
           id: EventV2.ID.create(),
-          type: EventV2.versionedType(SyncMessage.type, 1),
+          type: EventV2.versionedType(DurableMessage.type, 1),
           seq: 1,
           aggregateID,
-          data: { id: aggregateID, text: "ignored" },
+          data: durableData(aggregateID, "ignored"),
         },
         { ownerID: "owner-2", publish: true },
       )
@@ -1047,10 +1035,10 @@ describe("EventV2", () => {
     Effect.gen(function* () {
       const events = yield* EventV2.Service
       const received = new Array<EventV2.Payload>()
-      const aggregateID = EventV2.ID.create()
-      yield* events.publish(SyncMessage, { id: aggregateID, text: "seed" })
+      const aggregateID = Session.ID.create()
+      yield* events.publish(DurableMessage, durableData(aggregateID, "seed"))
       yield* events.remove(aggregateID)
-      yield* events.project(SyncMessage, (event) =>
+      yield* events.project(DurableMessage, (event) =>
         Effect.sync(() => {
           received.push(event)
         }),
@@ -1058,13 +1046,13 @@ describe("EventV2", () => {
 
       yield* events.replay({
         id: EventV2.ID.create(),
-        type: EventV2.versionedType(SyncMessage.type, 1),
+        type: EventV2.versionedType(DurableMessage.type, 1),
         seq: 0,
         aggregateID,
-        data: { id: aggregateID, text: "replayed" },
+        data: durableData(aggregateID, "replayed"),
       })
 
-      expect(received[0]?.data).toEqual({ id: aggregateID, text: "replayed" })
+      expect(received[0]?.data).toEqual(durableData(aggregateID, "replayed"))
     }),
   )
 })

+ 16 - 0
packages/core/test/legacy-event-schema.test.ts

@@ -0,0 +1,16 @@
+import { describe, expect, test } from "bun:test"
+import { SessionV1 as Wire } from "@opencode-ai/schema/session-v1"
+import { SessionV1 } from "../src/v1/session"
+
+describe("legacy event schema compatibility", () => {
+  test("Core references canonical SessionV1 definitions", () => {
+    expect(SessionV1.Event.Created).toBe(Wire.Event.Created)
+    expect(SessionV1.Event.PartUpdated).toBe(Wire.Event.PartUpdated)
+  })
+
+  test("Core retains NamedError constructor identity", () => {
+    const error = new SessionV1.APIError({ message: "failed", isRetryable: false })
+    expect(error).toBeInstanceOf(SessionV1.APIError)
+    expect(error.toObject()).toEqual({ name: "APIError", data: { message: "failed", isRetryable: false } })
+  })
+})

+ 1 - 0
packages/opencode/package.json

@@ -86,6 +86,7 @@
     "@openauthjs/openauth": "catalog:",
     "@opencode-ai/llm": "workspace:*",
     "@opencode-ai/plugin": "workspace:*",
+    "@opencode-ai/schema": "workspace:*",
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/server": "workspace:*",

+ 2 - 11
packages/opencode/src/command/index.ts

@@ -2,29 +2,20 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { InstanceState } from "@/effect/instance-state"
 import { EffectBridge } from "@/effect/bridge"
 import type { InstanceContext } from "@/project/instance-context"
-import { SessionID, MessageID } from "@/session/schema"
 import { Effect, Layer, Context, Schema } from "effect"
 import { Config } from "@/config/config"
 import { MCP } from "../mcp"
 import { Skill } from "../skill"
-import { EventV2 } from "@opencode-ai/core/event"
 import PROMPT_INITIALIZE from "./template/initialize.txt"
 import PROMPT_REVIEW from "./template/review.txt"
+import { LegacyEvent } from "@opencode-ai/schema/legacy-event"
 
 type State = {
   commands: Record<string, Info>
 }
 
 export const Event = {
-  Executed: EventV2.define({
-    type: "command.executed",
-    schema: {
-      name: Schema.String,
-      sessionID: SessionID,
-      arguments: Schema.String,
-      messageID: MessageID,
-    },
-  }),
+  Executed: LegacyEvent.CommandExecuted,
 }
 
 export const Info = Schema.Struct({

+ 5 - 21
packages/opencode/src/control-plane/workspace.ts

@@ -33,6 +33,7 @@ import { Vcs } from "@/project/vcs"
 import { InstanceStore } from "@/project/instance-store"
 import { InstanceBootstrap } from "@/project/bootstrap"
 import { WorkspaceAdapterRuntime } from "./workspace-adapter-runtime"
+import { WorkspaceEvent } from "@opencode-ai/schema/workspace-event"
 
 export const Info = Schema.Struct({
   ...WorkspaceInfoSchema.fields,
@@ -40,27 +41,10 @@ export const Info = Schema.Struct({
 }).annotate({ identifier: "Workspace" })
 export type Info = WorkspaceInfo & { timeUsed: number }
 
-export const ConnectionStatus = Schema.Struct({
-  workspaceID: WorkspaceV2.ID,
-  status: Schema.Literals(["connected", "connecting", "disconnected", "error"]),
-})
-export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
-
-export const Event = {
-  Ready: EventV2.define({
-    type: "workspace.ready",
-    schema: {
-      name: Schema.String,
-    },
-  }),
-  Failed: EventV2.define({
-    type: "workspace.failed",
-    schema: {
-      message: Schema.String,
-    },
-  }),
-  Status: EventV2.define({ type: "workspace.status", schema: ConnectionStatus.fields }),
-}
+export const ConnectionStatus = WorkspaceEvent.ConnectionStatus
+export type ConnectionStatus = WorkspaceEvent.ConnectionStatus
+
+export const Event = WorkspaceEvent
 
 function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
   return {

+ 3 - 0
packages/opencode/src/event-manifest.ts

@@ -0,0 +1,3 @@
+export * as EventManifest from "./event-manifest"
+
+export { Definitions, Durable, Latest } from "@opencode-ai/schema/event-manifest"

+ 2 - 8
packages/opencode/src/event-v2-bridge.ts

@@ -7,9 +7,6 @@ import { EventV2 } from "@opencode-ai/core/event"
 import { Location } from "@opencode-ai/core/location"
 import { Project } from "@opencode-ai/core/project"
 import { AbsolutePath } from "@opencode-ai/core/schema"
-import "@opencode-ai/core/account"
-import "@opencode-ai/core/catalog"
-import "@opencode-ai/core/session/event"
 import { Context, Effect, Layer } from "effect"
 
 export class Service extends Context.Service<Service, EventV2.Interface>()("@opencode/EventV2Bridge") {}
@@ -45,10 +42,7 @@ export const layer = Layer.effect(
           workspace: workspaceID,
           payload: { id: event.id, type: event.type, properties: event.data },
         })
-        const durable = EventV2.registry.get(event.type)?.durable
-        if (durable === undefined || event.durable === undefined) return
-        const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
-        if (typeof aggregateID !== "string") return
+        if (event.durable === undefined) return
         GlobalBus.emit("event", {
           directory: event.location?.directory ?? ctx?.directory,
           project: ctx?.project.id,
@@ -59,7 +53,7 @@ export const layer = Layer.effect(
               id: event.id,
               type: EventV2.versionedType(event.type, event.durable.version),
               seq: event.durable.seq,
-              aggregateID,
+              aggregateID: event.durable.aggregateID,
               data: event.data,
             },
           },

+ 2 - 9
packages/opencode/src/ide/index.ts

@@ -1,7 +1,7 @@
-import { EventV2 } from "@opencode-ai/core/event"
 import { Schema } from "effect"
 import { NamedError } from "@opencode-ai/core/util/error"
 import { Process } from "@/util/process"
+import { IdeEvent } from "@opencode-ai/schema/ide-event"
 
 const SUPPORTED_IDES = [
   { name: "Windsurf" as const, cmd: "windsurf" },
@@ -11,14 +11,7 @@ const SUPPORTED_IDES = [
   { name: "VSCodium" as const, cmd: "codium" },
 ]
 
-export const Event = {
-  Installed: EventV2.define({
-    type: "ide.installed",
-    schema: {
-      ide: Schema.String,
-    },
-  }),
-}
+export const Event = IdeEvent
 
 export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {})
 

+ 2 - 15
packages/opencode/src/installation/index.ts

@@ -8,30 +8,17 @@ import { errorMessage } from "@/util/error"
 import { ChildProcess } from "effect/unstable/process"
 import { AppProcess } from "@opencode-ai/core/process"
 import path from "path"
-import { EventV2 } from "@opencode-ai/core/event"
 import { makeRuntime } from "@opencode-ai/core/effect/runtime"
 import semver from "semver"
 import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
 import { NpmConfig } from "@opencode-ai/core/npm-config"
+import { InstallationEvent } from "@opencode-ai/schema/installation-event"
 
 export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
 
 export type ReleaseType = "patch" | "minor" | "major"
 
-export const Event = {
-  Updated: EventV2.define({
-    type: "installation.updated",
-    schema: {
-      version: Schema.String,
-    },
-  }),
-  UpdateAvailable: EventV2.define({
-    type: "installation.update-available",
-    schema: {
-      version: Schema.String,
-    },
-  }),
-}
+export const Event = InstallationEvent
 
 export function getReleaseType(current: string, latest: string): ReleaseType {
   const currMajor = semver.major(current)

+ 2 - 4
packages/opencode/src/lsp/lsp.ts

@@ -1,7 +1,6 @@
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { EventV2Bridge } from "@/event-v2-bridge"
-import { EventV2 } from "@opencode-ai/core/event"
 import * as LSPClient from "./client"
 import path from "path"
 import { pathToFileURL, fileURLToPath } from "url"
@@ -14,10 +13,9 @@ import { InstanceState } from "@/effect/instance-state"
 import { containsPath } from "@/project/instance-context"
 import { NonNegativeInt } from "@opencode-ai/core/schema"
 import { RuntimeFlags } from "@/effect/runtime-flags"
+import { LspEvent } from "@opencode-ai/schema/lsp-event"
 
-export const Event = {
-  Updated: EventV2.define({ type: "lsp.updated", schema: {} }),
-}
+export const Event = LspEvent
 
 const Position = Schema.Struct({
   line: NonNegativeInt,

+ 3 - 14
packages/opencode/src/mcp/index.ts

@@ -26,7 +26,6 @@ import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
 import { McpOAuthCallback } from "./oauth-callback"
 import { McpAuth } from "./auth"
 import { EventV2Bridge } from "@/event-v2-bridge"
-import { EventV2 } from "@opencode-ai/core/event"
 import { TuiEvent } from "@/server/tui-event"
 import open from "open"
 import { Cause, Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
@@ -35,6 +34,7 @@ import { InstanceState } from "@/effect/instance-state"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { McpCatalog } from "./catalog"
+import { McpEvent } from "@opencode-ai/schema/mcp-event"
 
 const DEFAULT_TIMEOUT = 30_000
 const CLIENT_OPTIONS = {
@@ -59,20 +59,9 @@ export const Resource = Schema.Struct({
 }).annotate({ identifier: "McpResource" })
 export type Resource = Schema.Schema.Type<typeof Resource>
 
-export const ToolsChanged = EventV2.define({
-  type: "mcp.tools.changed",
-  schema: {
-    server: Schema.String,
-  },
-})
+export const ToolsChanged = McpEvent.ToolsChanged
 
-export const BrowserOpenFailed = EventV2.define({
-  type: "mcp.browser.open.failed",
-  schema: {
-    mcpName: Schema.String,
-    url: Schema.String,
-  },
-})
+export const BrowserOpenFailed = McpEvent.BrowserOpenFailed
 
 export const Failed = NamedError.create("MCPFailed", {
   name: Schema.String,

+ 3 - 13
packages/opencode/src/permission/index.ts

@@ -6,19 +6,9 @@ import { Deferred, Effect, Layer, Context } from "effect"
 import os from "os"
 import { PermissionV1 } from "@opencode-ai/core/v1/permission"
 import { EventV2Bridge } from "@/event-v2-bridge"
-import { EventV2 } from "@opencode-ai/core/event"
-
-export const Event = {
-  Asked: EventV2.define({ type: "permission.asked", schema: PermissionV1.Request.fields }),
-  Replied: EventV2.define({
-    type: "permission.replied",
-    schema: {
-      sessionID: PermissionV1.Request.fields.sessionID,
-      requestID: PermissionV1.ID,
-      reply: PermissionV1.Reply,
-    },
-  }),
-}
+import { PermissionV1Event } from "@opencode-ai/schema/permission-v1"
+
+export const Event = PermissionV1Event
 
 export interface Interface {
   readonly ask: (input: PermissionV1.AskInput) => Effect.Effect<void, PermissionV1.Error>

+ 10 - 39
packages/opencode/src/project/project.ts

@@ -16,46 +16,18 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
 import { AppProcess } from "@opencode-ai/core/process"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
-import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
+import { AbsolutePath } from "@opencode-ai/core/schema"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { EventV2 } from "@opencode-ai/core/event"
+import { Project } from "@opencode-ai/schema/project"
 
-const ProjectVcs = Schema.Literal("git")
-
-const ProjectIcon = Schema.Struct({
-  url: optionalOmitUndefined(Schema.String),
-  override: optionalOmitUndefined(Schema.String),
-  color: optionalOmitUndefined(Schema.String),
-})
-
-const ProjectCommands = Schema.Struct({
-  start: optionalOmitUndefined(
-    Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
-  ),
-})
-
-const ProjectTime = Schema.Struct({
-  created: NonNegativeInt,
-  updated: NonNegativeInt,
-  initialized: optionalOmitUndefined(NonNegativeInt),
-})
-
-export const Info = Schema.Struct({
-  id: ProjectV2.ID,
-  worktree: Schema.String,
-  vcs: optionalOmitUndefined(ProjectVcs),
-  name: optionalOmitUndefined(Schema.String),
-  icon: optionalOmitUndefined(ProjectIcon),
-  commands: optionalOmitUndefined(ProjectCommands),
-  time: ProjectTime,
-  sandboxes: Schema.Array(Schema.String),
-}).annotate({ identifier: "Project" })
+export const Info = Project.Info
 export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
 
 export const Event = {
-  Updated: EventV2.define({ type: "project.updated", schema: Info.fields }),
+  Updated: Project.Event.Updated,
 }
 
 type Row = typeof ProjectTable.$inferSelect
@@ -72,7 +44,7 @@ export function fromRow(row: Row): Info {
   return {
     id: row.id,
     worktree: row.worktree,
-    vcs: row.vcs ? Schema.decodeUnknownSync(ProjectVcs)(row.vcs) : undefined,
+    vcs: row.vcs ? Schema.decodeUnknownSync(Project.Vcs)(row.vcs) : undefined,
     name: row.name ?? undefined,
     icon,
     time: {
@@ -88,15 +60,15 @@ export function fromRow(row: Row): Info {
 export const UpdateInput = Schema.Struct({
   projectID: ProjectV2.ID,
   name: Schema.optional(Schema.String),
-  icon: Schema.optional(ProjectIcon),
-  commands: Schema.optional(ProjectCommands),
+  icon: Schema.optional(Project.Icon),
+  commands: Schema.optional(Project.Commands),
 })
 export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInput>>
 
 export const UpdatePayload = Schema.Struct({
   name: Schema.optional(Schema.String),
-  icon: Schema.optional(ProjectIcon),
-  commands: Schema.optional(ProjectCommands),
+  icon: Schema.optional(Project.Icon),
+  commands: Schema.optional(Project.Commands),
 }).annotate({ identifier: "ProjectUpdateInput" })
 export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePayload>>
 
@@ -135,7 +107,6 @@ export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     const fs = yield* FSUtil.Service
-    const proc = yield* AppProcess.Service
     const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
     const projectV2 = yield* ProjectV2.Service
     const projectDirectories = yield* ProjectDirectories.Service
@@ -168,7 +139,7 @@ export const layer = Layer.effect(
         }),
       )
 
-    const fakeVcs = Schema.decodeUnknownSync(Schema.optional(ProjectVcs))(Flag.OPENCODE_FAKE_VCS)
+    const fakeVcs = Schema.decodeUnknownSync(Schema.optional(Project.Vcs))(Flag.OPENCODE_FAKE_VCS)
 
     const scope = yield* Scope.Scope
 

+ 3 - 9
packages/opencode/src/project/vcs.ts

@@ -1,11 +1,12 @@
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
-import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
+import { Effect, Layer, Context, Schema, Scope } from "effect"
 import { formatPatch, structuredPatch } from "diff"
 import { InstanceState } from "@/effect/instance-state"
 import { Watcher } from "@opencode-ai/core/filesystem/watcher"
 import { Git } from "@/git"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { EventV2 } from "@opencode-ai/core/event"
+import { VcsEvent } from "@opencode-ai/schema/vcs-event"
 
 const PATCH_CONTEXT_LINES = 2_147_483_647
 const MAX_PATCH_BYTES = 10_000_000
@@ -234,14 +235,7 @@ const track = Effect.fnUntraced(function* (
 export const Mode = Schema.Literals(["git", "branch"])
 export type Mode = Schema.Schema.Type<typeof Mode>
 
-export const Event = {
-  BranchUpdated: EventV2.define({
-    type: "vcs.branch.updated",
-    schema: {
-      branch: Schema.optional(Schema.String),
-    },
-  }),
-}
+export const Event = VcsEvent
 
 export const Info = Schema.Struct({
   branch: Schema.optional(Schema.String),

+ 20 - 86
packages/opencode/src/question/index.ts

@@ -1,94 +1,28 @@
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Deferred, Effect, Layer, Schema, Context } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { SessionID, MessageID } from "@/session/schema"
+import { SessionID } from "@/session/schema"
 import { QuestionID } from "./schema"
 import { EventV2Bridge } from "@/event-v2-bridge"
-import { EventV2 } from "@opencode-ai/core/event"
-
-// Schemas — these are pure data; nothing checks class identity (see PR
-// description) so they're plain `Schema.Struct` + type alias. That lets
-// `Question.ask` and other internal sites trust the type contract without a
-// re-decode to coerce nested class instances.
-
-export const Option = Schema.Struct({
-  label: Schema.String.annotate({
-    description: "Display text (1-5 words, concise)",
-  }),
-  description: Schema.String.annotate({
-    description: "Explanation of choice",
-  }),
-}).annotate({ identifier: "QuestionOption" })
-export type Option = Schema.Schema.Type<typeof Option>
-
-const base = {
-  question: Schema.String.annotate({
-    description: "Complete question",
-  }),
-  header: Schema.String.annotate({
-    description: "Very short label (max 30 chars)",
-  }),
-  options: Schema.Array(Option).annotate({
-    description: "Available choices",
-  }),
-  multiple: Schema.optional(Schema.Boolean).annotate({
-    description: "Allow selecting multiple choices",
-  }),
-}
-
-export const Info = Schema.Struct({
-  ...base,
-  custom: Schema.optional(Schema.Boolean).annotate({
-    description: "Allow typing a custom answer (default: true)",
-  }),
-}).annotate({ identifier: "QuestionInfo" })
-export type Info = Schema.Schema.Type<typeof Info>
-
-export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" })
-export type Prompt = Schema.Schema.Type<typeof Prompt>
-
-export const Tool = Schema.Struct({
-  messageID: MessageID,
-  callID: Schema.String,
-}).annotate({ identifier: "QuestionTool" })
-export type Tool = Schema.Schema.Type<typeof Tool>
-
-export const Request = Schema.Struct({
-  id: QuestionID,
-  sessionID: SessionID,
-  questions: Schema.Array(Info).annotate({
-    description: "Questions to ask",
-  }),
-  tool: Schema.optional(Tool),
-}).annotate({ identifier: "QuestionRequest" })
-export type Request = Schema.Schema.Type<typeof Request>
-
-export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" })
-export type Answer = Schema.Schema.Type<typeof Answer>
-
-export const Reply = Schema.Struct({
-  answers: Schema.Array(Answer).annotate({
-    description: "User answers in order of questions (each answer is an array of selected labels)",
-  }),
-}).annotate({ identifier: "QuestionReply" })
-export type Reply = Schema.Schema.Type<typeof Reply>
-
-export const Replied = Schema.Struct({
-  sessionID: SessionID,
-  requestID: QuestionID,
-  answers: Schema.Array(Answer),
-}).annotate({ identifier: "QuestionReplied" })
-
-export const Rejected = Schema.Struct({
-  sessionID: SessionID,
-  requestID: QuestionID,
-}).annotate({ identifier: "QuestionRejected" })
-
-export const Event = {
-  Asked: EventV2.define({ type: "question.asked", schema: Request.fields }),
-  Replied: EventV2.define({ type: "question.replied", schema: Replied.fields }),
-  Rejected: EventV2.define({ type: "question.rejected", schema: Rejected.fields }),
-}
+import { QuestionV1 } from "@opencode-ai/schema/question-v1"
+
+export const Option = QuestionV1.Option
+export type Option = typeof Option.Type
+export const Info = QuestionV1.Info
+export type Info = typeof Info.Type
+export const Prompt = QuestionV1.Prompt
+export type Prompt = typeof Prompt.Type
+export const Tool = QuestionV1.Tool
+export type Tool = typeof Tool.Type
+export const Request = QuestionV1.Request
+export type Request = typeof Request.Type
+export const Answer = QuestionV1.Answer
+export type Answer = typeof Answer.Type
+export const Reply = QuestionV1.Reply
+export type Reply = typeof Reply.Type
+export const Replied = QuestionV1.Replied
+export const Rejected = QuestionV1.Rejected
+export const Event = QuestionV1.Event
 
 export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
   override get message() {

+ 3 - 9
packages/opencode/src/question/schema.ts

@@ -1,10 +1,4 @@
-import { Schema } from "effect"
+import { QuestionV1 } from "@opencode-ai/schema/question-v1"
 
-import { Identifier } from "@/id/id"
-import { Newtype } from "@opencode-ai/core/schema"
-
-export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) {
-  static ascending(id?: string): QuestionID {
-    return this.make(Identifier.ascending("question", id))
-  }
-}
+export const QuestionID = QuestionV1.ID
+export type QuestionID = typeof QuestionID.Type

+ 2 - 5
packages/opencode/src/server/event.ts

@@ -1,10 +1,7 @@
-import { EventV2 } from "@opencode-ai/core/event"
 import { Schema } from "effect"
+import { ServerEvent } from "@opencode-ai/schema/server-event"
 
-export const Event = {
-  Connected: EventV2.define({ type: "server.connected", schema: {} }),
-  Disposed: EventV2.define({ type: "global.disposed", schema: {} }),
-}
+export const Event = ServerEvent
 
 export const InstanceDisposed = Schema.Struct({
   id: Schema.String,

+ 6 - 5
packages/opencode/src/server/routes/instance/httpapi/api.ts

@@ -1,6 +1,7 @@
 import { Schema } from "effect"
 import { HttpApi } from "effect/unstable/httpapi"
 import { EventV2 } from "@opencode-ai/core/event"
+import { EventManifest } from "@/event-manifest"
 import { Credential } from "@opencode-ai/core/credential"
 import { Integration } from "@opencode-ai/core/integration"
 import { SkillV2 } from "@opencode-ai/core/skill"
@@ -24,15 +25,13 @@ import { SessionApi } from "./groups/session"
 import { SyncApi } from "./groups/sync"
 import { TuiApi } from "./groups/tui"
 import { WorkspaceApi } from "./groups/workspace"
-import { Api } from "@opencode-ai/server/api"
-// GlobalEventSchema snapshots the registry after event-producing groups register their variants.
+import { makeApi } from "@opencode-ai/server/api"
 import { GlobalApi } from "./groups/global"
 import { Authorization } from "./middleware/authorization"
 import { SchemaErrorMiddleware } from "./middleware/schema-error"
 
 const EventSchema = Schema.Union([
-  ...EventV2.registry
-    .values()
+  ...EventManifest.Latest.values()
     .map((definition) =>
       Schema.Struct({
         id: EventV2.ID,
@@ -44,6 +43,8 @@ const EventSchema = Schema.Union([
   InstanceDisposed,
 ]).annotate({ identifier: "Event" })
 
+export const ServerApi = makeApi(EventManifest.Latest.values().toArray())
+
 export const RootHttpApi = HttpApi.make("opencode-root")
   .addHttpApi(ControlApi)
   .addHttpApi(ControlPlaneApi)
@@ -73,7 +74,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
   .addHttpApi(RootHttpApi)
   .addHttpApi(EventApi)
   .addHttpApi(InstanceHttpApi)
-  .addHttpApi(Api)
+  .addHttpApi(ServerApi)
   .addHttpApi(PtyConnectApi)
   .annotate(HttpApi.AdditionalSchemas, [
     EventSchema,

+ 3 - 5
packages/opencode/src/server/routes/instance/httpapi/groups/global.ts

@@ -1,6 +1,6 @@
-import { Config } from "@/config/config"
 import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
 import { EventV2 } from "@opencode-ai/core/event"
+import { EventManifest } from "@/event-manifest"
 import { InstanceDisposed } from "@/server/event"
 import "@opencode-ai/core/account"
 import "@/server/event"
@@ -13,8 +13,7 @@ const GlobalHealth = Schema.Struct({
   version: Schema.String,
 })
 
-const SyncEventSchemas = EventV2.registry
-  .values()
+const SyncEventSchemas = EventManifest.Latest.values()
   .flatMap((definition) => {
     if (!definition.durable) return []
     return [
@@ -38,8 +37,7 @@ const GlobalEventSchema = Schema.Struct({
   project: Schema.optional(Schema.String),
   workspace: Schema.optional(Schema.String),
   payload: Schema.Union([
-    ...EventV2.registry
-      .values()
+    ...EventManifest.Latest.values()
       .map((definition) =>
         Schema.Struct({ id: EventV2.ID, type: Schema.Literal(definition.type), properties: definition.data }),
       )

+ 1 - 53
packages/opencode/src/server/tui-event.ts

@@ -1,53 +1 @@
-import { SessionID } from "@/session/schema"
-import { PositiveInt } from "@opencode-ai/core/schema"
-import { EventV2 } from "@opencode-ai/core/event"
-import { Effect, Schema } from "effect"
-
-const DEFAULT_TOAST_DURATION = 5000
-
-export const TuiEvent = {
-  PromptAppend: EventV2.define({ type: "tui.prompt.append", schema: { text: Schema.String } }),
-  CommandExecute: EventV2.define({
-    type: "tui.command.execute",
-    schema: {
-      command: Schema.Union([
-        Schema.Literals([
-          "session.list",
-          "session.new",
-          "session.share",
-          "session.interrupt",
-          "session.compact",
-          "session.page.up",
-          "session.page.down",
-          "session.line.up",
-          "session.line.down",
-          "session.half.page.up",
-          "session.half.page.down",
-          "session.first",
-          "session.last",
-          "prompt.clear",
-          "prompt.submit",
-          "agent.cycle",
-        ]),
-        Schema.String,
-      ]),
-    },
-  }),
-  ToastShow: EventV2.define({
-    type: "tui.toast.show",
-    schema: {
-      title: Schema.optional(Schema.String),
-      message: Schema.String,
-      variant: Schema.Literals(["info", "success", "warning", "error"]),
-      duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
-        description: "Duration in milliseconds",
-      }),
-    },
-  }),
-  SessionSelect: EventV2.define({
-    type: "tui.session.select",
-    schema: {
-      sessionID: SessionID.annotate({ description: "Session ID to navigate to" }),
-    },
-  }),
-}
+export { TuiEvent } from "@opencode-ai/schema/tui-event"

+ 2 - 9
packages/opencode/src/session/compaction.ts

@@ -23,17 +23,10 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { ModelV2 } from "@opencode-ai/core/model"
-import { EventV2 } from "@opencode-ai/core/event"
 import { buildPrompt } from "@opencode-ai/core/session/compaction"
+import { SessionCompactionEvent } from "@opencode-ai/schema/session-compaction-event"
 
-export const Event = {
-  Compacted: EventV2.define({
-    type: "session.compacted",
-    schema: {
-      sessionID: SessionID,
-    },
-  }),
-}
+export const Event = SessionCompactionEvent
 
 export const PRUNE_MINIMUM = 20_000
 export const PRUNE_PROTECT = 40_000

+ 2 - 14
packages/opencode/src/session/message-v2.ts

@@ -1,5 +1,4 @@
-import { EventV2 } from "@opencode-ai/core/event"
-import { SessionID, MessageID, PartID } from "./schema"
+import { SessionID, MessageID } from "./schema"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import {
@@ -12,11 +11,9 @@ import {
   Info,
   OutputLengthError,
   Part,
-  StructuredOutputError,
   SubtaskPart,
   User,
   WithParts,
-  type ToolPart,
 } from "@opencode-ai/core/v1/session"
 
 import { NamedError } from "@opencode-ai/core/util/error"
@@ -61,16 +58,7 @@ export const Event = {
   Updated: SessionV1.Event.MessageUpdated,
   Removed: SessionV1.Event.MessageRemoved,
   PartUpdated: SessionV1.Event.PartUpdated,
-  PartDelta: EventV2.define({
-    type: "message.part.delta",
-    schema: {
-      sessionID: SessionID,
-      messageID: MessageID,
-      partID: PartID,
-      field: Schema.String,
-      delta: Schema.String,
-    },
-  }),
+  PartDelta: SessionV1.Event.PartDelta,
   PartRemoved: SessionV1.Event.PartRemoved,
 }
 

+ 2 - 61
packages/opencode/src/session/session.ts

@@ -11,7 +11,6 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { Database } from "@opencode-ai/core/database/database"
 import { makeRuntime } from "@opencode-ai/core/effect/runtime"
 import { EventV2Bridge } from "@/event-v2-bridge"
-import { EventV2 } from "@opencode-ai/core/event"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { SessionExecution } from "@opencode-ai/core/session/execution"
 
@@ -38,7 +37,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
 import { SessionID, MessageID, PartID } from "./schema"
 
 import type { Provider } from "@/provider/provider"
-import { Permission } from "@/permission"
 import { Global } from "@opencode-ai/core/global"
 import { Effect, Layer, Option, Context, Schema, Types } from "effect"
 import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
@@ -309,69 +307,12 @@ export type GlobalListInput = {
   archived?: boolean
 }
 
-const CreatedEventSchema = Schema.Struct({
-  sessionID: SessionID,
-  info: Info,
-})
-
-const UpdatedShare = Schema.Struct({
-  url: Schema.optional(Schema.NullOr(Schema.String)),
-})
-
-const UpdatedTime = Schema.Struct({
-  created: Schema.optional(Schema.NullOr(NonNegativeInt)),
-  updated: Schema.optional(Schema.NullOr(NonNegativeInt)),
-  compacting: Schema.optional(Schema.NullOr(NonNegativeInt)),
-  archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)),
-})
-
-const UpdatedInfo = Schema.Struct({
-  id: Schema.optional(Schema.NullOr(SessionID)),
-  slug: Schema.optional(Schema.NullOr(Schema.String)),
-  projectID: Schema.optional(Schema.NullOr(ProjectV2.ID)),
-  workspaceID: Schema.optional(Schema.NullOr(WorkspaceV2.ID)),
-  directory: Schema.optional(Schema.NullOr(Schema.String)),
-  path: Schema.optional(Schema.NullOr(Schema.String)),
-  parentID: Schema.optional(Schema.NullOr(SessionID)),
-  summary: Schema.optional(Schema.NullOr(Summary)),
-  cost: Schema.optional(Schema.Finite),
-  tokens: Schema.optional(Tokens),
-  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)),
-  metadata: Schema.optional(Schema.NullOr(Metadata)),
-  time: Schema.optional(UpdatedTime),
-  permission: Schema.optional(Schema.NullOr(PermissionV1.Ruleset)),
-  revert: Schema.optional(Schema.NullOr(Revert)),
-})
-
-const UpdatedEventSchema = Schema.Struct({
-  sessionID: SessionID,
-  info: UpdatedInfo,
-})
-
 export const Event = {
   Created: SessionV1.Event.Created,
   Updated: SessionV1.Event.Updated,
   Deleted: SessionV1.Event.Deleted,
-  Diff: EventV2.define({
-    type: "session.diff",
-    schema: {
-      sessionID: SessionID,
-      diff: Schema.Array(Snapshot.FileDiff),
-    },
-  }),
-  Error: EventV2.define({
-    type: "session.error",
-    schema: {
-      sessionID: Schema.optional(SessionID),
-      // Reuses SessionV1.Assistant.fields.error (already Schema.optional) so
-      // the derived schema keeps the same discriminated-union shape on the event stream.
-      error: SessionV1.Assistant.fields.error,
-    },
-  }),
+  Diff: SessionV1.Event.Diff,
+  Error: SessionV1.Event.Error,
 }
 
 export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) {

+ 5 - 44
packages/opencode/src/session/status.ts

@@ -1,53 +1,14 @@
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { InstanceState } from "@/effect/instance-state"
 import { SessionID } from "./schema"
-import { NonNegativeInt } from "@opencode-ai/core/schema"
-import { Effect, Layer, Context, Schema } from "effect"
+import { Effect, Layer, Context } from "effect"
 import { EventV2Bridge } from "@/event-v2-bridge"
-import { EventV2 } from "@opencode-ai/core/event"
+import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
 
-export const Info = Schema.Union([
-  Schema.Struct({
-    type: Schema.Literal("idle"),
-  }),
-  Schema.Struct({
-    type: Schema.Literal("retry"),
-    attempt: NonNegativeInt,
-    message: Schema.String,
-    action: Schema.optional(
-      Schema.Struct({
-        reason: Schema.String,
-        provider: Schema.String,
-        title: Schema.String,
-        message: Schema.String,
-        label: Schema.String,
-        link: Schema.optional(Schema.String),
-      }),
-    ),
-    next: NonNegativeInt,
-  }),
-  Schema.Struct({
-    type: Schema.Literal("busy"),
-  }),
-]).annotate({ identifier: "SessionStatus" })
-export type Info = Schema.Schema.Type<typeof Info>
+export const Info = SessionStatusEvent.Info
+export type Info = SessionStatusEvent.Info
 
-export const Event = {
-  Status: EventV2.define({
-    type: "session.status",
-    schema: {
-      sessionID: SessionID,
-      status: Info,
-    },
-  }),
-  // deprecated
-  Idle: EventV2.define({
-    type: "session.idle",
-    schema: {
-      sessionID: SessionID,
-    },
-  }),
-}
+export const Event = SessionStatusEvent
 
 export interface Interface {
   readonly get: (sessionID: SessionID) => Effect.Effect<Info>

+ 5 - 19
packages/opencode/src/session/todo.ts

@@ -1,31 +1,17 @@
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { SessionID } from "./schema"
-import { Effect, Layer, Context, Schema } from "effect"
+import { Effect, Layer, Context } from "effect"
 import { Database } from "@opencode-ai/core/database/database"
 import { eq } from "drizzle-orm"
 import { asc } from "drizzle-orm"
 import { TodoTable } from "@opencode-ai/core/session/sql"
 import { EventV2Bridge } from "@/event-v2-bridge"
-import { EventV2 } from "@opencode-ai/core/event"
+import { SessionTodo } from "@opencode-ai/schema/session-todo"
 
-export const Info = Schema.Struct({
-  content: Schema.String.annotate({ description: "Brief description of the task" }),
-  status: Schema.String.annotate({
-    description: "Current status of the task: pending, in_progress, completed, cancelled",
-  }),
-  priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
-}).annotate({ identifier: "Todo" })
-export type Info = Schema.Schema.Type<typeof Info>
+export const Info = SessionTodo.Info
+export type Info = SessionTodo.Info
 
-export const Event = {
-  Updated: EventV2.define({
-    type: "todo.updated",
-    schema: {
-      sessionID: SessionID,
-      todos: Schema.Array(Info),
-    },
-  }),
-}
+export const Event = SessionTodo.Event
 
 export interface Interface {
   readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect<void>

+ 2 - 10
packages/opencode/src/snapshot/index.ts

@@ -9,6 +9,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Hash } from "@opencode-ai/core/util/hash"
 import { Config } from "@/config/config"
 import { Global } from "@opencode-ai/core/global"
+import { Info } from "@opencode-ai/schema/file-diff"
 
 export const Patch = Schema.Struct({
   hash: Schema.String,
@@ -16,16 +17,7 @@ export const Patch = Schema.Struct({
 })
 export type Patch = typeof Patch.Type
 
-export const FileDiff = Schema.Struct({
-  // Optional because legacy/imported `summary_diffs` on disk may omit
-  // file details and patch text. Required Schema rejected the whole
-  // session response and broke session loading on Desktop.
-  file: Schema.optional(Schema.String),
-  patch: Schema.optional(Schema.String),
-  additions: Schema.Finite,
-  deletions: Schema.Finite,
-  status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
-}).annotate({ identifier: "SnapshotFileDiff" })
+export const FileDiff = Info
 export type FileDiff = typeof FileDiff.Type
 
 const prune = "7.days"

+ 2 - 16
packages/opencode/src/worktree/index.ts

@@ -10,7 +10,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
 import type { ProjectV2 } from "@opencode-ai/core/project"
 import { Slug } from "@opencode-ai/core/util/slug"
 import { errorMessage } from "../util/error"
-import { EventV2 } from "@opencode-ai/core/event"
 import { GlobalBus } from "@/bus/global"
 import { Git } from "@/git"
 import { Effect, Layer, Path, Schema, Scope, Context } from "effect"
@@ -19,22 +18,9 @@ import { NodePath } from "@effect/platform-node"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { AppProcess } from "@opencode-ai/core/process"
 import { InstanceState } from "@/effect/instance-state"
+import { WorktreeEvent } from "@opencode-ai/schema/worktree-event"
 
-export const Event = {
-  Ready: EventV2.define({
-    type: "worktree.ready",
-    schema: {
-      name: Schema.String,
-      branch: Schema.optional(Schema.String),
-    },
-  }),
-  Failed: EventV2.define({
-    type: "worktree.failed",
-    schema: {
-      message: Schema.String,
-    },
-  }),
-}
+export const Event = WorktreeEvent
 
 export const Info = Schema.Struct({
   name: Schema.String,

+ 24 - 0
packages/opencode/test/event-manifest.test.ts

@@ -0,0 +1,24 @@
+import { describe, expect, test } from "bun:test"
+import { SessionEvent } from "@opencode-ai/core/session/event"
+import { EventManifest as SchemaEventManifest } from "@opencode-ai/schema/event-manifest"
+import { Todo } from "@/session/todo"
+import { EventManifest } from "@/event-manifest"
+
+describe("public event manifest", () => {
+  test("contains every latest public wire type once", () => {
+    expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions)
+    expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest)
+    expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable)
+    expect(EventManifest.Latest.size).toBe(85)
+    expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended)
+    expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated)
+    expect(EventManifest.Latest.has("ide.installed")).toBe(false)
+    expect(EventManifest.Latest.has("server.connected")).toBe(true)
+    expect(EventManifest.Latest.has("global.disposed")).toBe(true)
+  })
+
+  test("contains only the current step settlement versions", () => {
+    expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false)
+    expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended)
+  })
+})

+ 6 - 0
packages/schema/src/catalog.ts

@@ -0,0 +1,6 @@
+export * as Catalog from "./catalog"
+
+import { define, inventory } from "./event"
+
+const Updated = define({ type: "catalog.updated", schema: {} })
+export const Event = { Updated, Definitions: inventory(Updated) }

+ 10 - 0
packages/schema/src/durable-event-manifest.ts

@@ -0,0 +1,10 @@
+export * as DurableEventManifest from "./durable-event-manifest"
+
+import { Event } from "./event"
+import { SessionEvent } from "./session-event"
+import { SessionV1 } from "./session-v1"
+
+export const Durable = Event.durable([
+  ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined),
+  ...SessionEvent.DurableDefinitions,
+])

+ 84 - 0
packages/schema/src/event-manifest.ts

@@ -0,0 +1,84 @@
+export * as EventManifest from "./event-manifest"
+
+import { Catalog } from "./catalog"
+import { Durable } from "./durable-event-manifest"
+import { Event } from "./event"
+import { FileSystem } from "./filesystem"
+import { FileSystemWatcher } from "./filesystem-watcher"
+import { InstallationEvent } from "./installation-event"
+import { Integration } from "./integration"
+import { LegacyEvent } from "./legacy-event"
+import { LspEvent } from "./lsp-event"
+import { McpEvent } from "./mcp-event"
+import { ModelsDev } from "./models-dev"
+import { Permission } from "./permission"
+import { PermissionV1 } from "./permission-v1"
+import { Plugin } from "./plugin"
+import { Project } from "./project"
+import { ProjectDirectories } from "./project-directories"
+import { Pty } from "./pty"
+import { Question } from "./question"
+import { QuestionV1 } from "./question-v1"
+import { Reference } from "./reference"
+import { ServerEvent } from "./server-event"
+import { SessionCompactionEvent } from "./session-compaction-event"
+import { SessionEvent } from "./session-event"
+import { SessionStatusEvent } from "./session-status-event"
+import { SessionTodo } from "./session-todo"
+import { SessionV1 } from "./session-v1"
+import { TuiEvent } from "./tui-event"
+import { VcsEvent } from "./vcs-event"
+import { WorkspaceEvent } from "./workspace-event"
+import { WorktreeEvent } from "./worktree-event"
+
+const sessionV1DurableDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined)
+const sessionV1LiveDefinitions = SessionV1.Event.Definitions.filter((definition) => definition.durable === undefined)
+
+const coreDefinitions = Event.inventory(...sessionV1DurableDefinitions, ...SessionEvent.Definitions)
+
+const foundationDefinitions = Event.inventory(
+  ...ModelsDev.Event.Definitions,
+  ...Integration.Event.Definitions,
+  ...Catalog.Event.Definitions,
+  ...coreDefinitions,
+)
+
+const featureDefinitions = Event.inventory(
+  ...FileSystem.Event.Definitions,
+  ...Reference.Event.Definitions,
+  ...Permission.Event.Definitions,
+  ...Plugin.Event.Definitions,
+  ...ProjectDirectories.Event.Definitions,
+  ...FileSystemWatcher.Event.Definitions,
+  ...Pty.Event.Definitions,
+  ...Question.Event.Definitions,
+)
+
+export const ServerDefinitions = Event.inventory(
+  ...foundationDefinitions,
+  ...featureDefinitions,
+  ...SessionTodo.Event.Definitions,
+)
+
+export const Definitions = Event.inventory(
+  ...foundationDefinitions,
+  ...sessionV1LiveDefinitions,
+  ...InstallationEvent.Definitions,
+  ...featureDefinitions,
+  ...SessionTodo.Event.Definitions,
+  ...LspEvent.Definitions,
+  ...PermissionV1.Event.Definitions,
+  ...TuiEvent.Definitions,
+  ...McpEvent.Definitions,
+  ...LegacyEvent.Definitions,
+  ...Project.Event.Definitions,
+  ...SessionStatusEvent.Definitions,
+  ...QuestionV1.Event.Definitions,
+  ...SessionCompactionEvent.Definitions,
+  ...VcsEvent.Definitions,
+  ...WorkspaceEvent.Definitions,
+  ...WorktreeEvent.Definitions,
+  ...ServerEvent.Definitions,
+)
+export const Latest = Event.latest(Definitions)
+export { Durable }

+ 125 - 0
packages/schema/src/event.ts

@@ -0,0 +1,125 @@
+export * as Event from "./event"
+
+import { Schema } from "effect"
+import { ascending } from "./identifier"
+import { Location } from "./location"
+import { withStatics } from "./schema"
+
+export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
+  Schema.brand("Event.ID"),
+  withStatics((schema) => ({ create: () => schema.make("evt_" + ascending()) })),
+)
+export type ID = typeof ID.Type
+
+export type Definition<
+  Type extends string = string,
+  DataSchema extends Schema.Codec<unknown, unknown> = Schema.Codec<unknown, unknown>,
+> = Schema.Top & {
+  readonly type: Type
+  readonly durable?: {
+    readonly version: number
+    readonly aggregate: string
+  }
+  readonly data: DataSchema
+}
+
+export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
+
+export type Payload<D extends Definition = Definition> = {
+  readonly id: ID
+  readonly type: D["type"]
+  readonly data: Data<D>
+  readonly durable?: {
+    readonly aggregateID: string
+    readonly seq: number
+    readonly version: number
+  }
+  readonly location?: Location.Ref
+  readonly metadata?: Record<string, unknown>
+}
+
+export function define<
+  const Type extends string,
+  Fields extends Readonly<Record<PropertyKey, Schema.Codec<unknown, unknown>>>,
+>(input: {
+  readonly type: Type
+  readonly durable?: {
+    readonly version: number
+    readonly aggregate: string
+  }
+  readonly schema: Fields
+}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
+  const data = Schema.Struct(input.schema)
+  return Object.assign(
+    Schema.Struct({
+      id: ID,
+      metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
+      type: Schema.Literal(input.type),
+      durable: Schema.optional(
+        Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number }),
+      ),
+      location: Schema.optional(Location.Ref),
+      data,
+    }).annotate({ identifier: input.type }),
+    {
+      type: input.type,
+      ...(input.durable === undefined ? {} : { durable: input.durable }),
+      data,
+    },
+  ) as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>>
+}
+
+export function inventory<const Definitions extends ReadonlyArray<Definition>>(...definitions: Definitions) {
+  return Object.freeze(definitions)
+}
+
+export function latest(definitions: ReadonlyArray<Definition>) {
+  return readonlyMap(
+    definitions.reduce((result, definition) => {
+      const existing = result.get(definition.type)
+      if (!existing) {
+        result.set(definition.type, definition)
+        return result
+      }
+      if (definition.durable && existing.durable && definition.durable.version !== existing.durable.version) {
+        if (definition.durable.version > existing.durable.version) result.set(definition.type, definition)
+        return result
+      }
+      if (definition !== existing) throw new Error(`Duplicate latest event definition for ${definition.type}`)
+      return result
+    }, new Map<string, Definition>()),
+  )
+}
+
+export function versionedType(type: string, version: number) {
+  return `${type}.${version}`
+}
+
+export function durable(definitions: ReadonlyArray<Definition>) {
+  return readonlyMap(
+    definitions.reduce((result, definition) => {
+      if (!definition.durable) return result
+      const key = versionedType(definition.type, definition.durable.version)
+      if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`)
+      result.set(key, definition)
+      return result
+    }, new Map<string, Definition>()),
+  )
+}
+
+function readonlyMap<Key, Value>(map: Map<Key, Value>): ReadonlyMap<Key, Value> {
+  const result: ReadonlyMap<Key, Value> = Object.freeze({
+    get size() {
+      return map.size
+    },
+    entries: () => map.entries(),
+    forEach: (callback: (value: Value, key: Key, map: ReadonlyMap<Key, Value>) => void, thisArg?: unknown) =>
+      map.forEach((value, key) => callback.call(thisArg, value, key, result)),
+    get: (key: Key) => map.get(key),
+    has: (key: Key) => map.has(key),
+    keys: () => map.keys(),
+    values: () => map.values(),
+    [Symbol.iterator]: () => map[Symbol.iterator](),
+  })
+  return result
+}

+ 12 - 0
packages/schema/src/file-diff.ts

@@ -0,0 +1,12 @@
+export * as FileDiff from "./file-diff"
+
+import { Schema } from "effect"
+
+export const Info = Schema.Struct({
+  file: Schema.optional(Schema.String),
+  patch: Schema.optional(Schema.String),
+  additions: Schema.Finite,
+  deletions: Schema.Finite,
+  status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
+}).annotate({ identifier: "SnapshotFileDiff" })
+export type Info = typeof Info.Type

+ 13 - 0
packages/schema/src/filesystem-watcher.ts

@@ -0,0 +1,13 @@
+export * as FileSystemWatcher from "./filesystem-watcher"
+
+import { Schema } from "effect"
+import { define, inventory } from "./event"
+
+const Updated = define({
+  type: "file.watcher.updated",
+  schema: {
+    file: Schema.String,
+    event: Schema.Literals(["add", "change", "unlink"]),
+  },
+})
+export const Event = { Updated, Definitions: inventory(Updated) }

+ 7 - 0
packages/schema/src/filesystem.ts

@@ -1,8 +1,15 @@
 export * as FileSystem from "./filesystem"
 
 import { Schema } from "effect"
+import { define, inventory } from "./event"
 import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
 
+const Edited = define({
+  type: "file.edited",
+  schema: { file: Schema.String },
+})
+export const Event = { Edited, Definitions: inventory(Edited) }
+
 export interface Entry extends Schema.Schema.Type<typeof Entry> {}
 export const Entry = Schema.Struct({
   path: RelativePath,

+ 13 - 0
packages/schema/src/ide-event.ts

@@ -0,0 +1,13 @@
+export * as IdeEvent from "./ide-event"
+
+import { Schema } from "effect"
+import { Event } from "./event"
+
+export const Installed = Event.define({
+  type: "ide.installed",
+  schema: {
+    ide: Schema.String,
+  },
+})
+
+export const Definitions = Event.inventory(Installed)

+ 1 - 0
packages/schema/src/index.ts

@@ -2,6 +2,7 @@ export { Agent } from "./agent"
 export { Command } from "./command"
 export { Connection } from "./connection"
 export { Credential } from "./credential"
+export { Event } from "./event"
 export { FileSystem } from "./filesystem"
 export { Integration } from "./integration"
 export { LLM } from "./llm"

+ 20 - 0
packages/schema/src/installation-event.ts

@@ -0,0 +1,20 @@
+export * as InstallationEvent from "./installation-event"
+
+import { Schema } from "effect"
+import { Event } from "./event"
+
+export const Updated = Event.define({
+  type: "installation.updated",
+  schema: {
+    version: Schema.String,
+  },
+})
+
+export const UpdateAvailable = Event.define({
+  type: "installation.update-available",
+  schema: {
+    version: Schema.String,
+  },
+})
+
+export const Definitions = Event.inventory(Updated, UpdateAvailable)

+ 11 - 0
packages/schema/src/integration.ts

@@ -1,6 +1,7 @@
 export * as Integration from "./integration"
 
 import { Schema } from "effect"
+import { define, inventory } from "./event"
 
 export const ID = Schema.String.pipe(Schema.brand("Integration.ID"))
 export type ID = typeof ID.Type
@@ -72,6 +73,16 @@ export type Method = typeof Method.Type
 export const Inputs = Schema.Record(Schema.String, Schema.String).annotate({ identifier: "Integration.Inputs" })
 export type Inputs = typeof Inputs.Type
 
+const Updated = define({
+  type: "integration.updated",
+  schema: {},
+})
+const ConnectionUpdated = define({
+  type: "integration.connection.updated",
+  schema: { integrationID: ID },
+})
+export const Event = { Updated, ConnectionUpdated, Definitions: inventory(Updated, ConnectionUpdated) }
+
 export interface Ref extends Schema.Schema.Type<typeof Ref> {}
 export const Ref = Schema.Struct({
   id: ID,

+ 18 - 0
packages/schema/src/legacy-event.ts

@@ -0,0 +1,18 @@
+export * as LegacyEvent from "./legacy-event"
+
+import { Schema } from "effect"
+import { define, inventory } from "./event"
+import { SessionID } from "./session-id"
+import { SessionV1 } from "./session-v1"
+
+export const CommandExecuted = define({
+  type: "command.executed",
+  schema: {
+    name: Schema.String,
+    sessionID: SessionID,
+    arguments: Schema.String,
+    messageID: SessionV1.MessageID,
+  },
+})
+
+export const Definitions = inventory(CommandExecuted)

+ 2 - 2
packages/schema/src/location.ts

@@ -2,12 +2,12 @@ export * as Location from "./location"
 
 import { Effect, Schema } from "effect"
 import { AbsolutePath } from "./schema"
-import { Workspace } from "./workspace"
+import { WorkspaceID } from "./workspace-id"
 
 export interface Ref extends Schema.Schema.Type<typeof Ref> {}
 export const Ref = Schema.Struct({
   directory: AbsolutePath,
-  workspaceID: Schema.optional(Workspace.ID).pipe(
+  workspaceID: Schema.optional(WorkspaceID).pipe(
     Schema.withDecodingDefault(Effect.succeed(undefined)),
     Schema.withConstructorDefault(Effect.succeed(undefined)),
   ),

+ 7 - 0
packages/schema/src/lsp-event.ts

@@ -0,0 +1,7 @@
+export * as LspEvent from "./lsp-event"
+
+import { Event } from "./event"
+
+export const Updated = Event.define({ type: "lsp.updated", schema: {} })
+
+export const Definitions = Event.inventory(Updated)

+ 21 - 0
packages/schema/src/mcp-event.ts

@@ -0,0 +1,21 @@
+export * as McpEvent from "./mcp-event"
+
+import { Schema } from "effect"
+import { Event } from "./event"
+
+export const ToolsChanged = Event.define({
+  type: "mcp.tools.changed",
+  schema: {
+    server: Schema.String,
+  },
+})
+
+export const BrowserOpenFailed = Event.define({
+  type: "mcp.browser.open.failed",
+  schema: {
+    mcpName: Schema.String,
+    url: Schema.String,
+  },
+})
+
+export const Definitions = Event.inventory(ToolsChanged, BrowserOpenFailed)

+ 9 - 0
packages/schema/src/models-dev.ts

@@ -0,0 +1,9 @@
+export * as ModelsDev from "./models-dev"
+
+import { define, inventory } from "./event"
+
+const Refreshed = define({
+  type: "models-dev.refreshed",
+  schema: {},
+})
+export const Event = { Refreshed, Definitions: inventory(Refreshed) }

+ 67 - 0
packages/schema/src/permission-v1.ts

@@ -0,0 +1,67 @@
+export * as PermissionV1 from "./permission-v1"
+
+import { Schema } from "effect"
+import { define, inventory } from "./event"
+import { ascending } from "./identifier"
+import { Project } from "./project"
+import { withStatics } from "./schema"
+import { SessionID } from "./session-id"
+
+export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
+  Schema.brand("PermissionID"),
+  withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + ascending()) })),
+)
+export type ID = typeof ID.Type
+
+export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" })
+export type Action = typeof Action.Type
+
+export const Rule = Schema.Struct({ permission: Schema.String, pattern: Schema.String, action: Action }).annotate({
+  identifier: "PermissionRule",
+})
+export type Rule = typeof Rule.Type
+
+export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
+export type Ruleset = typeof Ruleset.Type
+
+export const Request = Schema.Struct({
+  id: ID,
+  sessionID: SessionID,
+  permission: Schema.String,
+  patterns: Schema.Array(Schema.String),
+  metadata: Schema.Record(Schema.String, Schema.Unknown),
+  always: Schema.Array(Schema.String),
+  tool: Schema.optional(Schema.Struct({ messageID: Schema.String, callID: Schema.String })),
+}).annotate({ identifier: "PermissionRequest" })
+export type Request = typeof Request.Type
+
+export const Reply = Schema.Literals(["once", "always", "reject"])
+export type Reply = typeof Reply.Type
+
+export const ReplyBody = Schema.Struct({ reply: Reply, message: Schema.optional(Schema.String) }).annotate({
+  identifier: "PermissionReplyBody",
+})
+export type ReplyBody = typeof ReplyBody.Type
+
+export const Approval = Schema.Struct({ projectID: Project.ID, patterns: Schema.Array(Schema.String) }).annotate({
+  identifier: "PermissionApproval",
+})
+export type Approval = typeof Approval.Type
+
+export const AskInput = Schema.Struct({ ...Request.fields, id: Schema.optional(ID), ruleset: Ruleset }).annotate({
+  identifier: "PermissionAskInput",
+})
+export type AskInput = typeof AskInput.Type
+
+export const ReplyInput = Schema.Struct({ requestID: ID, ...ReplyBody.fields }).annotate({
+  identifier: "PermissionReplyInput",
+})
+export type ReplyInput = typeof ReplyInput.Type
+
+const Asked = define({ type: "permission.asked", schema: Request.fields })
+const Replied = define({
+  type: "permission.replied",
+  schema: { sessionID: SessionID, requestID: ID, reply: Reply },
+})
+export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) }
+export const PermissionV1Event = Event

+ 48 - 0
packages/schema/src/permission.ts

@@ -1,6 +1,54 @@
 export * as Permission from "./permission"
 
 import { Schema } from "effect"
+import { define, inventory } from "./event"
+import { ascending } from "./identifier"
+import { SessionID } from "./session-id"
+import { withStatics } from "./schema"
+
+export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
+  Schema.brand("PermissionV2.ID"),
+  withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + ascending()) })),
+)
+export type ID = typeof ID.Type
+
+export const Source = Schema.Union([
+  Schema.Struct({
+    type: Schema.Literal("tool"),
+    messageID: Schema.String,
+    callID: Schema.String,
+  }),
+]).annotate({ identifier: "PermissionV2.Source" })
+export type Source = typeof Source.Type
+
+const RequestFields = {
+  sessionID: SessionID,
+  action: Schema.String,
+  resources: Schema.Array(Schema.String),
+  save: Schema.Array(Schema.String).pipe(Schema.optional),
+  metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
+  source: Source.pipe(Schema.optional),
+}
+
+export const Request = Schema.Struct({
+  id: ID,
+  ...RequestFields,
+}).annotate({ identifier: "PermissionV2.Request" })
+export type Request = typeof Request.Type
+
+export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" })
+export type Reply = typeof Reply.Type
+
+const Asked = define({ type: "permission.v2.asked", schema: Request.fields })
+const Replied = define({
+  type: "permission.v2.replied",
+  schema: {
+    sessionID: SessionID,
+    requestID: ID,
+    reply: Reply,
+  },
+})
+export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) }
 
 export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" })
 export type Effect = typeof Effect.Type

+ 15 - 0
packages/schema/src/plugin.ts

@@ -0,0 +1,15 @@
+export * as Plugin from "./plugin"
+
+import { Schema } from "effect"
+import { define, inventory } from "./event"
+
+export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
+export type ID = typeof ID.Type
+export const PluginID = ID
+
+const Added = define({
+  type: "plugin.added",
+  schema: { id: ID },
+})
+export const Event = { Added, Definitions: inventory(Added) }
+export const PluginEvent = Event

+ 11 - 0
packages/schema/src/project-directories.ts

@@ -0,0 +1,11 @@
+export * as ProjectDirectories from "./project-directories"
+
+import { define, inventory } from "./event"
+import { Project } from "./project"
+
+const Updated = define({
+  type: "project.directories.updated",
+  schema: { projectID: Project.ID },
+})
+export const Event = { Updated, Definitions: inventory(Updated) }
+export const ProjectDirectoriesEvent = Event

+ 34 - 1
packages/schema/src/project.ts

@@ -1,10 +1,43 @@
 export * as Project from "./project"
 
 import { Schema } from "effect"
-import { withStatics } from "./schema"
+import { define, inventory } from "./event"
+import { NonNegativeInt, optionalOmitUndefined, withStatics } from "./schema"
 
 export const ID = Schema.String.pipe(
   Schema.brand("Project.ID"),
   withStatics((schema) => ({ global: schema.make("global") })),
 )
 export type ID = typeof ID.Type
+
+export const Vcs = Schema.Literal("git")
+export const Icon = Schema.Struct({
+  url: optionalOmitUndefined(Schema.String),
+  override: optionalOmitUndefined(Schema.String),
+  color: optionalOmitUndefined(Schema.String),
+})
+export const Commands = Schema.Struct({
+  start: optionalOmitUndefined(
+    Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
+  ),
+})
+export const Time = Schema.Struct({
+  created: NonNegativeInt,
+  updated: NonNegativeInt,
+  initialized: optionalOmitUndefined(NonNegativeInt),
+})
+
+export const Info = Schema.Struct({
+  id: ID,
+  worktree: Schema.String,
+  vcs: optionalOmitUndefined(Vcs),
+  name: optionalOmitUndefined(Schema.String),
+  icon: optionalOmitUndefined(Icon),
+  commands: optionalOmitUndefined(Commands),
+  time: Time,
+  sandboxes: Schema.Array(Schema.String),
+}).annotate({ identifier: "Project" })
+export type Info = typeof Info.Type
+
+const Updated = define({ type: "project.updated", schema: Info.fields })
+export const Event = { Updated, Definitions: inventory(Updated) }

+ 35 - 0
packages/schema/src/pty.ts

@@ -0,0 +1,35 @@
+export * as Pty from "./pty"
+
+import { Schema } from "effect"
+import { define, inventory } from "./event"
+import { ascending } from "./identifier"
+import { NonNegativeInt } from "./schema"
+import { withStatics } from "./schema"
+
+const IDSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
+
+export const ID = IDSchema.pipe(
+  withStatics((schema: typeof IDSchema) => ({
+    ascending: (id?: string) => schema.make(id ?? "pty_" + ascending()),
+  })),
+)
+export type ID = typeof ID.Type
+
+export const Info = Schema.Struct({
+  id: ID,
+  title: Schema.String,
+  command: Schema.String,
+  args: Schema.Array(Schema.String),
+  cwd: Schema.String,
+  status: Schema.Literals(["running", "exited"]),
+  pid: NonNegativeInt,
+  exitCode: Schema.optional(NonNegativeInt),
+}).annotate({ identifier: "Pty" })
+export const PtyInfo = Info
+
+const Created = define({ type: "pty.created", schema: { info: Info } })
+const Updated = define({ type: "pty.updated", schema: { info: Info } })
+const Exited = define({ type: "pty.exited", schema: { id: ID, exitCode: NonNegativeInt } })
+const Deleted = define({ type: "pty.deleted", schema: { id: ID } })
+export const Event = { Created, Updated, Exited, Deleted, Definitions: inventory(Created, Updated, Exited, Deleted) }
+export const PtyEvent = Event

+ 66 - 0
packages/schema/src/question-v1.ts

@@ -0,0 +1,66 @@
+export * as QuestionV1 from "./question-v1"
+
+import { Schema } from "effect"
+import { define, inventory } from "./event"
+import { ascending } from "./identifier"
+import { withStatics } from "./schema"
+import { SessionID } from "./session-id"
+import { SessionV1 } from "./session-v1"
+
+export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe(
+  Schema.brand("QuestionID"),
+  withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "que_" + ascending()) })),
+)
+
+export const Option = Schema.Struct({
+  label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
+  description: Schema.String.annotate({ description: "Explanation of choice" }),
+}).annotate({ identifier: "QuestionOption" })
+
+const base = {
+  question: Schema.String.annotate({ description: "Complete question" }),
+  header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }),
+  options: Schema.Array(Option).annotate({ description: "Available choices" }),
+  multiple: Schema.optional(Schema.Boolean).annotate({ description: "Allow selecting multiple choices" }),
+}
+
+export const Info = Schema.Struct({
+  ...base,
+  custom: Schema.optional(Schema.Boolean).annotate({ description: "Allow typing a custom answer (default: true)" }),
+}).annotate({ identifier: "QuestionInfo" })
+export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionPrompt" })
+export const Tool = Schema.Struct({ messageID: SessionV1.MessageID, callID: Schema.String }).annotate({
+  identifier: "QuestionTool",
+})
+export const Request = Schema.Struct({
+  id: ID,
+  sessionID: SessionID,
+  questions: Schema.Array(Info).annotate({ description: "Questions to ask" }),
+  tool: Schema.optional(Tool),
+}).annotate({ identifier: "QuestionRequest" })
+export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" })
+export const Reply = Schema.Struct({
+  answers: Schema.Array(Answer).annotate({
+    description: "User answers in order of questions (each answer is an array of selected labels)",
+  }),
+}).annotate({ identifier: "QuestionReply" })
+export const Replied = Schema.Struct({
+  sessionID: SessionID,
+  requestID: ID,
+  answers: Schema.Array(Answer),
+}).annotate({
+  identifier: "QuestionReplied",
+})
+export const Rejected = Schema.Struct({ sessionID: SessionID, requestID: ID }).annotate({
+  identifier: "QuestionRejected",
+})
+
+const Asked = define({ type: "question.asked", schema: Request.fields })
+const RepliedEvent = define({ type: "question.replied", schema: Replied.fields })
+const RejectedEvent = define({ type: "question.rejected", schema: Rejected.fields })
+export const Event = {
+  Asked,
+  Replied: RepliedEvent,
+  Rejected: RejectedEvent,
+  Definitions: inventory(Asked, RepliedEvent, RejectedEvent),
+}

+ 79 - 0
packages/schema/src/question.ts

@@ -0,0 +1,79 @@
+export * as Question from "./question"
+
+import { Schema } from "effect"
+import { define, inventory } from "./event"
+import { ascending } from "./identifier"
+import { SessionID } from "./session-id"
+import { withStatics } from "./schema"
+
+export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe(
+  Schema.brand("QuestionV2.ID"),
+  withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "que_" + ascending()) })),
+)
+export type ID = typeof ID.Type
+
+export const Option = Schema.Struct({
+  label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
+  description: Schema.String.annotate({ description: "Explanation of choice" }),
+}).annotate({ identifier: "QuestionV2.Option" })
+export type Option = typeof Option.Type
+
+const base = {
+  question: Schema.String.annotate({ description: "Complete question" }),
+  header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }),
+  options: Schema.Array(Option).annotate({ description: "Available choices" }),
+  multiple: Schema.Boolean.pipe(Schema.optional).annotate({ description: "Allow selecting multiple choices" }),
+}
+
+export const Info = Schema.Struct({
+  ...base,
+  custom: Schema.Boolean.pipe(Schema.optional).annotate({
+    description: "Allow typing a custom answer (default: true)",
+  }),
+}).annotate({ identifier: "QuestionV2.Info" })
+export type Info = typeof Info.Type
+
+export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" })
+export type Prompt = typeof Prompt.Type
+
+export const Tool = Schema.Struct({
+  messageID: Schema.String,
+  callID: Schema.String,
+}).annotate({ identifier: "QuestionV2.Tool" })
+export type Tool = typeof Tool.Type
+
+export const Request = Schema.Struct({
+  id: ID,
+  sessionID: SessionID,
+  questions: Schema.Array(Info).annotate({ description: "Questions to ask" }),
+  tool: Tool.pipe(Schema.optional),
+}).annotate({ identifier: "QuestionV2.Request" })
+export type Request = typeof Request.Type
+
+export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" })
+export type Answer = typeof Answer.Type
+
+export const Reply = Schema.Struct({
+  answers: Schema.Array(Answer).annotate({
+    description: "User answers in order of questions (each answer is an array of selected labels)",
+  }),
+}).annotate({ identifier: "QuestionV2.Reply" })
+export type Reply = typeof Reply.Type
+
+const Asked = define({ type: "question.v2.asked", schema: Request.fields })
+const Replied = define({
+  type: "question.v2.replied",
+  schema: {
+    sessionID: SessionID,
+    requestID: ID,
+    answers: Schema.Array(Answer),
+  },
+})
+const Rejected = define({
+  type: "question.v2.rejected",
+  schema: {
+    sessionID: SessionID,
+    requestID: ID,
+  },
+})
+export const Event = { Asked, Replied, Rejected, Definitions: inventory(Asked, Replied, Rejected) }

+ 4 - 0
packages/schema/src/reference.ts

@@ -1,8 +1,12 @@
 export * as Reference from "./reference"
 
 import { Schema } from "effect"
+import { define, inventory } from "./event"
 import { AbsolutePath } from "./schema"
 
+const Updated = define({ type: "reference.updated", schema: {} })
+export const Event = { Updated, Definitions: inventory(Updated) }
+
 export interface LocalSource extends Schema.Schema.Type<typeof LocalSource> {}
 export const LocalSource = Schema.Struct({
   type: Schema.Literal("local"),

+ 8 - 0
packages/schema/src/server-event.ts

@@ -0,0 +1,8 @@
+export * as ServerEvent from "./server-event"
+
+import { Event } from "./event"
+
+export const Connected = Event.define({ type: "server.connected", schema: {} })
+export const Disposed = Event.define({ type: "global.disposed", schema: {} })
+
+export const Definitions = Event.inventory(Connected, Disposed)

+ 13 - 0
packages/schema/src/session-compaction-event.ts

@@ -0,0 +1,13 @@
+export * as SessionCompactionEvent from "./session-compaction-event"
+
+import { Event } from "./event"
+import { SessionID } from "./session-id"
+
+export const Compacted = Event.define({
+  type: "session.compacted",
+  schema: {
+    sessionID: SessionID,
+  },
+})
+
+export const Definitions = Event.inventory(Compacted)

+ 497 - 0
packages/schema/src/session-event.ts

@@ -0,0 +1,497 @@
+export * as SessionEvent from "./session-event"
+
+import { Schema } from "effect"
+import { Event } from "./event"
+import { ProviderMetadata, ToolContent } from "./llm"
+import { Delivery } from "./session-delivery"
+import { Model } from "./model"
+import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "./schema"
+import { FileAttachment, Prompt } from "./prompt"
+import { SessionID } from "./session-id"
+import { Location } from "./location"
+import { SessionMessageID } from "./session-message-id"
+import { SessionMessage } from "./session-message"
+
+export { FileAttachment }
+
+export const Source = Schema.Struct({
+  start: NonNegativeInt,
+  end: NonNegativeInt,
+  text: Schema.String,
+}).annotate({
+  identifier: "session.next.event.source",
+})
+export type Source = typeof Source.Type
+
+const Base = {
+  timestamp: DateTimeUtcFromMillis,
+  sessionID: SessionID,
+}
+const PromptFields = {
+  ...Base,
+  messageID: SessionMessageID.ID,
+  prompt: Prompt,
+  delivery: Delivery,
+}
+
+const options = {
+  durable: {
+    aggregate: "sessionID",
+    version: 1,
+  },
+} as const
+const stepSettlementOptions = {
+  durable: {
+    aggregate: "sessionID",
+    version: 2,
+  },
+} as const
+
+export const UnknownError = SessionMessage.UnknownError
+export type UnknownError = SessionMessage.UnknownError
+
+export const AgentSwitched = Event.define({
+  type: "session.next.agent.switched",
+  ...options,
+  schema: {
+    ...Base,
+    messageID: SessionMessageID.ID,
+    agent: Schema.String,
+  },
+})
+export type AgentSwitched = typeof AgentSwitched.Type
+
+export const ModelSwitched = Event.define({
+  type: "session.next.model.switched",
+  ...options,
+  schema: {
+    ...Base,
+    messageID: SessionMessageID.ID,
+    model: Model.Ref,
+  },
+})
+export type ModelSwitched = typeof ModelSwitched.Type
+
+export const Moved = Event.define({
+  type: "session.next.moved",
+  ...options,
+  schema: {
+    ...Base,
+    location: Location.Ref,
+    subdirectory: RelativePath.pipe(Schema.optional),
+  },
+})
+export type Moved = typeof Moved.Type
+
+export const Prompted = Event.define({
+  type: "session.next.prompted",
+  ...options,
+  schema: PromptFields,
+})
+export type Prompted = typeof Prompted.Type
+
+export const PromptAdmitted = Event.define({
+  type: "session.next.prompt.admitted",
+  ...options,
+  schema: PromptFields,
+})
+export type PromptAdmitted = typeof PromptAdmitted.Type
+
+export const ContextUpdated = Event.define({
+  type: "session.next.context.updated",
+  ...options,
+  schema: {
+    ...Base,
+    messageID: SessionMessageID.ID,
+    text: Schema.String,
+  },
+})
+export type ContextUpdated = typeof ContextUpdated.Type
+
+export const Synthetic = Event.define({
+  type: "session.next.synthetic",
+  ...options,
+  schema: {
+    ...Base,
+    messageID: SessionMessageID.ID,
+    text: Schema.String,
+  },
+})
+export type Synthetic = typeof Synthetic.Type
+
+export namespace Shell {
+  export const Started = Event.define({
+    type: "session.next.shell.started",
+    ...options,
+    schema: {
+      ...Base,
+      messageID: SessionMessageID.ID,
+      callID: Schema.String,
+      command: Schema.String,
+    },
+  })
+  export type Started = typeof Started.Type
+
+  export const Ended = Event.define({
+    type: "session.next.shell.ended",
+    ...options,
+    schema: {
+      ...Base,
+      callID: Schema.String,
+      output: Schema.String,
+    },
+  })
+  export type Ended = typeof Ended.Type
+}
+
+export namespace Step {
+  export const Started = Event.define({
+    type: "session.next.step.started",
+    ...options,
+    schema: {
+      ...Base,
+      assistantMessageID: SessionMessageID.ID,
+      agent: Schema.String,
+      model: Model.Ref,
+      snapshot: Schema.String.pipe(Schema.optional),
+    },
+  })
+  export type Started = typeof Started.Type
+
+  export const Ended = Event.define({
+    type: "session.next.step.ended",
+    ...stepSettlementOptions,
+    schema: {
+      ...Base,
+      assistantMessageID: SessionMessageID.ID,
+      finish: Schema.String,
+      cost: Schema.Finite,
+      tokens: Schema.Struct({
+        input: Schema.Finite,
+        output: Schema.Finite,
+        reasoning: Schema.Finite,
+        cache: Schema.Struct({
+          read: Schema.Finite,
+          write: Schema.Finite,
+        }),
+      }),
+      snapshot: Schema.String.pipe(Schema.optional),
+    },
+  })
+  export type Ended = typeof Ended.Type
+
+  export const Failed = Event.define({
+    type: "session.next.step.failed",
+    ...stepSettlementOptions,
+    schema: {
+      ...Base,
+      assistantMessageID: SessionMessageID.ID,
+      error: UnknownError,
+    },
+  })
+  export type Failed = typeof Failed.Type
+}
+
+export namespace Text {
+  export const Started = Event.define({
+    type: "session.next.text.started",
+    ...options,
+    schema: {
+      ...Base,
+      assistantMessageID: SessionMessageID.ID,
+      textID: Schema.String,
+    },
+  })
+  export type Started = typeof Started.Type
+
+  // Stream fragments are live-only; Text.Ended is the replayable full-value boundary.
+  export const Delta = Event.define({
+    type: "session.next.text.delta",
+    schema: {
+      ...Base,
+      assistantMessageID: SessionMessageID.ID,
+      textID: Schema.String,
+      delta: Schema.String,
+    },
+  })
+  export type Delta = typeof Delta.Type
+
+  export const Ended = Event.define({
+    type: "session.next.text.ended",
+    ...options,
+    schema: {
+      ...Base,
+      assistantMessageID: SessionMessageID.ID,
+      textID: Schema.String,
+      text: Schema.String,
+    },
+  })
+  export type Ended = typeof Ended.Type
+}
+
+export namespace Reasoning {
+  export const Started = Event.define({
+    type: "session.next.reasoning.started",
+    ...options,
+    schema: {
+      ...Base,
+      assistantMessageID: SessionMessageID.ID,
+      reasoningID: Schema.String,
+      providerMetadata: ProviderMetadata.pipe(Schema.optional),
+    },
+  })
+  export type Started = typeof Started.Type
+
+  // Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary.
+  export const Delta = Event.define({
+    type: "session.next.reasoning.delta",
+    schema: {
+      ...Base,
+      assistantMessageID: SessionMessageID.ID,
+      reasoningID: Schema.String,
+      delta: Schema.String,
+    },
+  })
+  export type Delta = typeof Delta.Type
+
+  export const Ended = Event.define({
+    type: "session.next.reasoning.ended",
+    ...options,
+    schema: {
+      ...Base,
+      assistantMessageID: SessionMessageID.ID,
+      reasoningID: Schema.String,
+      text: Schema.String,
+      providerMetadata: ProviderMetadata.pipe(Schema.optional),
+    },
+  })
+  export type Ended = typeof Ended.Type
+}
+
+export namespace Tool {
+  const ToolBase = {
+    ...Base,
+    assistantMessageID: SessionMessageID.ID,
+    callID: Schema.String,
+  }
+
+  export namespace Input {
+    export const Started = Event.define({
+      type: "session.next.tool.input.started",
+      ...options,
+      schema: {
+        ...ToolBase,
+        name: Schema.String,
+      },
+    })
+    export type Started = typeof Started.Type
+
+    // Stream fragments are live-only; Input.Ended is the replayable raw-input boundary.
+    export const Delta = Event.define({
+      type: "session.next.tool.input.delta",
+      schema: {
+        ...ToolBase,
+        delta: Schema.String,
+      },
+    })
+    export type Delta = typeof Delta.Type
+
+    export const Ended = Event.define({
+      type: "session.next.tool.input.ended",
+      ...options,
+      schema: {
+        ...ToolBase,
+        text: Schema.String,
+      },
+    })
+    export type Ended = typeof Ended.Type
+  }
+
+  export const Called = Event.define({
+    type: "session.next.tool.called",
+    ...options,
+    schema: {
+      ...ToolBase,
+      tool: Schema.String,
+      input: Schema.Record(Schema.String, Schema.Unknown),
+      provider: Schema.Struct({
+        executed: Schema.Boolean,
+        metadata: ProviderMetadata.pipe(Schema.optional),
+      }),
+    },
+  })
+  export type Called = typeof Called.Type
+
+  /**
+   * Replayable bounded running-tool state. Tools should checkpoint semantic
+   * transitions or at a bounded cadence, not persist every stdout/stderr chunk.
+   */
+  export const Progress = Event.define({
+    type: "session.next.tool.progress",
+    ...options,
+    schema: {
+      ...ToolBase,
+      structured: Schema.Record(Schema.String, Schema.Any),
+      content: Schema.Array(ToolContent),
+    },
+  })
+  export type Progress = typeof Progress.Type
+
+  export const Success = Event.define({
+    type: "session.next.tool.success",
+    ...options,
+    schema: {
+      ...ToolBase,
+      structured: Schema.Record(Schema.String, Schema.Any),
+      content: Schema.Array(ToolContent),
+      outputPaths: Schema.Array(Schema.String).pipe(Schema.optional),
+      result: Schema.Unknown.pipe(Schema.optional),
+      provider: Schema.Struct({
+        executed: Schema.Boolean,
+        metadata: ProviderMetadata.pipe(Schema.optional),
+      }),
+    },
+  })
+  export type Success = typeof Success.Type
+
+  export const Failed = Event.define({
+    type: "session.next.tool.failed",
+    ...options,
+    schema: {
+      ...ToolBase,
+      error: UnknownError,
+      result: Schema.Unknown.pipe(Schema.optional),
+      provider: Schema.Struct({
+        executed: Schema.Boolean,
+        metadata: ProviderMetadata.pipe(Schema.optional),
+      }),
+    },
+  })
+  export type Failed = typeof Failed.Type
+}
+
+export const RetryError = Schema.Struct({
+  message: Schema.String,
+  statusCode: Schema.Finite.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 = typeof RetryError.Type
+
+export const Retried = Event.define({
+  type: "session.next.retried",
+  ...options,
+  schema: {
+    ...Base,
+    attempt: Schema.Finite,
+    error: RetryError,
+  },
+})
+export type Retried = typeof Retried.Type
+
+export namespace Compaction {
+  export const Started = Event.define({
+    type: "session.next.compaction.started",
+    ...options,
+    schema: {
+      ...Base,
+      messageID: SessionMessageID.ID,
+      reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
+    },
+  })
+  export type Started = typeof Started.Type
+
+  export const Delta = Event.define({
+    type: "session.next.compaction.delta",
+    schema: {
+      ...Base,
+      messageID: SessionMessageID.ID,
+      text: Schema.String,
+    },
+  })
+  export type Delta = typeof Delta.Type
+
+  export const Ended = Event.define({
+    type: "session.next.compaction.ended",
+    ...options,
+    schema: {
+      ...Base,
+      messageID: SessionMessageID.ID,
+      reason: Started.data.fields.reason,
+      text: Schema.String,
+      recent: Schema.String,
+    },
+  })
+  export type Ended = typeof Ended.Type
+}
+
+export const DurableDefinitions = Event.inventory(
+  AgentSwitched,
+  ModelSwitched,
+  Moved,
+  Prompted,
+  PromptAdmitted,
+  ContextUpdated,
+  Synthetic,
+  Shell.Started,
+  Shell.Ended,
+  Step.Started,
+  Step.Ended,
+  Step.Failed,
+  Text.Started,
+  Text.Ended,
+  Tool.Input.Started,
+  Tool.Input.Ended,
+  Tool.Called,
+  Tool.Progress,
+  Tool.Success,
+  Tool.Failed,
+  Reasoning.Started,
+  Reasoning.Ended,
+  Retried,
+  Compaction.Started,
+  Compaction.Ended,
+)
+
+export const Definitions = Event.inventory(
+  AgentSwitched,
+  ModelSwitched,
+  Moved,
+  Prompted,
+  PromptAdmitted,
+  ContextUpdated,
+  Synthetic,
+  Shell.Started,
+  Shell.Ended,
+  Step.Started,
+  Step.Ended,
+  Step.Failed,
+  Text.Started,
+  Text.Delta,
+  Text.Ended,
+  Reasoning.Started,
+  Reasoning.Delta,
+  Reasoning.Ended,
+  Tool.Input.Started,
+  Tool.Input.Delta,
+  Tool.Input.Ended,
+  Tool.Called,
+  Tool.Progress,
+  Tool.Success,
+  Tool.Failed,
+  Retried,
+  Compaction.Started,
+  Compaction.Delta,
+  Compaction.Ended,
+)
+
+export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
+export type DurableEvent = typeof Durable.Type
+
+export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
+export type Event = typeof All.Type
+export type Type = Event["type"]

+ 2 - 4
packages/schema/src/session-id.ts

@@ -1,10 +1,8 @@
-export * as SessionID from "./session-id"
-
 import { Schema } from "effect"
 import { descending } from "./identifier"
 import { withStatics } from "./schema"
 
-export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
+export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
   Schema.brand("SessionID"),
   withStatics((schema) => {
     const create = () => schema.make("ses_" + descending())
@@ -14,4 +12,4 @@ export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
     }
   }),
 )
-export type ID = typeof ID.Type
+export type SessionID = typeof SessionID.Type

+ 1 - 1
packages/schema/src/session-input.ts

@@ -14,7 +14,7 @@ export interface Admitted extends Schema.Schema.Type<typeof Admitted> {}
 export const Admitted = Schema.Struct({
   admittedSeq: NonNegativeInt,
   id: SessionMessageID.ID,
-  sessionID: SessionID.ID,
+  sessionID: SessionID,
   prompt: Prompt,
   delivery: Delivery,
   timeCreated: DateTimeUtcFromMillis,

+ 1 - 1
packages/schema/src/session-message.ts

@@ -49,7 +49,7 @@ export const User = Schema.Struct({
 export interface Synthetic extends Schema.Schema.Type<typeof Synthetic> {}
 export const Synthetic = Schema.Struct({
   ...Base,
-  sessionID: SessionID.ID,
+  sessionID: SessionID,
   text: Schema.String,
   type: Schema.Literal("synthetic"),
 }).annotate({ identifier: "Session.Message.Synthetic" })

+ 50 - 0
packages/schema/src/session-status-event.ts

@@ -0,0 +1,50 @@
+export * as SessionStatusEvent from "./session-status-event"
+
+import { Schema } from "effect"
+import { Event } from "./event"
+import { NonNegativeInt } from "./schema"
+import { SessionID } from "./session-id"
+
+export const Info = Schema.Union([
+  Schema.Struct({
+    type: Schema.Literal("idle"),
+  }),
+  Schema.Struct({
+    type: Schema.Literal("retry"),
+    attempt: NonNegativeInt,
+    message: Schema.String,
+    action: Schema.optional(
+      Schema.Struct({
+        reason: Schema.String,
+        provider: Schema.String,
+        title: Schema.String,
+        message: Schema.String,
+        label: Schema.String,
+        link: Schema.optional(Schema.String),
+      }),
+    ),
+    next: NonNegativeInt,
+  }),
+  Schema.Struct({
+    type: Schema.Literal("busy"),
+  }),
+]).annotate({ identifier: "SessionStatus" })
+export type Info = Schema.Schema.Type<typeof Info>
+
+export const Status = Event.define({
+  type: "session.status",
+  schema: {
+    sessionID: SessionID,
+    status: Info,
+  },
+})
+
+// deprecated
+export const Idle = Event.define({
+  type: "session.idle",
+  schema: {
+    sessionID: SessionID,
+  },
+})
+
+export const Definitions = Event.inventory(Status, Idle)

+ 24 - 0
packages/schema/src/session-todo.ts

@@ -0,0 +1,24 @@
+export * as SessionTodo from "./session-todo"
+
+import { Schema } from "effect"
+import { define, inventory } from "./event"
+import { SessionID } from "./session-id"
+
+export const Info = Schema.Struct({
+  content: Schema.String.annotate({ description: "Brief description of the task" }),
+  status: Schema.String.annotate({
+    description: "Current status of the task: pending, in_progress, completed, cancelled",
+  }),
+  priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
+}).annotate({ identifier: "Todo" })
+export type Info = typeof Info.Type
+export const SessionTodoInfo = Info
+
+const Updated = define({
+  type: "todo.updated",
+  schema: {
+    sessionID: SessionID,
+    todos: Schema.Array(Info),
+  },
+})
+export const Event = { Updated, Definitions: inventory(Updated) }

+ 676 - 0
packages/schema/src/session-v1.ts

@@ -0,0 +1,676 @@
+export * as SessionV1 from "./session-v1"
+
+import { Effect, Schema, Types } from "effect"
+import { define, inventory } from "./event"
+import { FileDiff } from "./file-diff"
+import { PermissionV1 } from "./permission-v1"
+import { Project } from "./project"
+import { Provider } from "./provider"
+import { Model } from "./model"
+import { NonNegativeInt, optionalOmitUndefined, withStatics } from "./schema"
+import { ascending } from "./identifier"
+import { SessionID } from "./session-id"
+import { WorkspaceID } from "./workspace-id"
+
+const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))
+
+export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
+  Schema.brand("MessageID"),
+  withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + ascending()) })),
+)
+export type MessageID = typeof MessageID.Type
+
+export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
+  Schema.brand("PartID"),
+  withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + ascending()) })),
+)
+export type PartID = typeof PartID.Type
+
+const namedError = <Name extends string, Fields extends Schema.Struct.Fields>(name: Name, fields: Fields) => {
+  const schema = Schema.Struct({ name: Schema.Literal(name), data: Schema.Struct(fields) }).annotate({
+    identifier: name,
+  })
+  return { Schema: schema, EffectSchema: schema }
+}
+
+export const OutputLengthError = namedError("MessageOutputLengthError", {})
+
+export const AuthError = namedError("ProviderAuthError", {
+  providerID: Schema.String,
+  message: Schema.String,
+})
+
+export const AbortedError = namedError("MessageAbortedError", { message: Schema.String })
+export const StructuredOutputError = namedError("StructuredOutputError", {
+  message: Schema.String,
+  retries: NonNegativeInt,
+})
+export const APIError = namedError("APIError", {
+  message: Schema.String,
+  statusCode: Schema.optional(NonNegativeInt),
+  isRetryable: Schema.Boolean,
+  responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+  responseBody: Schema.optional(Schema.String),
+  metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+})
+export type APIError = Schema.Schema.Type<typeof APIError.Schema>
+export const ContextOverflowError = namedError("ContextOverflowError", {
+  message: Schema.String,
+  responseBody: Schema.optional(Schema.String),
+})
+export const ContentFilterError = namedError("ContentFilterError", {
+  message: Schema.String,
+})
+
+export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({
+  type: Schema.Literal("text"),
+}) {}
+
+export class OutputFormatJsonSchema extends Schema.Class<OutputFormatJsonSchema>("OutputFormatJsonSchema")({
+  type: Schema.Literal("json_schema"),
+  schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }),
+  retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
+}) {}
+
+export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({
+  discriminator: "type",
+  identifier: "OutputFormat",
+})
+export type OutputFormat = Schema.Schema.Type<typeof Format>
+
+const partBase = {
+  id: PartID,
+  sessionID: SessionID,
+  messageID: MessageID,
+}
+
+export const SnapshotPart = Schema.Struct({
+  ...partBase,
+  type: Schema.Literal("snapshot"),
+  snapshot: Schema.String,
+}).annotate({ identifier: "SnapshotPart" })
+export type SnapshotPart = Types.DeepMutable<Schema.Schema.Type<typeof SnapshotPart>>
+
+export const PatchPart = Schema.Struct({
+  ...partBase,
+  type: Schema.Literal("patch"),
+  hash: Schema.String,
+  files: Schema.Array(Schema.String),
+}).annotate({ identifier: "PatchPart" })
+export type PatchPart = Types.DeepMutable<Schema.Schema.Type<typeof PatchPart>>
+
+export const TextPart = Schema.Struct({
+  ...partBase,
+  type: Schema.Literal("text"),
+  text: Schema.String,
+  synthetic: Schema.optional(Schema.Boolean),
+  ignored: Schema.optional(Schema.Boolean),
+  time: Schema.optional(
+    Schema.Struct({
+      start: NonNegativeInt,
+      end: Schema.optional(NonNegativeInt),
+    }),
+  ),
+  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+}).annotate({ identifier: "TextPart" })
+export type TextPart = Types.DeepMutable<Schema.Schema.Type<typeof TextPart>>
+
+export const ReasoningPart = Schema.Struct({
+  ...partBase,
+  type: Schema.Literal("reasoning"),
+  text: Schema.String,
+  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+  time: Schema.Struct({
+    start: NonNegativeInt,
+    end: Schema.optional(NonNegativeInt),
+  }),
+}).annotate({ identifier: "ReasoningPart" })
+export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof ReasoningPart>>
+
+const filePartSourceBase = {
+  text: Schema.Struct({
+    value: Schema.String,
+    start: Schema.Finite,
+    end: Schema.Finite,
+  }).annotate({ identifier: "FilePartSourceText" }),
+}
+
+export const Range = Schema.Struct({
+  start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }),
+  end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }),
+}).annotate({ identifier: "Range" })
+export type Range = typeof Range.Type
+
+export const FileSource = Schema.Struct({
+  ...filePartSourceBase,
+  type: Schema.Literal("file"),
+  path: Schema.String,
+}).annotate({ identifier: "FileSource" })
+
+export const SymbolSource = Schema.Struct({
+  ...filePartSourceBase,
+  type: Schema.Literal("symbol"),
+  path: Schema.String,
+  range: Range,
+  name: Schema.String,
+  kind: NonNegativeInt,
+}).annotate({ identifier: "SymbolSource" })
+
+export const ResourceSource = Schema.Struct({
+  ...filePartSourceBase,
+  type: Schema.Literal("resource"),
+  clientName: Schema.String,
+  uri: Schema.String,
+}).annotate({ identifier: "ResourceSource" })
+
+export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({
+  discriminator: "type",
+  identifier: "FilePartSource",
+})
+
+export const FilePart = Schema.Struct({
+  ...partBase,
+  type: Schema.Literal("file"),
+  mime: Schema.String,
+  filename: Schema.optional(Schema.String),
+  url: Schema.String,
+  source: Schema.optional(FilePartSource),
+}).annotate({ identifier: "FilePart" })
+export type FilePart = Types.DeepMutable<Schema.Schema.Type<typeof FilePart>>
+
+export const AgentPart = Schema.Struct({
+  ...partBase,
+  type: Schema.Literal("agent"),
+  name: Schema.String,
+  source: Schema.optional(
+    Schema.Struct({
+      value: Schema.String,
+      start: NonNegativeInt,
+      end: NonNegativeInt,
+    }),
+  ),
+}).annotate({ identifier: "AgentPart" })
+export type AgentPart = Types.DeepMutable<Schema.Schema.Type<typeof AgentPart>>
+
+export const CompactionPart = Schema.Struct({
+  ...partBase,
+  type: Schema.Literal("compaction"),
+  auto: Schema.Boolean,
+  overflow: Schema.optional(Schema.Boolean),
+  tail_start_id: Schema.optional(MessageID),
+}).annotate({ identifier: "CompactionPart" })
+export type CompactionPart = Types.DeepMutable<Schema.Schema.Type<typeof CompactionPart>>
+
+export const SubtaskPart = Schema.Struct({
+  ...partBase,
+  type: Schema.Literal("subtask"),
+  prompt: Schema.String,
+  description: Schema.String,
+  agent: Schema.String,
+  model: Schema.optional(
+    Schema.Struct({
+      providerID: Provider.ID,
+      modelID: Model.ID,
+    }),
+  ),
+  command: Schema.optional(Schema.String),
+}).annotate({ identifier: "SubtaskPart" })
+export type SubtaskPart = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPart>>
+
+export const RetryPart = Schema.Struct({
+  ...partBase,
+  type: Schema.Literal("retry"),
+  attempt: NonNegativeInt,
+  error: APIError.EffectSchema,
+  time: Schema.Struct({
+    created: NonNegativeInt,
+  }),
+}).annotate({ identifier: "RetryPart" })
+export type RetryPart = Omit<Types.DeepMutable<Schema.Schema.Type<typeof RetryPart>>, "error"> & {
+  error: APIError
+}
+
+export const StepStartPart = Schema.Struct({
+  ...partBase,
+  type: Schema.Literal("step-start"),
+  snapshot: Schema.optional(Schema.String),
+}).annotate({ identifier: "StepStartPart" })
+export type StepStartPart = Types.DeepMutable<Schema.Schema.Type<typeof StepStartPart>>
+
+export const StepFinishPart = Schema.Struct({
+  ...partBase,
+  type: Schema.Literal("step-finish"),
+  reason: Schema.String,
+  snapshot: Schema.optional(Schema.String),
+  cost: Schema.Finite,
+  tokens: Schema.Struct({
+    total: Schema.optional(Schema.Finite),
+    input: Schema.Finite,
+    output: Schema.Finite,
+    reasoning: Schema.Finite,
+    cache: Schema.Struct({
+      read: Schema.Finite,
+      write: Schema.Finite,
+    }),
+  }),
+}).annotate({ identifier: "StepFinishPart" })
+export type StepFinishPart = Types.DeepMutable<Schema.Schema.Type<typeof StepFinishPart>>
+
+export const ToolStatePending = Schema.Struct({
+  status: Schema.Literal("pending"),
+  input: Schema.Record(Schema.String, Schema.Any),
+  raw: Schema.String,
+}).annotate({ identifier: "ToolStatePending" })
+export type ToolStatePending = Types.DeepMutable<Schema.Schema.Type<typeof ToolStatePending>>
+
+export const ToolStateRunning = Schema.Struct({
+  status: Schema.Literal("running"),
+  input: Schema.Record(Schema.String, Schema.Any),
+  title: Schema.optional(Schema.String),
+  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+  time: Schema.Struct({
+    start: NonNegativeInt,
+  }),
+}).annotate({ identifier: "ToolStateRunning" })
+export type ToolStateRunning = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateRunning>>
+
+export const ToolStateCompleted = Schema.Struct({
+  status: Schema.Literal("completed"),
+  input: Schema.Record(Schema.String, Schema.Any),
+  output: Schema.String,
+  title: Schema.String,
+  metadata: Schema.Record(Schema.String, Schema.Any),
+  time: Schema.Struct({
+    start: NonNegativeInt,
+    end: NonNegativeInt,
+    compacted: Schema.optional(NonNegativeInt),
+  }),
+  attachments: Schema.optional(Schema.Array(FilePart)),
+}).annotate({ identifier: "ToolStateCompleted" })
+export type ToolStateCompleted = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateCompleted>>
+
+export const ToolStateError = Schema.Struct({
+  status: Schema.Literal("error"),
+  input: Schema.Record(Schema.String, Schema.Any),
+  error: Schema.String,
+  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+  time: Schema.Struct({
+    start: NonNegativeInt,
+    end: NonNegativeInt,
+  }),
+}).annotate({ identifier: "ToolStateError" })
+export type ToolStateError = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateError>>
+
+export const ToolState = Schema.Union([
+  ToolStatePending,
+  ToolStateRunning,
+  ToolStateCompleted,
+  ToolStateError,
+]).annotate({
+  discriminator: "status",
+  identifier: "ToolState",
+})
+export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
+
+export const ToolPart = Schema.Struct({
+  ...partBase,
+  type: Schema.Literal("tool"),
+  callID: Schema.String,
+  tool: Schema.String,
+  state: ToolState,
+  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+}).annotate({ identifier: "ToolPart" })
+export type ToolPart = Omit<Types.DeepMutable<Schema.Schema.Type<typeof ToolPart>>, "state"> & {
+  state: ToolState
+}
+
+const messageBase = {
+  id: MessageID,
+  sessionID: partBase.sessionID,
+}
+
+export const User = Schema.Struct({
+  ...messageBase,
+  role: Schema.Literal("user"),
+  time: Schema.Struct({
+    created: Timestamp,
+  }),
+  format: Schema.optional(Format),
+  summary: Schema.optional(
+    Schema.Struct({
+      title: Schema.optional(Schema.String),
+      body: Schema.optional(Schema.String),
+      diffs: Schema.Array(FileDiff.Info),
+    }),
+  ),
+  agent: Schema.String,
+  model: Schema.Struct({
+    providerID: Provider.ID,
+    modelID: Model.ID,
+    variant: Schema.optional(Schema.String),
+  }),
+  system: Schema.optional(Schema.String),
+  tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
+}).annotate({ identifier: "UserMessage" })
+export type User = Types.DeepMutable<Schema.Schema.Type<typeof User>>
+
+export const Part = Schema.Union([
+  TextPart,
+  SubtaskPart,
+  ReasoningPart,
+  FilePart,
+  ToolPart,
+  StepStartPart,
+  StepFinishPart,
+  SnapshotPart,
+  PatchPart,
+  AgentPart,
+  RetryPart,
+  CompactionPart,
+]).annotate({ discriminator: "type", identifier: "Part" })
+export type Part =
+  | TextPart
+  | SubtaskPart
+  | ReasoningPart
+  | FilePart
+  | ToolPart
+  | StepStartPart
+  | StepFinishPart
+  | SnapshotPart
+  | PatchPart
+  | AgentPart
+  | RetryPart
+  | CompactionPart
+
+const AssistantErrorSchema = Schema.Union([
+  AuthError.EffectSchema,
+  namedError("UnknownError", { message: Schema.String, ref: Schema.optional(Schema.String) }).EffectSchema,
+  OutputLengthError.EffectSchema,
+  AbortedError.EffectSchema,
+  StructuredOutputError.EffectSchema,
+  ContextOverflowError.EffectSchema,
+  ContentFilterError.EffectSchema,
+  APIError.EffectSchema,
+]).annotate({ discriminator: "name" })
+type AssistantError = Schema.Schema.Type<typeof AssistantErrorSchema>
+
+export const TextPartInput = Schema.Struct({
+  id: Schema.optional(PartID),
+  type: Schema.Literal("text"),
+  text: Schema.String,
+  synthetic: Schema.optional(Schema.Boolean),
+  ignored: Schema.optional(Schema.Boolean),
+  time: Schema.optional(
+    Schema.Struct({
+      start: NonNegativeInt,
+      end: Schema.optional(NonNegativeInt),
+    }),
+  ),
+  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
+}).annotate({ identifier: "TextPartInput" })
+export type TextPartInput = Types.DeepMutable<Schema.Schema.Type<typeof TextPartInput>>
+
+export const FilePartInput = Schema.Struct({
+  id: Schema.optional(PartID),
+  type: Schema.Literal("file"),
+  mime: Schema.String,
+  filename: Schema.optional(Schema.String),
+  url: Schema.String,
+  source: Schema.optional(FilePartSource),
+}).annotate({ identifier: "FilePartInput" })
+export type FilePartInput = Types.DeepMutable<Schema.Schema.Type<typeof FilePartInput>>
+
+export const AgentPartInput = Schema.Struct({
+  id: Schema.optional(PartID),
+  type: Schema.Literal("agent"),
+  name: Schema.String,
+  source: Schema.optional(
+    Schema.Struct({
+      value: Schema.String,
+      start: NonNegativeInt,
+      end: NonNegativeInt,
+    }),
+  ),
+}).annotate({ identifier: "AgentPartInput" })
+export type AgentPartInput = Types.DeepMutable<Schema.Schema.Type<typeof AgentPartInput>>
+
+export const SubtaskPartInput = Schema.Struct({
+  id: Schema.optional(PartID),
+  type: Schema.Literal("subtask"),
+  prompt: Schema.String,
+  description: Schema.String,
+  agent: Schema.String,
+  model: Schema.optional(
+    Schema.Struct({
+      providerID: Provider.ID,
+      modelID: Model.ID,
+    }),
+  ),
+  command: Schema.optional(Schema.String),
+}).annotate({ identifier: "SubtaskPartInput" })
+export type SubtaskPartInput = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPartInput>>
+
+export const Assistant = Schema.Struct({
+  ...messageBase,
+  role: Schema.Literal("assistant"),
+  time: Schema.Struct({
+    created: NonNegativeInt,
+    completed: Schema.optional(NonNegativeInt),
+  }),
+  error: Schema.optional(AssistantErrorSchema),
+  parentID: MessageID,
+  modelID: Model.ID,
+  providerID: Provider.ID,
+  mode: Schema.String,
+  agent: Schema.String,
+  path: Schema.Struct({
+    cwd: Schema.String,
+    root: Schema.String,
+  }),
+  summary: Schema.optional(Schema.Boolean),
+  cost: Schema.Finite,
+  tokens: Schema.Struct({
+    total: Schema.optional(Schema.Finite),
+    input: Schema.Finite,
+    output: Schema.Finite,
+    reasoning: Schema.Finite,
+    cache: Schema.Struct({
+      read: Schema.Finite,
+      write: Schema.Finite,
+    }),
+  }),
+  structured: Schema.optional(Schema.Any),
+  variant: Schema.optional(Schema.String),
+  finish: Schema.optional(Schema.String),
+}).annotate({ identifier: "AssistantMessage" })
+export type Assistant = Omit<Types.DeepMutable<Schema.Schema.Type<typeof Assistant>>, "error"> & {
+  error?: AssistantError
+}
+
+export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" })
+export type Info = User | Assistant
+
+export const WithParts = Schema.Struct({
+  info: Info,
+  parts: Schema.Array(Part),
+})
+export type WithParts = {
+  info: Info
+  parts: Part[]
+}
+
+const options = {
+  durable: {
+    aggregate: "sessionID",
+    version: 1,
+  },
+} as const
+
+const SessionSummary = Schema.Struct({
+  additions: Schema.Finite,
+  deletions: Schema.Finite,
+  files: Schema.Finite,
+  diffs: optionalOmitUndefined(Schema.Array(FileDiff.Info)),
+})
+
+const SessionTokens = Schema.Struct({
+  input: Schema.Finite,
+  output: Schema.Finite,
+  reasoning: Schema.Finite,
+  cache: Schema.Struct({
+    read: Schema.Finite,
+    write: Schema.Finite,
+  }),
+})
+
+const SessionShare = Schema.Struct({
+  url: Schema.String,
+})
+
+const SessionRevert = Schema.Struct({
+  messageID: MessageID,
+  partID: optionalOmitUndefined(PartID),
+  snapshot: optionalOmitUndefined(Schema.String),
+  diff: optionalOmitUndefined(Schema.String),
+})
+
+const SessionModel = Schema.Struct({
+  id: Model.ID,
+  providerID: Provider.ID,
+  variant: optionalOmitUndefined(Schema.String),
+})
+
+export const SessionInfo = Schema.Struct({
+  id: SessionID,
+  slug: Schema.String,
+  projectID: Project.ID,
+  workspaceID: optionalOmitUndefined(WorkspaceID),
+  directory: Schema.String,
+  path: optionalOmitUndefined(Schema.String),
+  parentID: optionalOmitUndefined(SessionID),
+  summary: optionalOmitUndefined(SessionSummary),
+  cost: optionalOmitUndefined(Schema.Finite),
+  tokens: optionalOmitUndefined(SessionTokens),
+  share: optionalOmitUndefined(SessionShare),
+  title: Schema.String,
+  agent: optionalOmitUndefined(Schema.String),
+  model: optionalOmitUndefined(SessionModel),
+  version: Schema.String,
+  metadata: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Any)),
+  time: Schema.Struct({
+    created: NonNegativeInt,
+    updated: NonNegativeInt,
+    compacting: optionalOmitUndefined(NonNegativeInt),
+    archived: optionalOmitUndefined(Schema.Finite),
+  }),
+  permission: optionalOmitUndefined(PermissionV1.Ruleset),
+  revert: optionalOmitUndefined(SessionRevert),
+}).annotate({ identifier: "Session" })
+export type SessionInfo = typeof SessionInfo.Type
+
+const events = {
+  Created: define({
+    type: "session.created",
+    ...options,
+    schema: {
+      sessionID: SessionID,
+      info: SessionInfo,
+    },
+  }),
+  Updated: define({
+    type: "session.updated",
+    ...options,
+    schema: {
+      sessionID: SessionID,
+      info: SessionInfo,
+    },
+  }),
+  Deleted: define({
+    type: "session.deleted",
+    ...options,
+    schema: {
+      sessionID: SessionID,
+      info: SessionInfo,
+    },
+  }),
+  MessageUpdated: define({
+    type: "message.updated",
+    ...options,
+    schema: {
+      sessionID: SessionID,
+      info: Info,
+    },
+  }),
+  MessageRemoved: define({
+    type: "message.removed",
+    ...options,
+    schema: {
+      sessionID: SessionID,
+      messageID: MessageID,
+    },
+  }),
+  PartUpdated: define({
+    type: "message.part.updated",
+    ...options,
+    schema: {
+      sessionID: SessionID,
+      part: Part,
+      time: Schema.Finite,
+    },
+  }),
+  PartRemoved: define({
+    type: "message.part.removed",
+    ...options,
+    schema: {
+      sessionID: SessionID,
+      messageID: MessageID,
+      partID: PartID,
+    },
+  }),
+}
+
+export const PartDelta = define({
+  type: "message.part.delta",
+  schema: {
+    sessionID: SessionID,
+    messageID: MessageID,
+    partID: PartID,
+    field: Schema.String,
+    delta: Schema.String,
+  },
+})
+
+export const Diff = define({
+  type: "session.diff",
+  schema: {
+    sessionID: SessionID,
+    diff: Schema.Array(FileDiff.Info),
+  },
+})
+
+export const Error = define({
+  type: "session.error",
+  schema: {
+    sessionID: Schema.optional(SessionID),
+    error: Assistant.fields.error,
+  },
+})
+
+export const Event = {
+  ...events,
+  PartDelta,
+  Diff,
+  Error,
+  Definitions: inventory(
+    events.Created,
+    events.Updated,
+    events.Deleted,
+    events.MessageUpdated,
+    events.MessageRemoved,
+    events.PartUpdated,
+    events.PartRemoved,
+    PartDelta,
+    Diff,
+    Error,
+  ),
+}

+ 5 - 2
packages/schema/src/session.ts

@@ -6,10 +6,13 @@ import { Location } from "./location"
 import { Model } from "./model"
 import { Project } from "./project"
 import { DateTimeUtcFromMillis, optionalOmitUndefined, RelativePath } from "./schema"
+import { SessionEvent } from "./session-event"
 import { SessionID } from "./session-id"
 
-export const ID = SessionID.ID
-export type ID = SessionID.ID
+export const ID = SessionID
+export type ID = SessionID
+
+export const Event = SessionEvent
 
 export interface Info extends Schema.Schema.Type<typeof Info> {}
 export const Info = Schema.Struct({

+ 58 - 0
packages/schema/src/tui-event.ts

@@ -0,0 +1,58 @@
+export * as TuiEvent from "./tui-event"
+
+import { Effect, Schema } from "effect"
+import { Event } from "./event"
+import { PositiveInt } from "./schema"
+import { SessionID } from "./session-id"
+
+const DEFAULT_TOAST_DURATION = 5000
+
+export const PromptAppend = Event.define({ type: "tui.prompt.append", schema: { text: Schema.String } })
+
+export const CommandExecute = Event.define({
+  type: "tui.command.execute",
+  schema: {
+    command: Schema.Union([
+      Schema.Literals([
+        "session.list",
+        "session.new",
+        "session.share",
+        "session.interrupt",
+        "session.compact",
+        "session.page.up",
+        "session.page.down",
+        "session.line.up",
+        "session.line.down",
+        "session.half.page.up",
+        "session.half.page.down",
+        "session.first",
+        "session.last",
+        "prompt.clear",
+        "prompt.submit",
+        "agent.cycle",
+      ]),
+      Schema.String,
+    ]),
+  },
+})
+
+export const ToastShow = Event.define({
+  type: "tui.toast.show",
+  schema: {
+    title: Schema.optional(Schema.String),
+    message: Schema.String,
+    variant: Schema.Literals(["info", "success", "warning", "error"]),
+    duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
+      description: "Duration in milliseconds",
+    }),
+  },
+})
+
+export const SessionSelect = Event.define({
+  type: "tui.session.select",
+  schema: {
+    sessionID: SessionID.annotate({ description: "Session ID to navigate to" }),
+  },
+})
+
+export const Definitions = Event.inventory(PromptAppend, CommandExecute, ToastShow, SessionSelect)

+ 13 - 0
packages/schema/src/vcs-event.ts

@@ -0,0 +1,13 @@
+export * as VcsEvent from "./vcs-event"
+
+import { Schema } from "effect"
+import { Event } from "./event"
+
+export const BranchUpdated = Event.define({
+  type: "vcs.branch.updated",
+  schema: {
+    branch: Schema.optional(Schema.String),
+  },
+})
+
+export const Definitions = Event.inventory(BranchUpdated)

+ 32 - 0
packages/schema/src/workspace-event.ts

@@ -0,0 +1,32 @@
+export * as WorkspaceEvent from "./workspace-event"
+
+import { Schema } from "effect"
+import { Event } from "./event"
+import { WorkspaceID } from "./workspace-id"
+
+export const ConnectionStatus = Schema.Struct({
+  workspaceID: WorkspaceID,
+  status: Schema.Literals(["connected", "connecting", "disconnected", "error"]),
+})
+export type ConnectionStatus = typeof ConnectionStatus.Type
+
+export const Ready = Event.define({
+  type: "workspace.ready",
+  schema: {
+    name: Schema.String,
+  },
+})
+
+export const Failed = Event.define({
+  type: "workspace.failed",
+  schema: {
+    message: Schema.String,
+  },
+})
+
+export const Status = Event.define({
+  type: "workspace.status",
+  schema: ConnectionStatus.fields,
+})
+
+export const Definitions = Event.inventory(Ready, Failed, Status)

+ 19 - 0
packages/schema/src/workspace-id.ts

@@ -0,0 +1,19 @@
+import { Schema } from "effect"
+import { ascending } from "./identifier"
+import { withStatics } from "./schema"
+
+export const WorkspaceID = Schema.String.check(Schema.isStartsWith("wrk")).pipe(
+  Schema.brand("WorkspaceV2.ID"),
+  withStatics((schema) => {
+    const create = () => schema.make("wrk_" + ascending())
+    return {
+      ascending: (id?: string) => {
+        if (!id) return create()
+        if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`)
+        return schema.make(id)
+      },
+      create,
+    }
+  }),
+)
+export type WorkspaceID = typeof WorkspaceID.Type

+ 6 - 18
packages/schema/src/workspace.ts

@@ -1,21 +1,9 @@
 export * as Workspace from "./workspace"
 
-import { Schema } from "effect"
-import { ascending } from "./identifier"
-import { withStatics } from "./schema"
+import { WorkspaceEvent } from "./workspace-event"
+import { WorkspaceID } from "./workspace-id"
 
-export const ID = Schema.String.check(Schema.isStartsWith("wrk")).pipe(
-  Schema.brand("WorkspaceV2.ID"),
-  withStatics((schema) => {
-    const create = () => schema.make("wrk_" + ascending())
-    return {
-      ascending: (id?: string) => {
-        if (!id) return create()
-        if (!id.startsWith("wrk")) throw new Error(`ID ${id} does not start with wrk`)
-        return schema.make(id)
-      },
-      create,
-    }
-  }),
-)
-export type ID = typeof ID.Type
+export const ID = WorkspaceID
+export type ID = WorkspaceID
+
+export const Event = WorkspaceEvent

+ 21 - 0
packages/schema/src/worktree-event.ts

@@ -0,0 +1,21 @@
+export * as WorktreeEvent from "./worktree-event"
+
+import { Schema } from "effect"
+import { Event } from "./event"
+
+export const Ready = Event.define({
+  type: "worktree.ready",
+  schema: {
+    name: Schema.String,
+    branch: Schema.optional(Schema.String),
+  },
+})
+
+export const Failed = Event.define({
+  type: "worktree.failed",
+  schema: {
+    message: Schema.String,
+  },
+})
+
+export const Definitions = Event.inventory(Ready, Failed)

+ 53 - 0
packages/schema/test/event-manifest.test.ts

@@ -0,0 +1,53 @@
+import { describe, expect, test } from "bun:test"
+import { FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src"
+import { EventManifest } from "../src/event-manifest"
+import { IdeEvent } from "../src/ide-event"
+import { SessionEvent } from "../src/session-event"
+import { SessionTodo } from "../src/session-todo"
+import { SessionV1 } from "../src/session-v1"
+import { WorkspaceEvent } from "../src/workspace-event"
+
+describe("public event manifest", () => {
+  test("owns the complete public event surface", () => {
+    expect(EventManifest.ServerDefinitions.length).toBe(55)
+    expect(EventManifest.Definitions.length).toBe(85)
+    expect(SessionV1.Event.Definitions).toEqual([
+      SessionV1.Event.Created,
+      SessionV1.Event.Updated,
+      SessionV1.Event.Deleted,
+      SessionV1.Event.MessageUpdated,
+      SessionV1.Event.MessageRemoved,
+      SessionV1.Event.PartUpdated,
+      SessionV1.Event.PartRemoved,
+      SessionV1.Event.PartDelta,
+      SessionV1.Event.Diff,
+      SessionV1.Event.Error,
+    ])
+    expect(EventManifest.Latest.size).toBe(85)
+    expect(EventManifest.Durable.size).toBe(32)
+  })
+
+  test("uses canonical definitions for current public events", () => {
+    expect(Session.Event).toBe(SessionEvent)
+    expect(Session.Event.Definitions).toBe(SessionEvent.Definitions)
+    expect(Workspace.Event).toBe(WorkspaceEvent)
+    expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions)
+    expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended)
+    expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated)
+    expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
+    expect(Project.Event.Definitions).toEqual([Project.Event.Updated])
+    expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited])
+    expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
+    expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
+    expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
+    expect(EventManifest.Latest.has("ide.installed")).toBe(false)
+    expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
+    expect(EventManifest.Definitions.slice(40, 43)).toEqual([
+      SessionV1.Event.PartDelta,
+      SessionV1.Event.Diff,
+      SessionV1.Event.Error,
+    ])
+    expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false)
+    expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended)
+  })
+})

+ 37 - 0
packages/schema/test/event.test.ts

@@ -0,0 +1,37 @@
+import { describe, expect, test } from "bun:test"
+import { Schema } from "effect"
+import { Event } from "../src/event"
+
+describe("public event schemas", () => {
+  test("definition is pure", () => {
+    const definitions = Event.inventory()
+    Event.define({ type: "test.pure", schema: { value: Schema.String } })
+    expect(definitions).toEqual([])
+  })
+
+  test("latest selection is independent of declaration order", () => {
+    const historical = Event.define({
+      type: "test.versioned",
+      durable: { aggregate: "id", version: 1 },
+      schema: { id: Schema.String },
+    })
+    const current = Event.define({
+      type: "test.versioned",
+      durable: { aggregate: "id", version: 2 },
+      schema: { id: Schema.String, value: Schema.String },
+    })
+
+    expect(Event.latest([historical, current]).get(current.type)).toBe(current)
+    expect(Event.latest([current, historical]).get(current.type)).toBe(current)
+  })
+
+  test("durable definitions are indexed by type and version", () => {
+    const definition = Event.define({
+      type: "test.durable",
+      durable: { aggregate: "id", version: 1 },
+      schema: { id: Schema.String },
+    })
+
+    expect(Event.durable([definition]).get("test.durable.1")).toBe(definition)
+  })
+})

+ 53 - 0
packages/schema/test/legacy-event.test.ts

@@ -0,0 +1,53 @@
+import { describe, expect, test } from "bun:test"
+import { LegacyEvent } from "../src/legacy-event"
+import { PermissionV1 } from "../src/permission-v1"
+import { QuestionV1 } from "../src/question-v1"
+import { Project } from "../src/project"
+import { SessionV1 } from "../src/session-v1"
+
+describe("legacy public event schemas", () => {
+  test("owns all SessionV1 definitions", () => {
+    expect(SessionV1.Event.Definitions.map((event) => event.type)).toEqual([
+      "session.created",
+      "session.updated",
+      "session.deleted",
+      "message.updated",
+      "message.removed",
+      "message.part.updated",
+      "message.part.removed",
+      "message.part.delta",
+      "session.diff",
+      "session.error",
+    ])
+    const durable = SessionV1.Event.Definitions.filter((event) => event.durable !== undefined)
+    expect(durable).toHaveLength(7)
+    expect(durable.every((event) => event.durable?.aggregate === "sessionID")).toBe(true)
+    expect(durable.every((event) => event.durable?.version === 1)).toBe(true)
+  })
+
+  test("owns the legacy transient public definitions", () => {
+    expect([
+      SessionV1.PartDelta.type,
+      SessionV1.Diff.type,
+      SessionV1.Error.type,
+      PermissionV1.Event.Asked.type,
+      PermissionV1.Event.Replied.type,
+      QuestionV1.Event.Asked.type,
+      QuestionV1.Event.Replied.type,
+      QuestionV1.Event.Rejected.type,
+      Project.Event.Updated.type,
+      LegacyEvent.CommandExecuted.type,
+    ]).toEqual([
+      "message.part.delta",
+      "session.diff",
+      "session.error",
+      "permission.asked",
+      "permission.replied",
+      "question.asked",
+      "question.replied",
+      "question.rejected",
+      "project.updated",
+      "command.executed",
+    ])
+  })
+})

+ 36 - 30
packages/server/src/api.ts

@@ -1,4 +1,4 @@
-import { HttpApi, OpenApi } from "effect/unstable/httpapi"
+import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { SchemaErrorMiddleware } from "./middleware/schema-error"
 import { MessageGroup } from "./groups/message"
 import { ModelGroup } from "./groups/model"
@@ -8,7 +8,8 @@ import { PermissionGroup } from "./groups/permission"
 import { FileSystemGroup } from "./groups/fs"
 import { CommandGroup } from "./groups/command"
 import { SkillGroup } from "./groups/skill"
-import { EventGroup } from "./groups/event"
+import { EventGroup, makeEventGroup } from "./groups/event"
+import type { Definition } from "@opencode-ai/core/event"
 import { AgentGroup } from "./groups/agent"
 import { HealthGroup } from "./groups/health"
 import { PtyGroup } from "./groups/pty"
@@ -20,31 +21,36 @@ import { IntegrationGroup } from "./groups/integration"
 import { CredentialGroup } from "./groups/credential"
 import { ProjectCopyGroup } from "./groups/project-copy"
 
-export const Api = HttpApi.make("server")
-  .add(HealthGroup)
-  .add(LocationGroup)
-  .add(AgentGroup)
-  .add(SessionGroup)
-  .add(MessageGroup)
-  .add(ModelGroup)
-  .add(ProviderGroup)
-  .add(IntegrationGroup)
-  .add(CredentialGroup)
-  .add(PermissionGroup)
-  .add(FileSystemGroup)
-  .add(CommandGroup)
-  .add(SkillGroup)
-  .add(EventGroup)
-  .add(PtyGroup)
-  .add(QuestionGroup)
-  .add(ReferenceGroup)
-  .add(ProjectCopyGroup)
-  .annotateMerge(
-    OpenApi.annotations({
-      title: "opencode HttpApi",
-      version: "0.0.1",
-      description: "Experimental HttpApi surface for selected instance routes.",
-    }),
-  )
-  .middleware(Authorization)
-  .middleware(SchemaErrorMiddleware)
+const makeApiFromGroup = <const Group extends HttpApiGroup.Any>(eventGroup: Group) =>
+  HttpApi.make("server")
+    .add(HealthGroup)
+    .add(LocationGroup)
+    .add(AgentGroup)
+    .add(SessionGroup)
+    .add(MessageGroup)
+    .add(ModelGroup)
+    .add(ProviderGroup)
+    .add(IntegrationGroup)
+    .add(CredentialGroup)
+    .add(PermissionGroup)
+    .add(FileSystemGroup)
+    .add(CommandGroup)
+    .add(SkillGroup)
+    .add(eventGroup)
+    .add(PtyGroup)
+    .add(QuestionGroup)
+    .add(ReferenceGroup)
+    .add(ProjectCopyGroup)
+    .annotateMerge(
+      OpenApi.annotations({
+        title: "opencode HttpApi",
+        version: "0.0.1",
+        description: "Experimental HttpApi surface for selected instance routes.",
+      }),
+    )
+    .middleware(Authorization)
+    .middleware(SchemaErrorMiddleware)
+
+export const makeApi = (definitions: ReadonlyArray<Definition>) => makeApiFromGroup(makeEventGroup(definitions))
+
+export const Api = makeApiFromGroup(EventGroup)

+ 40 - 17
packages/server/src/groups/event.ts

@@ -1,5 +1,7 @@
 import { EventV2 } from "@opencode-ai/core/event"
+import { PublicEventManifest } from "@opencode-ai/core/public-event-manifest"
 import { Location } from "@opencode-ai/core/location"
+import type { Definition } from "@opencode-ai/core/event"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 
@@ -10,25 +12,47 @@ const fields = {
   location: Schema.optional(Location.Ref),
 }
 
-const Event = Schema.Union([
-  ...EventV2.definitions().map((definition) =>
-    Schema.Struct({
-      ...fields,
-      type: Schema.Literal(definition.type),
-      data: definition.data as Schema.Struct<{}>,
-    }).annotate({ identifier: `V2Event.${definition.type}` }),
-  ),
-  Schema.Struct({
-    ...fields,
-    type: Schema.Literal("server.connected"),
-    data: Schema.Struct({}),
-  }).annotate({ identifier: "V2Event.server.connected" }),
-]).annotate({ identifier: "V2Event" })
+const schema = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) =>
+  Schema.Union([
+    ...definitions.map((definition) =>
+      Schema.Struct({
+        ...fields,
+        type: Schema.Literal(definition.type),
+        data: definition.data,
+      }).annotate({ identifier: `V2Event.${definition.type}` }),
+    ),
+    ...(definitions.some((definition) => definition.type === "server.connected")
+      ? []
+      : [
+          Schema.Struct({
+            ...fields,
+            type: Schema.Literal("server.connected"),
+            data: Schema.Struct({}),
+          }).annotate({ identifier: "V2Event.server.connected" }),
+        ]),
+  ]).annotate({ identifier: "V2Event" })
+
+export const makeEventGroup = <const Definitions extends ReadonlyArray<Definition>>(definitions: Definitions) =>
+  HttpApiGroup.make("server.event")
+    .add(
+      HttpApiEndpoint.get("event.subscribe", "/api/event", {
+        success: schema(definitions),
+      }).annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.event.subscribe",
+          summary: "Subscribe to events",
+          description: "Subscribe to native event payloads for the server.",
+        }),
+      ),
+    )
+    .annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." }))
+
+const EventSchema = schema(PublicEventManifest.Definitions)
 
 export const EventGroup = HttpApiGroup.make("server.event")
   .add(
     HttpApiEndpoint.get("event.subscribe", "/api/event", {
-      success: Event,
+      success: EventSchema,
     }).annotateMerge(
       OpenApi.annotations({
         identifier: "v2.event.subscribe",
@@ -38,5 +62,4 @@ export const EventGroup = HttpApiGroup.make("server.event")
     ),
   )
   .annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." }))
-
-export type Event = typeof Event.Type
+export type Event = typeof EventSchema.Type