Kaynağa Gözat

refactor(protocol): extract server contracts (#33708)

Kit Langton 2 ay önce
ebeveyn
işleme
56a37c3640
77 değiştirilmiş dosya ile 1070 ekleme ve 878 silme
  1. 16 0
      bun.lock
  2. 2 6
      packages/core/src/filesystem.ts
  3. 6 33
      packages/core/src/integration.ts
  4. 3 18
      packages/core/src/location.ts
  5. 3 12
      packages/core/src/permission/saved.ts
  6. 5 16
      packages/core/src/project/copy.ts
  7. 3 18
      packages/core/src/pty.ts
  8. 3 6
      packages/core/src/pty/ticket.ts
  9. 3 8
      packages/core/src/reference.ts
  10. 1 0
      packages/opencode/package.json
  11. 8 2
      packages/opencode/src/server/routes/instance/httpapi/api.ts
  12. 1 1
      packages/opencode/test/server/httpapi-query-schema-drift.test.ts
  13. 22 0
      packages/protocol/package.json
  14. 86 0
      packages/protocol/src/api.ts
  15. 0 0
      packages/protocol/src/errors.ts
  16. 20 0
      packages/protocol/src/groups/agent.ts
  17. 4 5
      packages/protocol/src/groups/command.ts
  18. 2 3
      packages/protocol/src/groups/credential.ts
  19. 59 0
      packages/protocol/src/groups/event.ts
  20. 4 5
      packages/protocol/src/groups/fs.ts
  21. 0 0
      packages/protocol/src/groups/health.ts
  22. 3 4
      packages/protocol/src/groups/integration.ts
  23. 42 0
      packages/protocol/src/groups/location.ts
  24. 11 14
      packages/protocol/src/groups/message.ts
  25. 4 5
      packages/protocol/src/groups/model.ts
  26. 95 0
      packages/protocol/src/groups/permission.ts
  27. 6 7
      packages/protocol/src/groups/project-copy.ts
  28. 6 7
      packages/protocol/src/groups/provider.ts
  29. 9 11
      packages/protocol/src/groups/pty.ts
  30. 84 0
      packages/protocol/src/groups/question.ts
  31. 3 4
      packages/protocol/src/groups/reference.ts
  32. 280 0
      packages/protocol/src/groups/session.ts
  33. 4 5
      packages/protocol/src/groups/skill.ts
  34. 6 0
      packages/protocol/src/middleware/authorization.ts
  35. 7 0
      packages/protocol/src/middleware/schema-error.ts
  36. 18 0
      packages/protocol/test/session-cursor.test.ts
  37. 8 0
      packages/protocol/tsconfig.json
  38. 2 2
      packages/schema/src/credential.ts
  39. 6 0
      packages/schema/src/filesystem.ts
  40. 5 0
      packages/schema/src/index.ts
  41. 7 0
      packages/schema/src/integration-id.ts
  42. 40 2
      packages/schema/src/integration.ts
  43. 15 1
      packages/schema/src/location.ts
  44. 20 0
      packages/schema/src/permission-saved.ts
  45. 29 0
      packages/schema/src/project-copy.ts
  46. 8 0
      packages/schema/src/project-id.ts
  47. 3 5
      packages/schema/src/project.ts
  48. 9 0
      packages/schema/src/pty-ticket.ts
  49. 21 1
      packages/schema/src/pty.ts
  50. 8 0
      packages/schema/src/reference.ts
  51. 10 0
      packages/schema/test/compatibility.test.ts
  52. 1 0
      packages/server/package.json
  53. 7 55
      packages/server/src/api.ts
  54. 0 22
      packages/server/src/groups/agent.ts
  55. 0 65
      packages/server/src/groups/event.ts
  56. 0 86
      packages/server/src/groups/permission.ts
  57. 0 75
      packages/server/src/groups/question.ts
  58. 0 281
      packages/server/src/groups/session.ts
  59. 1 1
      packages/server/src/handlers.ts
  60. 1 1
      packages/server/src/handlers/agent.ts
  61. 1 2
      packages/server/src/handlers/command.ts
  62. 1 1
      packages/server/src/handlers/fs.ts
  63. 2 2
      packages/server/src/handlers/integration.ts
  64. 1 1
      packages/server/src/handlers/message.ts
  65. 1 1
      packages/server/src/handlers/model.ts
  66. 2 2
      packages/server/src/handlers/permission.ts
  67. 1 1
      packages/server/src/handlers/project-copy.ts
  68. 2 3
      packages/server/src/handlers/provider.ts
  69. 7 3
      packages/server/src/handlers/pty.ts
  70. 2 2
      packages/server/src/handlers/question.ts
  71. 1 1
      packages/server/src/handlers/reference.ts
  72. 2 2
      packages/server/src/handlers/session.ts
  73. 1 1
      packages/server/src/handlers/skill.ts
  74. 6 51
      packages/server/src/location.ts
  75. 4 7
      packages/server/src/middleware/authorization.ts
  76. 3 6
      packages/server/src/middleware/schema-error.ts
  77. 3 5
      packages/server/src/middleware/session-location.ts

+ 16 - 0
bun.lock

@@ -539,6 +539,7 @@
         "@openauthjs/openauth": "catalog:",
         "@opencode-ai/llm": "workspace:*",
         "@opencode-ai/plugin": "workspace:*",
+        "@opencode-ai/protocol": "workspace:*",
         "@opencode-ai/schema": "workspace:*",
         "@opencode-ai/script": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
@@ -656,6 +657,18 @@
         "@opentui/solid",
       ],
     },
+    "packages/protocol": {
+      "name": "@opencode-ai/protocol",
+      "dependencies": {
+        "@opencode-ai/schema": "workspace:*",
+        "effect": "catalog:",
+      },
+      "devDependencies": {
+        "@tsconfig/bun": "catalog:",
+        "@types/bun": "catalog:",
+        "@typescript/native-preview": "catalog:",
+      },
+    },
     "packages/schema": {
       "name": "@opencode-ai/schema",
       "dependencies": {
@@ -697,6 +710,7 @@
       "version": "1.17.10",
       "dependencies": {
         "@opencode-ai/core": "workspace:*",
+        "@opencode-ai/protocol": "workspace:*",
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
       },
@@ -1863,6 +1877,8 @@
 
     "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
 
+    "@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
+
     "@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
 
     "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],

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

@@ -6,7 +6,7 @@ import { FSUtil } from "./fs-util"
 import { Location } from "./location"
 import { PositiveInt, RelativePath } from "./schema"
 import { FileSystemSearch } from "./filesystem/search"
-import { Entry, FileSystem, Match } from "@opencode-ai/schema/filesystem"
+import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem"
 export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
 
 export const ReadInput = Schema.Struct({
@@ -28,11 +28,7 @@ export const ListInput = Schema.Struct({
 })
 export type ListInput = typeof ListInput.Type
 
-export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
-  query: Schema.String,
-  type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
-  limit: PositiveInt.pipe(Schema.optional),
-}) {}
+export { FindInput }
 
 export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
   pattern: Schema.String,

+ 6 - 33
packages/core/src/integration.ts

@@ -16,9 +16,7 @@ import {
 } from "effect"
 import { Integration } from "@opencode-ai/schema/integration"
 import { Credential } from "./credential"
-import { withStatics } from "./schema"
 import { State } from "./state"
-import { Identifier } from "./util/identifier"
 import { EventV2 } from "./event"
 import { IntegrationConnection } from "./integration/connection"
 
@@ -28,10 +26,7 @@ export type ID = Integration.ID
 export const MethodID = Integration.MethodID
 export type MethodID = Integration.MethodID
 
-export const AttemptID = Schema.String.pipe(
-  Schema.brand("Integration.AttemptID"),
-  withStatics((schema) => ({ create: () => schema.make("con_" + Identifier.ascending()) })),
-)
+export const AttemptID = Integration.AttemptID
 export type AttemptID = typeof AttemptID.Type
 
 export const When = Integration.When
@@ -58,12 +53,8 @@ export type EnvMethod = Integration.EnvMethod
 export const Method = Integration.Method
 export type Method = Integration.Method
 
-export class Info extends Schema.Class<Info>("Integration.Info")({
-  id: ID,
-  name: Schema.String,
-  methods: Schema.mutable(Schema.Array(Method)),
-  connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
-}) {}
+export const Info = Integration.Info
+export type Info = Integration.Info
 
 export const Inputs = Integration.Inputs
 export type Inputs = Integration.Inputs
@@ -102,28 +93,10 @@ export interface EnvImplementation {
 
 export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
 
-export class Attempt extends Schema.Class<Attempt>("Integration.Attempt")({
-  attemptID: AttemptID,
-  url: Schema.String,
-  instructions: Schema.String,
-  mode: Schema.Literals(["auto", "code"]),
-  time: Schema.Struct({
-    created: Schema.Number,
-    expires: Schema.Number,
-  }),
-}) {}
+export const Attempt = Integration.Attempt
+export type Attempt = Integration.Attempt
 
-const Time = Schema.Struct({
-  created: Schema.Number,
-  expires: Schema.Number,
-})
-
-export const AttemptStatus = Schema.Union([
-  Schema.Struct({ status: Schema.Literal("pending"), time: Time }),
-  Schema.Struct({ status: Schema.Literal("complete"), time: Time }),
-  Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: Time }),
-  Schema.Struct({ status: Schema.Literal("expired"), time: Time }),
-]).pipe(Schema.toTaggedUnion("status"))
+export const AttemptStatus = Integration.AttemptStatus
 export type AttemptStatus = typeof AttemptStatus.Type
 
 export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError>()("Integration.CodeRequired", {

+ 3 - 18
packages/core/src/location.ts

@@ -1,30 +1,15 @@
-import { Context, Effect, Layer, Schema } from "effect"
-import { Ref } from "@opencode-ai/schema/location"
+import { Context, Effect, Layer } from "effect"
+import { Info, Ref, response } from "@opencode-ai/schema/location"
 import { Project } from "./project"
-import { AbsolutePath, optionalOmitUndefined } from "./schema"
-import { WorkspaceV2 } from "./workspace"
 
 export * as Location from "./location"
 
-export { Ref }
-
-export class Info extends Schema.Class<Info>("Location.Info")({
-  directory: AbsolutePath,
-  workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
-  project: Schema.Struct({
-    id: Project.ID,
-    directory: AbsolutePath,
-  }),
-}) {}
+export { Info, Ref, response }
 
 export interface Interface extends Info {
   readonly vcs?: Project.Vcs
 }
 
-export function response<S extends Schema.Top>(data: S) {
-  return Schema.Struct({ location: Info, data })
-}
-
 export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
 
 export const layer = (ref: Ref) =>

+ 3 - 12
packages/core/src/permission/saved.ts

@@ -4,22 +4,13 @@ import { eq } from "drizzle-orm"
 import { Context, Effect, Layer, Schema } from "effect"
 import { Database } from "../database/database"
 import { ProjectV2 } from "../project"
-import { withStatics } from "../schema"
-import { Identifier } from "../util/identifier"
 import { PermissionTable } from "./sql"
+import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
 
-export const ID = Schema.String.pipe(
-  Schema.brand("PermissionSaved.ID"),
-  withStatics((schema) => ({ create: () => schema.make("psv_" + Identifier.ascending()) })),
-)
+export const ID = PermissionSaved.ID
 export type ID = typeof ID.Type
 
-export const Info = Schema.Struct({
-  id: ID,
-  projectID: ProjectV2.ID,
-  action: Schema.String,
-  resource: Schema.String,
-}).annotate({ identifier: "PermissionSaved.Info" })
+export const Info = PermissionSaved.Info
 export type Info = typeof Info.Type
 
 export const ListInput = Schema.Struct({

+ 5 - 16
packages/core/src/project/copy.ts

@@ -14,24 +14,15 @@ import { EventV2 } from "../event"
 import { Database } from "../database/database"
 import { Location } from "../location"
 import { ProjectDirectoriesEvent } from "@opencode-ai/schema/project-directories"
+import { ProjectCopy } from "@opencode-ai/schema/project-copy"
 
-export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
+export const StrategyID = ProjectCopy.StrategyID
 export type StrategyID = typeof StrategyID.Type
 
-export const CreateInput = Schema.Struct({
-  projectID: Project.ID,
-  strategy: StrategyID,
-  sourceDirectory: AbsolutePath,
-  directory: AbsolutePath,
-  name: Schema.optional(Schema.String),
-}).annotate({ identifier: "ProjectCopy.CreateInput" })
+export const CreateInput = ProjectCopy.CreateInput
 export type CreateInput = typeof CreateInput.Type
 
-export const RemoveInput = Schema.Struct({
-  projectID: Project.ID,
-  directory: AbsolutePath,
-  force: Schema.Boolean,
-}).annotate({ identifier: "ProjectCopy.RemoveInput" })
+export const RemoveInput = ProjectCopy.RemoveInput
 export type RemoveInput = typeof RemoveInput.Type
 
 export const RefreshInput = Schema.Struct({
@@ -45,9 +36,7 @@ export const RefreshResult = Schema.Struct({
 }).annotate({ identifier: "ProjectCopy.RefreshResult" })
 export type RefreshResult = typeof RefreshResult.Type
 
-export const Copy = Schema.Struct({
-  directory: AbsolutePath,
-}).annotate({ identifier: "ProjectCopy.Copy" })
+export const Copy = ProjectCopy.Copy
 export type Copy = typeof Copy.Type
 
 export const ListEntry = Schema.Struct({

+ 3 - 18
packages/core/src/pty.ts

@@ -2,11 +2,10 @@ 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 { PtyEvent, PtyInfo, Pty } from "@opencode-ai/schema/pty"
 import { Config } from "./config"
 import { EventV2 } from "./event"
 import { Location } from "./location"
-import { PositiveInt } from "./schema"
 import { PtyID } from "./pty/schema"
 import { Shell } from "./shell"
 import { lazy } from "./util/lazy"
@@ -40,25 +39,11 @@ export const Info = PtyInfo
 
 export type Info = Types.DeepMutable<typeof Info.Type>
 
-export const CreateInput = Schema.Struct({
-  command: Schema.optional(Schema.String),
-  args: Schema.optional(Schema.Array(Schema.String)),
-  cwd: Schema.optional(Schema.String),
-  title: Schema.optional(Schema.String),
-  env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
-})
+export const CreateInput = Pty.CreateInput
 
 export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
 
-export const UpdateInput = Schema.Struct({
-  title: Schema.optional(Schema.String),
-  size: Schema.optional(
-    Schema.Struct({
-      rows: PositiveInt,
-      cols: PositiveInt,
-    }),
-  ),
-})
+export const UpdateInput = Pty.UpdateInput
 
 export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
 

+ 3 - 6
packages/core/src/pty/ticket.ts

@@ -1,18 +1,15 @@
 export * as PtyTicket from "./ticket"
 
 import { WorkspaceV2 } from "../workspace"
-import { PositiveInt } from "../schema"
+import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
 import { PtyID } from "./schema"
-import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
+import { Cache, Context, Duration, Effect, Layer } from "effect"
 import { LayerNode } from "../effect/layer-node"
 
 const DEFAULT_TTL = Duration.seconds(60)
 const CAPACITY = 10_000
 
-export const ConnectToken = Schema.Struct({
-  ticket: Schema.String,
-  expires_in: PositiveInt,
-})
+export const ConnectToken = PtyTicket.ConnectToken
 
 export type Scope = {
   readonly ptyID: PtyID

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

@@ -1,6 +1,6 @@
 export * as Reference from "./reference"
 
-import { Context, Effect, Layer, Schema, Scope, Types } from "effect"
+import { Context, Effect, Layer, Scope, Types } from "effect"
 import { Reference } from "@opencode-ai/schema/reference"
 import { Global } from "./global"
 import { EventV2 } from "./event"
@@ -20,13 +20,8 @@ export type Source = Reference.Source
 
 export const Event = Reference.Event
 
-export class Info extends Schema.Class<Info>("Reference.Info")({
-  name: Schema.String,
-  path: AbsolutePath,
-  description: Schema.String.pipe(Schema.optional),
-  hidden: Schema.Boolean.pipe(Schema.optional),
-  source: Source,
-}) {}
+export const Info = Reference.Info
+export type Info = Reference.Info
 
 type Data = {
   sources: Map<string, Types.DeepMutable<Source>>

+ 1 - 0
packages/opencode/package.json

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

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

@@ -25,7 +25,9 @@ import { SessionApi } from "./groups/session"
 import { SyncApi } from "./groups/sync"
 import { TuiApi } from "./groups/tui"
 import { WorkspaceApi } from "./groups/workspace"
-import { makeApi } from "@opencode-ai/server/api"
+import { makeApi } from "@opencode-ai/protocol/api"
+import { LocationMiddleware } from "@opencode-ai/server/location"
+import { SessionLocationMiddleware } from "@opencode-ai/server/middleware/session-location"
 import { GlobalApi } from "./groups/global"
 import { Authorization } from "./middleware/authorization"
 import { SchemaErrorMiddleware } from "./middleware/schema-error"
@@ -43,7 +45,11 @@ const EventSchema = Schema.Union([
   InstanceDisposed,
 ]).annotate({ identifier: "Event" })
 
-export const ServerApi = makeApi(EventManifest.Latest.values().toArray())
+export const ServerApi = makeApi({
+  definitions: EventManifest.Latest.values().toArray(),
+  locationMiddleware: LocationMiddleware,
+  sessionLocationMiddleware: SessionLocationMiddleware,
+})
 
 export const RootHttpApi = HttpApi.make("opencode-root")
   .addHttpApi(ControlApi)

+ 1 - 1
packages/opencode/test/server/httpapi-query-schema-drift.test.ts

@@ -24,7 +24,7 @@ import {
   SessionPaths,
 } from "../../src/server/routes/instance/httpapi/groups/session"
 import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
-import { SessionMessagesQuery } from "@opencode-ai/server/groups/message"
+import { SessionMessagesQuery } from "@opencode-ai/protocol/groups/message"
 import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
 import { resetDatabase } from "../fixture/db"
 import { disposeAllInstances, tmpdir } from "../fixture/fixture"

+ 22 - 0
packages/protocol/package.json

@@ -0,0 +1,22 @@
+{
+  "$schema": "https://json.schemastore.org/package.json",
+  "name": "@opencode-ai/protocol",
+  "private": true,
+  "type": "module",
+  "license": "MIT",
+  "exports": {
+    "./*": "./src/*.ts"
+  },
+  "scripts": {
+    "typecheck": "tsgo --noEmit"
+  },
+  "dependencies": {
+    "@opencode-ai/schema": "workspace:*",
+    "effect": "catalog:"
+  },
+  "devDependencies": {
+    "@tsconfig/bun": "catalog:",
+    "@types/bun": "catalog:",
+    "@typescript/native-preview": "catalog:"
+  }
+}

+ 86 - 0
packages/protocol/src/api.ts

@@ -0,0 +1,86 @@
+import { Context } from "effect"
+import { HttpApi, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
+import { SchemaErrorMiddleware } from "./middleware/schema-error"
+import { MessageGroup } from "./groups/message"
+import { ModelGroup } from "./groups/model"
+import { ProviderGroup } from "./groups/provider"
+import { makeSessionGroup } from "./groups/session"
+import { makePermissionGroup } from "./groups/permission"
+import { FileSystemGroup } from "./groups/fs"
+import { CommandGroup } from "./groups/command"
+import { SkillGroup } from "./groups/skill"
+import { EventGroup, makeEventGroup } from "./groups/event"
+import type { Definition } from "@opencode-ai/schema/event"
+import { AgentGroup } from "./groups/agent"
+import { HealthGroup } from "./groups/health"
+import { PtyGroup } from "./groups/pty"
+import { makeQuestionGroup } from "./groups/question"
+import { ReferenceGroup } from "./groups/reference"
+import { Authorization } from "./middleware/authorization"
+import { LocationGroup } from "./groups/location"
+import { IntegrationGroup } from "./groups/integration"
+import { CredentialGroup } from "./groups/credential"
+import { ProjectCopyGroup } from "./groups/project-copy"
+
+// Protocol owns middleware placement, while Server injects concrete keys so Core service identities stay downstream.
+const makeApiFromGroup = <
+  const Group extends HttpApiGroup.Any,
+  LocationId extends HttpApiMiddleware.AnyId,
+  LocationService,
+  SessionLocationId extends HttpApiMiddleware.AnyId,
+  SessionLocationService,
+>(
+  eventGroup: Group,
+  locationMiddleware: Context.Key<LocationId, LocationService>,
+  sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
+) =>
+  HttpApi.make("server")
+    .add(HealthGroup)
+    .add(LocationGroup.middleware(locationMiddleware))
+    .add(AgentGroup.middleware(locationMiddleware))
+    .add(makeSessionGroup(sessionLocationMiddleware))
+    .add(MessageGroup.middleware(sessionLocationMiddleware))
+    .add(ModelGroup.middleware(locationMiddleware))
+    .add(ProviderGroup.middleware(locationMiddleware))
+    .add(IntegrationGroup.middleware(locationMiddleware))
+    .add(CredentialGroup.middleware(locationMiddleware))
+    .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware))
+    .add(FileSystemGroup.middleware(locationMiddleware))
+    .add(CommandGroup.middleware(locationMiddleware))
+    .add(SkillGroup.middleware(locationMiddleware))
+    .add(eventGroup)
+    .add(PtyGroup.middleware(locationMiddleware))
+    .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
+    .add(ReferenceGroup.middleware(locationMiddleware))
+    .add(ProjectCopyGroup.middleware(locationMiddleware))
+    .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 = <
+  LocationId extends HttpApiMiddleware.AnyId,
+  LocationService,
+  SessionLocationId extends HttpApiMiddleware.AnyId,
+  SessionLocationService,
+>(options: {
+  readonly definitions: ReadonlyArray<Definition>
+  readonly locationMiddleware: Context.Key<LocationId, LocationService>
+  readonly sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>
+}) =>
+  makeApiFromGroup(makeEventGroup(options.definitions), options.locationMiddleware, options.sessionLocationMiddleware)
+
+export const makeDefaultApi = <
+  LocationId extends HttpApiMiddleware.AnyId,
+  LocationService,
+  SessionLocationId extends HttpApiMiddleware.AnyId,
+  SessionLocationService,
+>(options: {
+  readonly locationMiddleware: Context.Key<LocationId, LocationService>
+  readonly sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>
+}) => makeApiFromGroup(EventGroup, options.locationMiddleware, options.sessionLocationMiddleware)

+ 0 - 0
packages/server/src/errors.ts → packages/protocol/src/errors.ts


+ 20 - 0
packages/protocol/src/groups/agent.ts

@@ -0,0 +1,20 @@
+import { Agent } from "@opencode-ai/schema/agent"
+import { Location } from "@opencode-ai/schema/location"
+import { Schema } from "effect"
+import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { LocationQuery, locationQueryOpenApi } from "./location"
+
+export const AgentGroup = HttpApiGroup.make("server.agent").add(
+  HttpApiEndpoint.get("agent.list", "/api/agent", {
+    query: LocationQuery,
+    success: Location.response(Schema.Array(Agent.Info)),
+  })
+    .annotateMerge(locationQueryOpenApi)
+    .annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.agent.list",
+        summary: "List agents",
+        description: "Retrieve currently registered agents.",
+      }),
+    ),
+)

+ 4 - 5
packages/server/src/groups/command.ts → packages/protocol/src/groups/command.ts

@@ -1,14 +1,14 @@
-import { CommandV2 } from "@opencode-ai/core/command"
-import { Location } from "@opencode-ai/core/location"
+import { Command } from "@opencode-ai/schema/command"
+import { Location } from "@opencode-ai/schema/location"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
-import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
+import { LocationQuery, locationQueryOpenApi } from "./location"
 
 export const CommandGroup = HttpApiGroup.make("server.command")
   .add(
     HttpApiEndpoint.get("command.list", "/api/command", {
       query: LocationQuery,
-      success: Location.response(Schema.Array(CommandV2.Info)),
+      success: Location.response(Schema.Array(Command.Info)),
     })
       .annotateMerge(locationQueryOpenApi)
       .annotateMerge(
@@ -25,4 +25,3 @@ export const CommandGroup = HttpApiGroup.make("server.command")
       description: "Experimental command routes.",
     }),
   )
-  .middleware(LocationMiddleware)

+ 2 - 3
packages/server/src/groups/credential.ts → packages/protocol/src/groups/credential.ts

@@ -1,7 +1,7 @@
-import { Credential } from "@opencode-ai/core/credential"
+import { Credential } from "@opencode-ai/schema/credential"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
-import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
+import { LocationQuery, locationQueryOpenApi } from "./location"
 
 export const CredentialGroup = HttpApiGroup.make("server.credential")
   .add(
@@ -35,4 +35,3 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
         }),
       ),
   )
-  .middleware(LocationMiddleware)

+ 59 - 0
packages/protocol/src/groups/event.ts

@@ -0,0 +1,59 @@
+import { Event } from "@opencode-ai/schema/event"
+import { EventManifest } from "@opencode-ai/schema/event-manifest"
+import { Location } from "@opencode-ai/schema/location"
+import type { Definition } from "@opencode-ai/schema/event"
+import { Schema } from "effect"
+import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+
+const fields = {
+  id: Event.ID,
+  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
+  durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })),
+  location: Schema.optional(Location.Ref),
+}
+
+const schema = (definitions: ReadonlyArray<Definition>) =>
+  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" })
+
+const make = (definitions: ReadonlyArray<Definition>) => {
+  const EventSchema = schema(definitions)
+  return {
+    schema: EventSchema,
+    group: HttpApiGroup.make("server.event")
+      .add(
+        HttpApiEndpoint.get("event.subscribe", "/api/event", {
+          success: EventSchema,
+        }).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." })),
+  }
+}
+
+export const makeEventGroup = (definitions: ReadonlyArray<Definition>) => make(definitions).group
+
+const event = make(EventManifest.ServerDefinitions)
+export const EventGroup = event.group
+export type Event = typeof event.schema.Type

+ 4 - 5
packages/server/src/groups/fs.ts → packages/protocol/src/groups/fs.ts

@@ -1,9 +1,9 @@
-import { FileSystem } from "@opencode-ai/core/filesystem"
-import { Location } from "@opencode-ai/core/location"
-import { PositiveInt, RelativePath } from "@opencode-ai/core/schema"
+import { FileSystem } from "@opencode-ai/schema/filesystem"
+import { Location } from "@opencode-ai/schema/location"
+import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
-import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
+import { LocationQuery, locationQueryOpenApi } from "./location"
 
 const ListQuery = Schema.Struct({
   ...LocationQuery.fields,
@@ -66,4 +66,3 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
       description: "Experimental location-scoped filesystem routes.",
     }),
   )
-  .middleware(LocationMiddleware)

+ 0 - 0
packages/server/src/groups/health.ts → packages/protocol/src/groups/health.ts


+ 3 - 4
packages/server/src/groups/integration.ts → packages/protocol/src/groups/integration.ts

@@ -1,9 +1,9 @@
-import { Integration } from "@opencode-ai/core/integration"
-import { Location } from "@opencode-ai/core/location"
+import { Integration } from "@opencode-ai/schema/integration"
+import { Location } from "@opencode-ai/schema/location"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { InvalidRequestError } from "../errors"
-import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
+import { LocationQuery, locationQueryOpenApi } from "./location"
 
 const Inputs = Schema.Record(Schema.String, Schema.String)
 
@@ -128,4 +128,3 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
   .annotateMerge(
     OpenApi.annotations({ title: "integrations", description: "Integration discovery and authentication routes." }),
   )
-  .middleware(LocationMiddleware)

+ 42 - 0
packages/protocol/src/groups/location.ts

@@ -0,0 +1,42 @@
+import { Location } from "@opencode-ai/schema/location"
+import { Schema } from "effect"
+import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+
+export const LocationQuery = Schema.Struct({
+  location: Schema.optional(
+    Schema.Struct({
+      directory: Schema.optional(Schema.String),
+      workspace: Schema.optional(Schema.String),
+    }),
+  ),
+}).annotate({ identifier: "LocationQuery" })
+
+export const locationQueryOpenApi = OpenApi.annotations({
+  transform: (operation) => {
+    const parameters = operation.parameters
+    if (!Array.isArray(parameters)) return operation
+    return {
+      ...operation,
+      parameters: parameters.map((parameter) =>
+        parameter?.name === "location" && parameter?.in === "query"
+          ? { ...parameter, style: "deepObject", explode: true }
+          : parameter,
+      ),
+    }
+  },
+})
+
+export const LocationGroup = HttpApiGroup.make("server.location").add(
+  HttpApiEndpoint.get("location.get", "/api/location", {
+    query: LocationQuery,
+    success: Location.Info,
+  })
+    .annotateMerge(locationQueryOpenApi)
+    .annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.location.get",
+        summary: "Get location",
+        description: "Resolve the requested location or the server default location.",
+      }),
+    ),
+)

+ 11 - 14
packages/server/src/groups/message.ts → packages/protocol/src/groups/message.ts

@@ -1,9 +1,8 @@
-import { SessionV2 } from "@opencode-ai/core/session"
-import { SessionMessage } from "@opencode-ai/core/session/message"
+import { Session } from "@opencode-ai/schema/session"
+import { SessionMessage } from "@opencode-ai/schema/session-message"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors"
-import { SessionLocationMiddleware } from "../middleware/session-location"
 
 export const SessionMessagesQuery = Schema.Struct({
   limit: Schema.optional(
@@ -25,7 +24,7 @@ export const SessionMessagesQuery = Schema.Struct({
 export const MessageGroup = HttpApiGroup.make("server.message")
   .add(
     HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", {
-      params: { sessionID: SessionV2.ID },
+      params: { sessionID: Session.ID },
       query: SessionMessagesQuery,
       success: Schema.Struct({
         data: Schema.Array(SessionMessage.Message),
@@ -35,16 +34,14 @@ export const MessageGroup = HttpApiGroup.make("server.message")
         }),
       }).annotate({ identifier: "SessionMessagesResponse" }),
       error: [InvalidCursorError, SessionNotFoundError, UnknownError],
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.session.messages",
-          summary: "Get session messages",
-          description:
-            "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
-        }),
-      ),
+    }).annotateMerge(
+      OpenApi.annotations({
+        identifier: "v2.session.messages",
+        summary: "Get session messages",
+        description:
+          "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
+      }),
+    ),
   )
   .annotateMerge(
     OpenApi.annotations({

+ 4 - 5
packages/server/src/groups/model.ts → packages/protocol/src/groups/model.ts

@@ -1,15 +1,15 @@
-import { ModelV2 } from "@opencode-ai/core/model"
-import { Location } from "@opencode-ai/core/location"
+import { Model } from "@opencode-ai/schema/model"
+import { Location } from "@opencode-ai/schema/location"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { ServiceUnavailableError } from "../errors"
-import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
+import { LocationQuery, locationQueryOpenApi } from "./location"
 
 export const ModelGroup = HttpApiGroup.make("server.model")
   .add(
     HttpApiEndpoint.get("model.list", "/api/model", {
       query: LocationQuery,
-      success: Location.response(Schema.Array(ModelV2.Info)),
+      success: Location.response(Schema.Array(Model.Info)),
       error: ServiceUnavailableError,
     })
       .annotateMerge(locationQueryOpenApi)
@@ -27,4 +27,3 @@ export const ModelGroup = HttpApiGroup.make("server.model")
       description: "Experimental model routes.",
     }),
   )
-  .middleware(LocationMiddleware)

+ 95 - 0
packages/protocol/src/groups/permission.ts

@@ -0,0 +1,95 @@
+import { Permission } from "@opencode-ai/schema/permission"
+import { Location } from "@opencode-ai/schema/location"
+import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
+import { Project } from "@opencode-ai/schema/project"
+import { Session } from "@opencode-ai/schema/session"
+import { Context, Schema } from "effect"
+import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import { PermissionNotFoundError, SessionNotFoundError } from "../errors"
+import { LocationQuery, locationQueryOpenApi } from "./location"
+
+export const makePermissionGroup = <
+  LocationId extends HttpApiMiddleware.AnyId,
+  LocationService,
+  SessionLocationId extends HttpApiMiddleware.AnyId,
+  SessionLocationService,
+>(
+  locationMiddleware: Context.Key<LocationId, LocationService>,
+  sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
+) =>
+  HttpApiGroup.make("server.permission")
+    .add(
+      HttpApiEndpoint.get("permission.request.list", "/api/permission/request", {
+        query: LocationQuery,
+        success: Location.response(Schema.Array(Permission.Request)),
+      })
+        .annotateMerge(locationQueryOpenApi)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.permission.request.list",
+            summary: "List pending permission requests",
+            description: "Retrieve pending permission requests for a location.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.get("permission.saved.list", "/api/permission/saved", {
+        query: Schema.Struct({ projectID: Project.ID.pipe(Schema.optional) }),
+        success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }),
+      }).annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.permission.saved.list",
+          summary: "List saved permissions",
+          description: "Retrieve saved permissions, optionally filtered by project.",
+        }),
+      ),
+    )
+    .add(
+      HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", {
+        params: { id: PermissionSaved.ID },
+        success: HttpApiSchema.NoContent,
+      }).annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.permission.saved.remove",
+          summary: "Remove saved permission",
+          description: "Remove a saved permission by ID.",
+        }),
+      ),
+    )
+    // Effect applies group middleware only to endpoints already added; session endpoints use session placement below.
+    .middleware(locationMiddleware)
+    .add(
+      HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", {
+        params: { sessionID: Session.ID },
+        success: Schema.Struct({ data: Schema.Array(Permission.Request) }),
+        error: SessionNotFoundError,
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.permission.list",
+            summary: "List session permission requests",
+            description: "Retrieve pending permission requests owned by a session.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", {
+        params: { sessionID: Session.ID, requestID: Permission.ID },
+        payload: Schema.Struct({
+          reply: Permission.Reply,
+          message: Schema.String.pipe(Schema.optional),
+        }),
+        success: HttpApiSchema.NoContent,
+        error: [SessionNotFoundError, PermissionNotFoundError],
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.permission.reply",
+            summary: "Reply to pending permission request",
+            description: "Respond to a pending permission request owned by a session.",
+          }),
+        ),
+    )
+    .annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." }))

+ 6 - 7
packages/server/src/groups/project-copy.ts → packages/protocol/src/groups/project-copy.ts

@@ -1,8 +1,8 @@
-import { ProjectCopy } from "@opencode-ai/core/project/copy"
-import { ProjectV2 } from "@opencode-ai/core/project"
+import { ProjectCopy } from "@opencode-ai/schema/project-copy"
+import { Project } from "@opencode-ai/schema/project"
 import { Schema, Struct } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
-import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
+import { LocationQuery, locationQueryOpenApi } from "./location"
 
 const root = "/experimental/project/:projectID/copy"
 
@@ -23,7 +23,7 @@ const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields,
 export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
   .add(
     HttpApiEndpoint.post("projectCopy.create", root, {
-      params: { projectID: ProjectV2.ID },
+      params: { projectID: Project.ID },
       query: LocationQuery,
       payload: CreatePayload,
       success: ProjectCopy.Copy,
@@ -34,7 +34,7 @@ export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
   )
   .add(
     HttpApiEndpoint.delete("projectCopy.remove", root, {
-      params: { projectID: ProjectV2.ID },
+      params: { projectID: Project.ID },
       query: LocationQuery,
       payload: RemovePayload,
       success: HttpApiSchema.NoContent,
@@ -45,7 +45,7 @@ export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
   )
   .add(
     HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, {
-      params: { projectID: ProjectV2.ID },
+      params: { projectID: Project.ID },
       query: LocationQuery,
       success: HttpApiSchema.NoContent,
       error: ProjectCopyError,
@@ -54,4 +54,3 @@ export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
       .annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })),
   )
   .annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
-  .middleware(LocationMiddleware)

+ 6 - 7
packages/server/src/groups/provider.ts → packages/protocol/src/groups/provider.ts

@@ -1,15 +1,15 @@
-import { ProviderV2 } from "@opencode-ai/core/provider"
-import { Location } from "@opencode-ai/core/location"
+import { Provider } from "@opencode-ai/schema/provider"
+import { Location } from "@opencode-ai/schema/location"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { ProviderNotFoundError, ServiceUnavailableError } from "../errors"
-import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
+import { LocationQuery, locationQueryOpenApi } from "./location"
 
 export const ProviderGroup = HttpApiGroup.make("server.provider")
   .add(
     HttpApiEndpoint.get("provider.list", "/api/provider", {
       query: LocationQuery,
-      success: Location.response(Schema.Array(ProviderV2.Info)),
+      success: Location.response(Schema.Array(Provider.Info)),
       error: ServiceUnavailableError,
     })
       .annotateMerge(locationQueryOpenApi)
@@ -23,9 +23,9 @@ export const ProviderGroup = HttpApiGroup.make("server.provider")
   )
   .add(
     HttpApiEndpoint.get("provider.get", "/api/provider/:providerID", {
-      params: { providerID: ProviderV2.ID },
+      params: { providerID: Provider.ID },
       query: LocationQuery,
-      success: Location.response(ProviderV2.Info),
+      success: Location.response(Provider.Info),
       error: [ProviderNotFoundError, ServiceUnavailableError],
     })
       .annotateMerge(locationQueryOpenApi)
@@ -43,4 +43,3 @@ export const ProviderGroup = HttpApiGroup.make("server.provider")
       description: "Experimental provider routes.",
     }),
   )
-  .middleware(LocationMiddleware)

+ 9 - 11
packages/server/src/groups/pty.ts → packages/protocol/src/groups/pty.ts

@@ -1,11 +1,10 @@
-import { Pty } from "@opencode-ai/core/pty"
-import { PtyID } from "@opencode-ai/core/pty/schema"
-import { PtyTicket } from "@opencode-ai/core/pty/ticket"
-import { Location } from "@opencode-ai/core/location"
+import { Pty } from "@opencode-ai/schema/pty"
+import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
+import { Location } from "@opencode-ai/schema/location"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { ForbiddenError, PtyNotFoundError } from "../errors"
-import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
+import { LocationQuery, locationQueryOpenApi } from "./location"
 
 export const PTY_CONNECT_TICKET_QUERY = "ticket"
 export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket"
@@ -51,7 +50,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
   )
   .add(
     HttpApiEndpoint.get("pty.get", "/api/pty/:ptyID", {
-      params: { ptyID: PtyID },
+      params: { ptyID: Pty.ID },
       query: LocationQuery,
       success: Location.response(Pty.Info),
       error: PtyNotFoundError,
@@ -67,7 +66,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
   )
   .add(
     HttpApiEndpoint.put("pty.update", "/api/pty/:ptyID", {
-      params: { ptyID: PtyID },
+      params: { ptyID: Pty.ID },
       query: LocationQuery,
       payload: Pty.UpdateInput,
       success: Location.response(Pty.Info),
@@ -84,7 +83,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
   )
   .add(
     HttpApiEndpoint.delete("pty.remove", "/api/pty/:ptyID", {
-      params: { ptyID: PtyID },
+      params: { ptyID: Pty.ID },
       query: LocationQuery,
       success: HttpApiSchema.NoContent,
       error: PtyNotFoundError,
@@ -100,7 +99,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
   )
   .add(
     HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", {
-      params: { ptyID: PtyID },
+      params: { ptyID: Pty.ID },
       query: LocationQuery,
       success: Location.response(PtyTicket.ConnectToken),
       error: [ForbiddenError, PtyNotFoundError],
@@ -118,7 +117,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
     // Query fields are decoded in the raw handler after the existence check so a missing
     // session responds with an empty 404 before any upgrade work.
     HttpApiEndpoint.get("pty.connect", "/api/pty/:ptyID/connect", {
-      params: { ptyID: PtyID },
+      params: { ptyID: Pty.ID },
       success: Schema.Boolean,
       error: [ForbiddenError, PtyNotFoundError],
     }).annotateMerge(
@@ -141,4 +140,3 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
     ),
   )
   .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental location-scoped PTY routes." }))
-  .middleware(LocationMiddleware)

+ 84 - 0
packages/protocol/src/groups/question.ts

@@ -0,0 +1,84 @@
+import { Question } from "@opencode-ai/schema/question"
+import { Location } from "@opencode-ai/schema/location"
+import { Session } from "@opencode-ai/schema/session"
+import { Context, Schema } from "effect"
+import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import { QuestionNotFoundError, SessionNotFoundError } from "../errors"
+import { LocationQuery, locationQueryOpenApi } from "./location"
+
+export const makeQuestionGroup = <
+  LocationId extends HttpApiMiddleware.AnyId,
+  LocationService,
+  SessionLocationId extends HttpApiMiddleware.AnyId,
+  SessionLocationService,
+>(
+  locationMiddleware: Context.Key<LocationId, LocationService>,
+  sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
+) =>
+  HttpApiGroup.make("server.question")
+    .add(
+      HttpApiEndpoint.get("question.request.list", "/api/question/request", {
+        query: LocationQuery,
+        success: Location.response(Schema.Array(Question.Request)),
+      })
+        .annotateMerge(locationQueryOpenApi)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.question.request.list",
+            summary: "List pending question requests",
+            description: "Retrieve pending question requests for a location.",
+          }),
+        ),
+    )
+    .annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." }))
+    // Effect applies group middleware only to endpoints already added; session endpoints use session placement below.
+    .middleware(locationMiddleware)
+    .add(
+      HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", {
+        params: { sessionID: Session.ID },
+        success: Schema.Struct({ data: Schema.Array(Question.Request) }),
+        error: SessionNotFoundError,
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.question.list",
+            summary: "List session question requests",
+            description: "Retrieve pending question requests owned by a session.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
+        params: { sessionID: Session.ID, requestID: Question.ID },
+        payload: Question.Reply,
+        success: HttpApiSchema.NoContent,
+        error: [SessionNotFoundError, QuestionNotFoundError],
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.question.reply",
+            summary: "Reply to pending question request",
+            description: "Answer a pending question request owned by a session.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", {
+        params: { sessionID: Session.ID, requestID: Question.ID },
+        success: HttpApiSchema.NoContent,
+        error: [SessionNotFoundError, QuestionNotFoundError],
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.question.reject",
+            summary: "Reject pending question request",
+            description: "Reject a pending question request owned by a session.",
+          }),
+        ),
+    )
+    .annotateMerge(
+      OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }),
+    )

+ 3 - 4
packages/server/src/groups/reference.ts → packages/protocol/src/groups/reference.ts

@@ -1,8 +1,8 @@
-import { Location } from "@opencode-ai/core/location"
-import { Reference } from "@opencode-ai/core/reference"
+import { Location } from "@opencode-ai/schema/location"
+import { Reference } from "@opencode-ai/schema/reference"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
-import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
+import { LocationQuery, locationQueryOpenApi } from "./location"
 
 export const ReferenceGroup = HttpApiGroup.make("server.reference")
   .add(
@@ -25,4 +25,3 @@ export const ReferenceGroup = HttpApiGroup.make("server.reference")
       description: "Location-scoped project references.",
     }),
   )
-  .middleware(LocationMiddleware)

+ 280 - 0
packages/protocol/src/groups/session.ts

@@ -0,0 +1,280 @@
+import { SessionMessage } from "@opencode-ai/schema/session-message"
+import { SessionInput } from "@opencode-ai/schema/session-input"
+import { Prompt } from "@opencode-ai/schema/prompt"
+import { Session } from "@opencode-ai/schema/session"
+import { Project } from "@opencode-ai/schema/project"
+import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/schema/schema"
+import { Workspace } from "@opencode-ai/schema/workspace"
+import { Context, Encoding, Result, Schema, Struct } from "effect"
+import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import {
+  ConflictError,
+  InvalidCursorError,
+  InvalidRequestError,
+  MessageNotFoundError,
+  ServiceUnavailableError,
+  SessionNotFoundError,
+  UnknownError,
+} from "../errors"
+import { Agent } from "@opencode-ai/schema/agent"
+import { Model } from "@opencode-ai/schema/model"
+import { Location } from "@opencode-ai/schema/location"
+import { Revert } from "@opencode-ai/schema/revert"
+
+const SessionsQueryFields = {
+  workspace: Workspace.ID.pipe(Schema.optional),
+  limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
+    description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
+  }),
+  order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
+    description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
+  }),
+  search: Schema.optional(Schema.String),
+}
+
+const SessionsDirectoryQuery = Schema.Struct({
+  ...SessionsQueryFields,
+  directory: AbsolutePath,
+})
+
+const SessionsProjectQuery = Schema.Struct({
+  ...SessionsQueryFields,
+  project: Project.ID,
+  subpath: RelativePath.pipe(Schema.optional),
+})
+
+const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
+
+const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
+  schema.mapFields((fields) => ({
+    ...Struct.omit(fields, ["limit"]),
+    anchor: Session.ListAnchor,
+  }))
+
+const SessionsCursorInput = Schema.Union([
+  withCursor(SessionsDirectoryQuery),
+  withCursor(SessionsProjectQuery),
+  withCursor(SessionsAllQuery),
+])
+const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
+const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
+const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
+
+export const SessionsCursor = Schema.String.pipe(
+  Schema.brand("SessionsCursor"),
+  withStatics((schema) => {
+    const make = schema.make.bind(schema)
+    return {
+      make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
+      parse: (input: string) => decodeSessionsCursor(Result.getOrThrow(Encoding.decodeBase64UrlString(input))),
+    }
+  }),
+)
+export type SessionsCursor = typeof SessionsCursor.Type
+
+const SessionsQueryCursor = SessionsCursor.annotate({
+  description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
+})
+
+export const SessionsQuery = Schema.Struct({
+  ...SessionsQueryFields,
+  directory: AbsolutePath.pipe(Schema.optional),
+  project: Project.ID.pipe(Schema.optional),
+  subpath: RelativePath.pipe(Schema.optional),
+  cursor: SessionsQueryCursor.pipe(Schema.optional),
+}).annotate({ identifier: "SessionsQuery" })
+
+export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLocationMiddleware: Context.Key<I, S>) =>
+  HttpApiGroup.make("server.session")
+    .add(
+      HttpApiEndpoint.get("session.list", "/api/session", {
+        query: SessionsQuery,
+        success: Schema.Struct({
+          data: Schema.Array(Session.Info),
+          cursor: Schema.Struct({
+            previous: SessionsCursor.pipe(Schema.optional),
+            next: SessionsCursor.pipe(Schema.optional),
+          }),
+        }).annotate({ identifier: "SessionsResponse" }),
+        error: [InvalidCursorError, InvalidRequestError],
+      }).annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.session.list",
+          summary: "List sessions",
+          description:
+            "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
+        }),
+      ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.create", "/api/session", {
+        payload: Schema.Struct({
+          id: Session.ID.pipe(Schema.optional),
+          agent: Agent.ID.pipe(Schema.optional),
+          model: Model.Ref.pipe(Schema.optional),
+          location: Location.Ref.pipe(Schema.optional),
+        }),
+        success: Schema.Struct({ data: Session.Info }),
+      }).annotateMerge(
+        OpenApi.annotations({
+          identifier: "v2.session.create",
+          summary: "Create session",
+          description: "Create a session at the requested location.",
+        }),
+      ),
+    )
+    .add(
+      HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
+        params: { sessionID: Session.ID },
+        success: Schema.Struct({ data: Session.Info }),
+        error: SessionNotFoundError,
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.get",
+            summary: "Get session",
+            description: "Retrieve a session by ID.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", {
+        params: { sessionID: Session.ID },
+        payload: Schema.Struct({ agent: Agent.ID }),
+        success: HttpApiSchema.NoContent,
+        error: SessionNotFoundError,
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.switchAgent",
+            summary: "Switch session agent",
+            description: "Switch the agent used by subsequent provider turns.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", {
+        params: { sessionID: Session.ID },
+        payload: Schema.Struct({ model: Model.Ref }),
+        success: HttpApiSchema.NoContent,
+        error: SessionNotFoundError,
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.switchModel",
+            summary: "Switch session model",
+            description: "Switch the model used by subsequent provider turns.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
+        params: { sessionID: Session.ID },
+        payload: Schema.Struct({
+          id: SessionMessage.ID.pipe(Schema.optional),
+          prompt: Prompt,
+          delivery: SessionInput.Delivery.pipe(Schema.optional),
+          resume: Schema.Boolean.pipe(Schema.optional),
+        }),
+        success: Schema.Struct({ data: SessionInput.Admitted }),
+        error: [ConflictError, SessionNotFoundError],
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.prompt",
+            summary: "Send message",
+            description: "Durably admit one session input and schedule agent-loop execution unless resume is false.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
+        params: { sessionID: Session.ID },
+        success: HttpApiSchema.NoContent,
+        error: [SessionNotFoundError, ServiceUnavailableError],
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.compact",
+            summary: "Compact session",
+            description: "Compact a session conversation.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", {
+        params: { sessionID: Session.ID },
+        success: HttpApiSchema.NoContent,
+        error: [SessionNotFoundError, ServiceUnavailableError],
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.wait",
+            summary: "Wait for session",
+            description: "Wait for a session agent loop to become idle.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", {
+        params: { sessionID: Session.ID },
+        payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }),
+        success: Schema.Struct({ data: Revert.State }),
+        error: [MessageNotFoundError, SessionNotFoundError, UnknownError],
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.revert.stage",
+            summary: "Stage session revert",
+            description: "Stage or move a reversible session boundary and optionally apply its file changes.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", {
+        params: { sessionID: Session.ID },
+        success: HttpApiSchema.NoContent,
+        error: [SessionNotFoundError, UnknownError],
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })),
+    )
+    .add(
+      HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", {
+        params: { sessionID: Session.ID },
+        success: HttpApiSchema.NoContent,
+        error: SessionNotFoundError,
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({ identifier: "v2.session.revert.commit", summary: "Commit staged revert" }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
+        params: { sessionID: Session.ID },
+        success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }),
+        error: [SessionNotFoundError, UnknownError],
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.context",
+            summary: "Get session context",
+            description: "Retrieve the active context messages for a session (all messages after the last compaction).",
+          }),
+        ),
+    )
+    .annotateMerge(
+      OpenApi.annotations({
+        title: "sessions",
+        description: "Experimental session routes.",
+      }),
+    )

+ 4 - 5
packages/server/src/groups/skill.ts → packages/protocol/src/groups/skill.ts

@@ -1,14 +1,14 @@
-import { SkillV2 } from "@opencode-ai/core/skill"
-import { Location } from "@opencode-ai/core/location"
+import { Skill } from "@opencode-ai/schema/skill"
+import { Location } from "@opencode-ai/schema/location"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
-import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
+import { LocationQuery, locationQueryOpenApi } from "./location"
 
 export const SkillGroup = HttpApiGroup.make("server.skill")
   .add(
     HttpApiEndpoint.get("skill.list", "/api/skill", {
       query: LocationQuery,
-      success: Location.response(Schema.Array(SkillV2.Info)),
+      success: Location.response(Schema.Array(Skill.Info)),
     })
       .annotateMerge(locationQueryOpenApi)
       .annotateMerge(
@@ -25,4 +25,3 @@ export const SkillGroup = HttpApiGroup.make("server.skill")
       description: "Experimental skill routes.",
     }),
   )
-  .middleware(LocationMiddleware)

+ 6 - 0
packages/protocol/src/middleware/authorization.ts

@@ -0,0 +1,6 @@
+import { HttpApiMiddleware } from "effect/unstable/httpapi"
+import { UnauthorizedError } from "../errors"
+
+export class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/HttpApiAuthorization", {
+  error: UnauthorizedError,
+}) {}

+ 7 - 0
packages/protocol/src/middleware/schema-error.ts

@@ -0,0 +1,7 @@
+import { HttpApiMiddleware } from "effect/unstable/httpapi"
+import { InvalidRequestError } from "../errors"
+
+export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
+  "@opencode/HttpApiSchemaError",
+  { error: InvalidRequestError },
+) {}

+ 18 - 0
packages/protocol/test/session-cursor.test.ts

@@ -0,0 +1,18 @@
+import { describe, expect, test } from "bun:test"
+import { Effect } from "effect"
+import { SessionsCursor } from "../src/groups/session"
+import { Session } from "@opencode-ai/schema/session"
+
+describe("SessionsCursor", () => {
+  test("round trips without Node globals", async () => {
+    const input = {
+      workspace: undefined,
+      search: "protocol",
+      order: "desc" as const,
+      anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
+    }
+    const cursor = SessionsCursor.make(input)
+
+    expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input)
+  })
+})

+ 8 - 0
packages/protocol/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "@tsconfig/bun/tsconfig.json",
+  "compilerOptions": {
+    "lib": ["ESNext", "DOM", "DOM.Iterable"],
+    "noUncheckedIndexedAccess": false
+  }
+}

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

@@ -1,7 +1,7 @@
 export * as Credential from "./credential"
 
 import { Schema } from "effect"
-import { Integration } from "./integration"
+import { IntegrationMethodID } from "./integration-id"
 import { ascending } from "./identifier"
 import { NonNegativeInt, withStatics } from "./schema"
 
@@ -14,7 +14,7 @@ export type ID = typeof ID.Type
 export interface OAuth extends Schema.Schema.Type<typeof OAuth> {}
 export const OAuth = Schema.Struct({
   type: Schema.Literal("oauth"),
-  methodID: Integration.MethodID,
+  methodID: IntegrationMethodID,
   refresh: Schema.String,
   access: Schema.String,
   expires: NonNegativeInt,

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

@@ -31,3 +31,9 @@ export const Match = Schema.Struct({
   text: Schema.String,
   submatches: Schema.Array(Submatch),
 }).annotate({ identifier: "FileSystem.Match" })
+
+export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
+  query: Schema.String,
+  type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
+  limit: PositiveInt.pipe(Schema.optional),
+}) {}

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

@@ -9,7 +9,9 @@ export { LLM } from "./llm"
 export { Location } from "./location"
 export { Model } from "./model"
 export { Permission } from "./permission"
+export { PermissionSaved } from "./permission-saved"
 export { Project } from "./project"
+export { ProjectCopy } from "./project-copy"
 export { Provider } from "./provider"
 export { Reference } from "./reference"
 export { Revert } from "./revert"
@@ -17,6 +19,9 @@ export { Session } from "./session"
 export { SessionInput } from "./session-input"
 export { SessionMessage } from "./session-message"
 export { Skill } from "./skill"
+export { Pty } from "./pty"
+export { PtyTicket } from "./pty-ticket"
+export { Question } from "./question"
 export { Workspace } from "./workspace"
 export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt"
 export * from "./schema"

+ 7 - 0
packages/schema/src/integration-id.ts

@@ -0,0 +1,7 @@
+import { Schema } from "effect"
+
+export const IntegrationID = Schema.String.pipe(Schema.brand("Integration.ID"))
+export type IntegrationID = typeof IntegrationID.Type
+
+export const IntegrationMethodID = Schema.String.pipe(Schema.brand("Integration.MethodID"))
+export type IntegrationMethodID = typeof IntegrationMethodID.Type

+ 40 - 2
packages/schema/src/integration.ts

@@ -2,11 +2,15 @@ export * as Integration from "./integration"
 
 import { Schema } from "effect"
 import { define, inventory } from "./event"
+import { Connection } from "./connection"
+import { ascending } from "./identifier"
+import { withStatics } from "./schema"
+import { IntegrationID, IntegrationMethodID } from "./integration-id"
 
-export const ID = Schema.String.pipe(Schema.brand("Integration.ID"))
+export const ID = IntegrationID
 export type ID = typeof ID.Type
 
-export const MethodID = Schema.String.pipe(Schema.brand("Integration.MethodID"))
+export const MethodID = IntegrationMethodID
 export type MethodID = typeof MethodID.Type
 
 export interface When extends Schema.Schema.Type<typeof When> {}
@@ -88,3 +92,37 @@ export const Ref = Schema.Struct({
   id: ID,
   name: Schema.String,
 }).annotate({ identifier: "Integration.Ref" })
+
+export class Info extends Schema.Class<Info>("Integration.Info")({
+  id: ID,
+  name: Schema.String,
+  methods: Schema.mutable(Schema.Array(Method)),
+  connections: Schema.mutable(Schema.Array(Connection.Info)),
+}) {}
+
+export const AttemptID = Schema.String.pipe(
+  Schema.brand("Integration.AttemptID"),
+  withStatics((schema) => ({ create: () => schema.make("con_" + ascending()) })),
+)
+export type AttemptID = typeof AttemptID.Type
+
+const AttemptTime = Schema.Struct({
+  created: Schema.Number,
+  expires: Schema.Number,
+})
+
+export class Attempt extends Schema.Class<Attempt>("Integration.Attempt")({
+  attemptID: AttemptID,
+  url: Schema.String,
+  instructions: Schema.String,
+  mode: Schema.Literals(["auto", "code"]),
+  time: AttemptTime,
+}) {}
+
+export const AttemptStatus = Schema.Union([
+  Schema.Struct({ status: Schema.Literal("pending"), time: AttemptTime }),
+  Schema.Struct({ status: Schema.Literal("complete"), time: AttemptTime }),
+  Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: AttemptTime }),
+  Schema.Struct({ status: Schema.Literal("expired"), time: AttemptTime }),
+]).pipe(Schema.toTaggedUnion("status"))
+export type AttemptStatus = typeof AttemptStatus.Type

+ 15 - 1
packages/schema/src/location.ts

@@ -1,7 +1,8 @@
 export * as Location from "./location"
 
 import { Effect, Schema } from "effect"
-import { AbsolutePath } from "./schema"
+import { AbsolutePath, optionalOmitUndefined } from "./schema"
+import { ProjectID } from "./project-id"
 import { WorkspaceID } from "./workspace-id"
 
 export interface Ref extends Schema.Schema.Type<typeof Ref> {}
@@ -12,3 +13,16 @@ export const Ref = Schema.Struct({
     Schema.withConstructorDefault(Effect.succeed(undefined)),
   ),
 }).annotate({ identifier: "Location.Ref" })
+
+export class Info extends Schema.Class<Info>("Location.Info")({
+  directory: AbsolutePath,
+  workspaceID: optionalOmitUndefined(WorkspaceID),
+  project: Schema.Struct({
+    id: ProjectID,
+    directory: AbsolutePath,
+  }),
+}) {}
+
+export function response<S extends Schema.Top>(data: S) {
+  return Schema.Struct({ location: Info, data })
+}

+ 20 - 0
packages/schema/src/permission-saved.ts

@@ -0,0 +1,20 @@
+export * as PermissionSaved from "./permission-saved"
+
+import { Schema } from "effect"
+import { ascending } from "./identifier"
+import { ProjectID } from "./project-id"
+import { withStatics } from "./schema"
+
+export const ID = Schema.String.pipe(
+  Schema.brand("PermissionSaved.ID"),
+  withStatics((schema) => ({ create: () => schema.make("psv_" + ascending()) })),
+)
+export type ID = typeof ID.Type
+
+export const Info = Schema.Struct({
+  id: ID,
+  projectID: ProjectID,
+  action: Schema.String,
+  resource: Schema.String,
+}).annotate({ identifier: "PermissionSaved.Info" })
+export type Info = typeof Info.Type

+ 29 - 0
packages/schema/src/project-copy.ts

@@ -0,0 +1,29 @@
+export * as ProjectCopy from "./project-copy"
+
+import { Schema } from "effect"
+import { ProjectID } from "./project-id"
+import { AbsolutePath } from "./schema"
+
+export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
+export type StrategyID = typeof StrategyID.Type
+
+export const CreateInput = Schema.Struct({
+  projectID: ProjectID,
+  strategy: StrategyID,
+  sourceDirectory: AbsolutePath,
+  directory: AbsolutePath,
+  name: Schema.optional(Schema.String),
+}).annotate({ identifier: "ProjectCopy.CreateInput" })
+export type CreateInput = typeof CreateInput.Type
+
+export const RemoveInput = Schema.Struct({
+  projectID: ProjectID,
+  directory: AbsolutePath,
+  force: Schema.Boolean,
+}).annotate({ identifier: "ProjectCopy.RemoveInput" })
+export type RemoveInput = typeof RemoveInput.Type
+
+export const Copy = Schema.Struct({
+  directory: AbsolutePath,
+}).annotate({ identifier: "ProjectCopy.Copy" })
+export type Copy = typeof Copy.Type

+ 8 - 0
packages/schema/src/project-id.ts

@@ -0,0 +1,8 @@
+import { Schema } from "effect"
+import { withStatics } from "./schema"
+
+export const ProjectID = Schema.String.pipe(
+  Schema.brand("Project.ID"),
+  withStatics((schema) => ({ global: schema.make("global") })),
+)
+export type ProjectID = typeof ProjectID.Type

+ 3 - 5
packages/schema/src/project.ts

@@ -2,12 +2,10 @@ export * as Project from "./project"
 
 import { Schema } from "effect"
 import { define, inventory } from "./event"
-import { NonNegativeInt, optionalOmitUndefined, withStatics } from "./schema"
+import { NonNegativeInt, optionalOmitUndefined } from "./schema"
+import { ProjectID } from "./project-id"
 
-export const ID = Schema.String.pipe(
-  Schema.brand("Project.ID"),
-  withStatics((schema) => ({ global: schema.make("global") })),
-)
+export const ID = ProjectID
 export type ID = typeof ID.Type
 
 export const Vcs = Schema.Literal("git")

+ 9 - 0
packages/schema/src/pty-ticket.ts

@@ -0,0 +1,9 @@
+export * as PtyTicket from "./pty-ticket"
+
+import { Schema } from "effect"
+import { PositiveInt } from "./schema"
+
+export const ConnectToken = Schema.Struct({
+  ticket: Schema.String,
+  expires_in: PositiveInt,
+})

+ 21 - 1
packages/schema/src/pty.ts

@@ -3,7 +3,7 @@ export * as Pty from "./pty"
 import { Schema } from "effect"
 import { define, inventory } from "./event"
 import { ascending } from "./identifier"
-import { NonNegativeInt } from "./schema"
+import { NonNegativeInt, PositiveInt } from "./schema"
 import { withStatics } from "./schema"
 
 const IDSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
@@ -33,3 +33,23 @@ const Exited = define({ type: "pty.exited", schema: { id: ID, exitCode: NonNegat
 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
+
+export const CreateInput = Schema.Struct({
+  command: Schema.optional(Schema.String),
+  args: Schema.optional(Schema.Array(Schema.String)),
+  cwd: Schema.optional(Schema.String),
+  title: Schema.optional(Schema.String),
+  env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+})
+export type CreateInput = typeof CreateInput.Type
+
+export const UpdateInput = Schema.Struct({
+  title: Schema.optional(Schema.String),
+  size: Schema.optional(
+    Schema.Struct({
+      rows: PositiveInt,
+      cols: PositiveInt,
+    }),
+  ),
+})
+export type UpdateInput = typeof UpdateInput.Type

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

@@ -26,3 +26,11 @@ export const GitSource = Schema.Struct({
 
 export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type"))
 export type Source = typeof Source.Type
+
+export class Info extends Schema.Class<Info>("Reference.Info")({
+  name: Schema.String,
+  path: AbsolutePath,
+  description: Schema.String.pipe(Schema.optional),
+  hidden: Schema.Boolean.pipe(Schema.optional),
+  source: Source,
+}) {}

+ 10 - 0
packages/schema/test/compatibility.test.ts

@@ -0,0 +1,10 @@
+import { describe, expect, test } from "bun:test"
+import { FileSystem } from "../src/filesystem"
+
+describe("schema compatibility", () => {
+  test("moved class schemas remain constructible", () => {
+    const input = new FileSystem.FindInput({ query: "src" })
+    expect(input).toBeInstanceOf(FileSystem.FindInput)
+    expect(input.query).toBe("src")
+  })
+})

+ 1 - 0
packages/server/package.json

@@ -13,6 +13,7 @@
   },
   "dependencies": {
     "@opencode-ai/core": "workspace:*",
+    "@opencode-ai/protocol": "workspace:*",
     "drizzle-orm": "catalog:",
     "effect": "catalog:"
   },

+ 7 - 55
packages/server/src/api.ts

@@ -1,56 +1,8 @@
-import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
-import { SchemaErrorMiddleware } from "./middleware/schema-error"
-import { MessageGroup } from "./groups/message"
-import { ModelGroup } from "./groups/model"
-import { ProviderGroup } from "./groups/provider"
-import { SessionGroup } from "./groups/session"
-import { PermissionGroup } from "./groups/permission"
-import { FileSystemGroup } from "./groups/fs"
-import { CommandGroup } from "./groups/command"
-import { SkillGroup } from "./groups/skill"
-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"
-import { QuestionGroup } from "./groups/question"
-import { ReferenceGroup } from "./groups/reference"
-import { Authorization } from "./middleware/authorization"
-import { LocationGroup } from "./groups/location"
-import { IntegrationGroup } from "./groups/integration"
-import { CredentialGroup } from "./groups/credential"
-import { ProjectCopyGroup } from "./groups/project-copy"
+import { makeDefaultApi } from "@opencode-ai/protocol/api"
+import { LocationMiddleware } from "./location"
+import { SessionLocationMiddleware } from "./middleware/session-location"
 
-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)
+export const Api = makeDefaultApi({
+  locationMiddleware: LocationMiddleware,
+  sessionLocationMiddleware: SessionLocationMiddleware,
+})

+ 0 - 22
packages/server/src/groups/agent.ts

@@ -1,22 +0,0 @@
-import { AgentV2 } from "@opencode-ai/core/agent"
-import { Location } from "@opencode-ai/core/location"
-import { Schema } from "effect"
-import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
-import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
-
-export const AgentGroup = HttpApiGroup.make("server.agent")
-  .add(
-    HttpApiEndpoint.get("agent.list", "/api/agent", {
-      query: LocationQuery,
-      success: Location.response(Schema.Array(AgentV2.Info)),
-    })
-      .annotateMerge(locationQueryOpenApi)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.agent.list",
-          summary: "List agents",
-          description: "Retrieve currently registered agents.",
-        }),
-      ),
-  )
-  .middleware(LocationMiddleware)

+ 0 - 65
packages/server/src/groups/event.ts

@@ -1,65 +0,0 @@
-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"
-
-const fields = {
-  id: EventV2.ID,
-  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
-  durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })),
-  location: Schema.optional(Location.Ref),
-}
-
-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: EventSchema,
-    }).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." }))
-export type Event = typeof EventSchema.Type

+ 0 - 86
packages/server/src/groups/permission.ts

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

+ 0 - 75
packages/server/src/groups/question.ts

@@ -1,75 +0,0 @@
-import { QuestionV2 } from "@opencode-ai/core/question"
-import { Location } from "@opencode-ai/core/location"
-import { SessionV2 } from "@opencode-ai/core/session"
-import { Schema } from "effect"
-import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
-import { QuestionNotFoundError, SessionNotFoundError } from "../errors"
-import { SessionLocationMiddleware } from "../middleware/session-location"
-import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
-
-export const QuestionGroup = HttpApiGroup.make("server.question")
-  .add(
-    HttpApiEndpoint.get("question.request.list", "/api/question/request", {
-      query: LocationQuery,
-      success: Location.response(Schema.Array(QuestionV2.Request)),
-    })
-      .annotateMerge(locationQueryOpenApi)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.question.request.list",
-          summary: "List pending question requests",
-          description: "Retrieve pending question requests for a location.",
-        }),
-      ),
-  )
-  .annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." }))
-  .middleware(LocationMiddleware)
-  .add(
-    HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", {
-      params: { sessionID: SessionV2.ID },
-      success: Schema.Struct({ data: Schema.Array(QuestionV2.Request) }),
-      error: SessionNotFoundError,
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.session.question.list",
-          summary: "List session question requests",
-          description: "Retrieve pending question requests owned by a session.",
-        }),
-      ),
-  )
-  .add(
-    HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
-      params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
-      payload: QuestionV2.Reply,
-      success: HttpApiSchema.NoContent,
-      error: [SessionNotFoundError, QuestionNotFoundError],
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.session.question.reply",
-          summary: "Reply to pending question request",
-          description: "Answer a pending question request owned by a session.",
-        }),
-      ),
-  )
-  .add(
-    HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", {
-      params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
-      success: HttpApiSchema.NoContent,
-      error: [SessionNotFoundError, QuestionNotFoundError],
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.session.question.reject",
-          summary: "Reject pending question request",
-          description: "Reject a pending question request owned by a session.",
-        }),
-      ),
-  )
-  .annotateMerge(
-    OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }),
-  )

+ 0 - 281
packages/server/src/groups/session.ts

@@ -1,281 +0,0 @@
-import { SessionMessage } from "@opencode-ai/core/session/message"
-import { SessionInput } from "@opencode-ai/core/session/input"
-import { Prompt } from "@opencode-ai/core/session/prompt"
-import { SessionV2 } from "@opencode-ai/core/session"
-import { ProjectV2 } from "@opencode-ai/core/project"
-import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema"
-import { WorkspaceV2 } from "@opencode-ai/core/workspace"
-import { Schema, Struct } from "effect"
-import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
-import {
-  ConflictError,
-  InvalidCursorError,
-  InvalidRequestError,
-  MessageNotFoundError,
-  ServiceUnavailableError,
-  SessionNotFoundError,
-  UnknownError,
-} from "../errors"
-import { SessionLocationMiddleware } from "../middleware/session-location"
-import { AgentV2 } from "@opencode-ai/core/agent"
-import { ModelV2 } from "@opencode-ai/core/model"
-import { Location } from "@opencode-ai/core/location"
-
-const SessionsQueryFields = {
-  workspace: WorkspaceV2.ID.pipe(Schema.optional),
-  limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
-    description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
-  }),
-  order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
-    description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
-  }),
-  search: Schema.optional(Schema.String),
-}
-
-const SessionsDirectoryQuery = Schema.Struct({
-  ...SessionsQueryFields,
-  directory: AbsolutePath,
-})
-
-const SessionsProjectQuery = Schema.Struct({
-  ...SessionsQueryFields,
-  project: ProjectV2.ID,
-  subpath: RelativePath.pipe(Schema.optional),
-})
-
-const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
-
-const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
-  schema.mapFields((fields) => ({
-    ...Struct.omit(fields, ["limit"]),
-    anchor: SessionV2.ListAnchor,
-  }))
-
-const SessionsCursorInput = Schema.Union([
-  withCursor(SessionsDirectoryQuery),
-  withCursor(SessionsProjectQuery),
-  withCursor(SessionsAllQuery),
-])
-const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
-const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
-const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
-
-export const SessionsCursor = Schema.String.pipe(
-  Schema.brand("SessionsCursor"),
-  withStatics((schema) => {
-    const make = schema.make
-    return {
-      make: (input: typeof SessionsCursorInput.Type) =>
-        make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")),
-      parse: (input: string) => decodeSessionsCursor(Buffer.from(input, "base64url").toString("utf8")),
-    }
-  }),
-)
-export type SessionsCursor = typeof SessionsCursor.Type
-
-const SessionsCursorQuery = Schema.Struct({
-  cursor: SessionsCursor.annotate({
-    description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
-  }),
-  limit: SessionsQueryFields.limit,
-})
-
-export const SessionsQuery = Schema.Struct({
-  ...SessionsQueryFields,
-  directory: AbsolutePath.pipe(Schema.optional),
-  project: ProjectV2.ID.pipe(Schema.optional),
-  subpath: RelativePath.pipe(Schema.optional),
-  cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional),
-}).annotate({ identifier: "SessionsQuery" })
-
-export const SessionGroup = HttpApiGroup.make("server.session")
-  .add(
-    HttpApiEndpoint.get("session.list", "/api/session", {
-      query: SessionsQuery,
-      success: Schema.Struct({
-        data: Schema.Array(SessionV2.Info),
-        cursor: Schema.Struct({
-          previous: SessionsCursor.pipe(Schema.optional),
-          next: SessionsCursor.pipe(Schema.optional),
-        }),
-      }).annotate({ identifier: "SessionsResponse" }),
-      error: [InvalidCursorError, InvalidRequestError],
-    }).annotateMerge(
-      OpenApi.annotations({
-        identifier: "v2.session.list",
-        summary: "List sessions",
-        description:
-          "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
-      }),
-    ),
-  )
-  .add(
-    HttpApiEndpoint.post("session.create", "/api/session", {
-      payload: Schema.Struct({
-        id: SessionV2.ID.pipe(Schema.optional),
-        agent: AgentV2.ID.pipe(Schema.optional),
-        model: ModelV2.Ref.pipe(Schema.optional),
-        location: Location.Ref.pipe(Schema.optional),
-      }),
-      success: Schema.Struct({ data: SessionV2.Info }),
-    }).annotateMerge(
-      OpenApi.annotations({
-        identifier: "v2.session.create",
-        summary: "Create session",
-        description: "Create a session at the requested location.",
-      }),
-    ),
-  )
-  .add(
-    HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
-      params: { sessionID: SessionV2.ID },
-      success: Schema.Struct({ data: SessionV2.Info }),
-      error: SessionNotFoundError,
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.session.get",
-          summary: "Get session",
-          description: "Retrieve a session by ID.",
-        }),
-      ),
-  )
-  .add(
-    HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", {
-      params: { sessionID: SessionV2.ID },
-      payload: Schema.Struct({ agent: AgentV2.ID }),
-      success: HttpApiSchema.NoContent,
-      error: SessionNotFoundError,
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.session.switchAgent",
-          summary: "Switch session agent",
-          description: "Switch the agent used by subsequent provider turns.",
-        }),
-      ),
-  )
-  .add(
-    HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", {
-      params: { sessionID: SessionV2.ID },
-      payload: Schema.Struct({ model: ModelV2.Ref }),
-      success: HttpApiSchema.NoContent,
-      error: SessionNotFoundError,
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.session.switchModel",
-          summary: "Switch session model",
-          description: "Switch the model used by subsequent provider turns.",
-        }),
-      ),
-  )
-  .add(
-    HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
-      params: { sessionID: SessionV2.ID },
-      payload: Schema.Struct({
-        id: SessionMessage.ID.pipe(Schema.optional),
-        prompt: Prompt,
-        delivery: SessionInput.Delivery.pipe(Schema.optional),
-        resume: Schema.Boolean.pipe(Schema.optional),
-      }),
-      success: Schema.Struct({ data: SessionInput.Admitted }),
-      error: [ConflictError, SessionNotFoundError],
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.session.prompt",
-          summary: "Send message",
-          description: "Durably admit one session input and schedule agent-loop execution unless resume is false.",
-        }),
-      ),
-  )
-  .add(
-    HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
-      params: { sessionID: SessionV2.ID },
-      success: HttpApiSchema.NoContent,
-      error: [SessionNotFoundError, ServiceUnavailableError],
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.session.compact",
-          summary: "Compact session",
-          description: "Compact a session conversation.",
-        }),
-      ),
-  )
-  .add(
-    HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", {
-      params: { sessionID: SessionV2.ID },
-      success: HttpApiSchema.NoContent,
-      error: [SessionNotFoundError, ServiceUnavailableError],
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.session.wait",
-          summary: "Wait for session",
-          description: "Wait for a session agent loop to become idle.",
-        }),
-      ),
-  )
-  .add(
-    HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", {
-      params: { sessionID: SessionV2.ID },
-      payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }),
-      success: Schema.Struct({ data: SessionV2.RevertState }),
-      error: [MessageNotFoundError, SessionNotFoundError, UnknownError],
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.session.revert.stage",
-          summary: "Stage session revert",
-          description: "Stage or move a reversible session boundary and optionally apply its file changes.",
-        }),
-      ),
-  )
-  .add(
-    HttpApiEndpoint.post("session.revert.clear", "/api/session/:sessionID/revert/clear", {
-      params: { sessionID: SessionV2.ID },
-      success: HttpApiSchema.NoContent,
-      error: [SessionNotFoundError, UnknownError],
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })),
-  )
-  .add(
-    HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", {
-      params: { sessionID: SessionV2.ID },
-      success: HttpApiSchema.NoContent,
-      error: SessionNotFoundError,
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.commit", summary: "Commit staged revert" })),
-  )
-  .add(
-    HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
-      params: { sessionID: SessionV2.ID },
-      success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }),
-      error: [SessionNotFoundError, UnknownError],
-    })
-      .middleware(SessionLocationMiddleware)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.session.context",
-          summary: "Get session context",
-          description: "Retrieve the active context messages for a session (all messages after the last compaction).",
-        }),
-      ),
-  )
-  .annotateMerge(
-    OpenApi.annotations({
-      title: "sessions",
-      description: "Experimental session routes.",
-    }),
-  )

+ 1 - 1
packages/server/src/handlers.ts

@@ -3,7 +3,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { PermissionSaved } from "@opencode-ai/core/permission/saved"
 import { PtyTicket } from "@opencode-ai/core/pty/ticket"
 import { Layer } from "effect"
-import { layer as locationLayer } from "./groups/location"
+import { layer as locationLayer } from "./location"
 import { sessionLocationLayer } from "./middleware/session-location"
 import { MessageHandler } from "./handlers/message"
 import { ModelHandler } from "./handlers/model"

+ 1 - 1
packages/server/src/handlers/agent.ts

@@ -2,7 +2,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
 import { Effect } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { response } from "../groups/location"
+import { response } from "../location"
 
 export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) =>
   handlers.handle("agent.list", () =>

+ 1 - 2
packages/server/src/handlers/command.ts

@@ -1,8 +1,7 @@
 import { CommandV2 } from "@opencode-ai/core/command"
-import { Effect } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { response } from "../groups/location"
+import { response } from "../location"
 
 export const CommandHandler = HttpApiBuilder.group(Api, "server.command", (handlers) =>
   handlers.handle("command.list", () => response(CommandV2.Service.use((command) => command.list()))),

+ 1 - 1
packages/server/src/handlers/fs.ts

@@ -4,7 +4,7 @@ import { Effect } from "effect"
 import { HttpServerResponse } from "effect/unstable/http"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { response } from "../groups/location"
+import { response } from "../location"
 
 export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
   Effect.gen(function* () {

+ 2 - 2
packages/server/src/handlers/integration.ts

@@ -2,8 +2,8 @@ import { Integration } from "@opencode-ai/core/integration"
 import { Effect } from "effect"
 import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { InvalidRequestError } from "../errors"
-import { response } from "../groups/location"
+import { InvalidRequestError } from "@opencode-ai/protocol/errors"
+import { response } from "../location"
 
 const authorize = <A, R>(effect: Effect.Effect<A, Integration.AuthorizationError, R>) =>
   effect.pipe(

+ 1 - 1
packages/server/src/handlers/message.ts

@@ -3,7 +3,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
 import { Effect, Schema } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors"
+import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
 
 const DefaultMessagesLimit = 50
 

+ 1 - 1
packages/server/src/handlers/model.ts

@@ -2,7 +2,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
 import { Effect } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { response } from "../groups/location"
+import { response } from "../location"
 
 export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) =>
   Effect.gen(function* () {

+ 2 - 2
packages/server/src/handlers/permission.ts

@@ -4,8 +4,8 @@ import { PermissionSaved } from "@opencode-ai/core/permission/saved"
 import { Effect } from "effect"
 import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { PermissionNotFoundError } from "../errors"
-import { response } from "../groups/location"
+import { PermissionNotFoundError } from "@opencode-ai/protocol/errors"
+import { response } from "../location"
 
 function missingRequest(id: PermissionV2.ID) {
   return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })

+ 1 - 1
packages/server/src/handlers/project-copy.ts

@@ -4,7 +4,7 @@ import { Git } from "@opencode-ai/core/git"
 import { Effect } from "effect"
 import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { ProjectCopyError } from "../groups/project-copy"
+import { ProjectCopyError } from "@opencode-ai/protocol/groups/project-copy"
 
 export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) =>
   Effect.succeed(

+ 2 - 3
packages/server/src/handlers/provider.ts

@@ -1,10 +1,9 @@
 import { Catalog } from "@opencode-ai/core/catalog"
-import { ProviderV2 } from "@opencode-ai/core/provider"
 import { Effect } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { ProviderNotFoundError } from "../errors"
-import { response } from "../groups/location"
+import { ProviderNotFoundError } from "@opencode-ai/protocol/errors"
+import { response } from "../location"
 
 export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) =>
   Effect.gen(function* () {

+ 7 - 3
packages/server/src/handlers/pty.ts

@@ -8,9 +8,13 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
 import * as Socket from "effect/unstable/socket/Socket"
 import { Api } from "../api"
 import { CorsConfig, isAllowedRequestOrigin } from "../cors"
-import { ForbiddenError, PtyNotFoundError } from "../errors"
-import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE } from "../groups/pty"
-import { response } from "../groups/location"
+import { ForbiddenError, PtyNotFoundError } from "@opencode-ai/protocol/errors"
+import {
+  PTY_CONNECT_TICKET_QUERY,
+  PTY_CONNECT_TOKEN_HEADER,
+  PTY_CONNECT_TOKEN_HEADER_VALUE,
+} from "@opencode-ai/protocol/groups/pty"
+import { response } from "../location"
 import { PtyEnvironment } from "../pty-environment"
 
 const ticketScope = Effect.gen(function* () {

+ 2 - 2
packages/server/src/handlers/question.ts

@@ -2,8 +2,8 @@ import { QuestionV2 } from "@opencode-ai/core/question"
 import { Effect } from "effect"
 import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { QuestionNotFoundError } from "../errors"
-import { response } from "../groups/location"
+import { QuestionNotFoundError } from "@opencode-ai/protocol/errors"
+import { response } from "../location"
 
 function missingRequest(id: QuestionV2.ID) {
   return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })

+ 1 - 1
packages/server/src/handlers/reference.ts

@@ -1,7 +1,7 @@
 import { Reference } from "@opencode-ai/core/reference"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { response } from "../groups/location"
+import { response } from "../location"
 
 export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) =>
   handlers.handle("reference.list", () => response(Reference.Service.use((reference) => reference.list()))),

+ 2 - 2
packages/server/src/handlers/session.ts

@@ -2,7 +2,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
 import { DateTime, Effect } from "effect"
 import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { SessionsCursor } from "../groups/session"
+import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
 import {
   ConflictError,
   InvalidCursorError,
@@ -10,7 +10,7 @@ import {
   ServiceUnavailableError,
   SessionNotFoundError,
   UnknownError,
-} from "../errors"
+} from "@opencode-ai/protocol/errors"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 
 const DefaultSessionsLimit = 50

+ 1 - 1
packages/server/src/handlers/skill.ts

@@ -1,7 +1,7 @@
 import { SkillV2 } from "@opencode-ai/core/skill"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { Api } from "../api"
-import { response } from "../groups/location"
+import { response } from "../location"
 
 export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) =>
   handlers.handle("skill.list", () => response(SkillV2.Service.use((skill) => skill.list()))),

+ 6 - 51
packages/server/src/groups/location.ts → packages/server/src/location.ts

@@ -1,35 +1,16 @@
 import { Location } from "@opencode-ai/core/location"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
-import { FileSystem } from "@opencode-ai/core/filesystem"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { WorkspaceV2 } from "@opencode-ai/core/workspace"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Layer } from "effect"
 import { HttpServerRequest } from "effect/unstable/http"
-import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
+import { HttpApiMiddleware } from "effect/unstable/httpapi"
 
-export const LocationQuery = Schema.Struct({
-  location: Schema.optional(
-    Schema.Struct({
-      directory: Schema.optional(Schema.String),
-      workspace: Schema.optional(Schema.String),
-    }),
-  ),
-}).annotate({ identifier: "LocationQuery" })
+export type LocationServices = Layer.Success<ReturnType<(typeof LocationServiceMap)["get"]>>
 
-export const locationQueryOpenApi = OpenApi.annotations({
-  transform: (operation) => {
-    const parameters = operation.parameters
-    if (!Array.isArray(parameters)) return operation
-    return {
-      ...operation,
-      parameters: parameters.map((parameter) =>
-        parameter?.name === "location" && parameter?.in === "query"
-          ? { ...parameter, style: "deepObject", explode: true }
-          : parameter,
-      ),
-    }
-  },
-})
+export class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware, { provides: LocationServices }>()(
+  "@opencode/HttpApiLocation",
+) {}
 
 export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
   return Effect.gen(function* () {
@@ -45,32 +26,6 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
   })
 }
 
-export type LocationServices = Layer.Success<ReturnType<typeof LocationServiceMap.get>>
-
-export class LocationMiddleware extends HttpApiMiddleware.Service<
-  LocationMiddleware,
-  {
-    provides: LocationServices
-  }
->()("@opencode/HttpApiLocation") {}
-
-export const LocationGroup = HttpApiGroup.make("server.location")
-  .add(
-    HttpApiEndpoint.get("location.get", "/api/location", {
-      query: LocationQuery,
-      success: Location.Info,
-    })
-      .annotateMerge(locationQueryOpenApi)
-      .annotateMerge(
-        OpenApi.annotations({
-          identifier: "v2.location.get",
-          summary: "Get location",
-          description: "Resolve the requested location or the server default location.",
-        }),
-      ),
-  )
-  .middleware(LocationMiddleware)
-
 function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
   const query = new URL(request.url, "http://localhost").searchParams
   const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]

+ 4 - 7
packages/server/src/middleware/authorization.ts

@@ -1,17 +1,14 @@
 import { ServerAuth } from "../auth"
-import { UnauthorizedError } from "../errors"
-import { hasPtyConnectTicketURL } from "../groups/pty"
+import { UnauthorizedError } from "@opencode-ai/protocol/errors"
+import { Authorization } from "@opencode-ai/protocol/middleware/authorization"
+export { Authorization } from "@opencode-ai/protocol/middleware/authorization"
+import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
 import { Effect, Encoding, Layer, Redacted } from "effect"
 import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
-import { HttpApiMiddleware } from "effect/unstable/httpapi"
 
 const AUTH_TOKEN_QUERY = "auth_token"
 const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
 
-export class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/HttpApiAuthorization", {
-  error: UnauthorizedError,
-}) {}
-
 function emptyCredential() {
   return { username: "", password: Redacted.make("") }
 }

+ 3 - 6
packages/server/src/middleware/schema-error.ts

@@ -1,6 +1,8 @@
 import { Effect } from "effect"
 import { HttpApiMiddleware } from "effect/unstable/httpapi"
-import { InvalidRequestError } from "../errors"
+import { InvalidRequestError } from "@opencode-ai/protocol/errors"
+import { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error"
+export { SchemaErrorMiddleware } from "@opencode-ai/protocol/middleware/schema-error"
 
 const REASON_LIMIT = 1024
 
@@ -9,11 +11,6 @@ function truncateReason(reason: string) {
   return reason.slice(0, REASON_LIMIT) + `... (${reason.length - REASON_LIMIT} more chars)`
 }
 
-export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
-  "@opencode/HttpApiSchemaError",
-  { error: InvalidRequestError },
-) {}
-
 export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => {
   const reason = truncateReason(error.cause.message)
   return Effect.logWarning("schema rejection").pipe(

+ 3 - 5
packages/server/src/middleware/session-location.ts

@@ -9,14 +9,12 @@ import { eq } from "drizzle-orm"
 import { Effect, Layer, Schema } from "effect"
 import { HttpRouter } from "effect/unstable/http"
 import { HttpApiMiddleware } from "effect/unstable/httpapi"
-import { InvalidRequestError, SessionNotFoundError } from "../errors"
-import type { LocationServices } from "../groups/location"
+import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
+import type { LocationServices } from "../location"
 
 export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
   SessionLocationMiddleware,
-  {
-    provides: LocationServices
-  }
+  { provides: LocationServices }
 >()("@opencode/HttpApiSessionLocation", {
   error: [InvalidRequestError, SessionNotFoundError],
 }) {}