فهرست منبع

feat(sdk): add HttpApi clients and embedded host (#33445)

Kit Langton 1 ماه پیش
والد
کامیت
cdd67cf30f
59فایلهای تغییر یافته به همراه4003 افزوده شده و 316 حذف شده
  1. 5 0
      .github/workflows/test.yml
  2. 77 0
      CONTEXT.md
  3. 55 0
      bun.lock
  4. 27 0
      packages/client/README.md
  5. 39 0
      packages/client/package.json
  6. 23 0
      packages/client/script/build.ts
  7. 19 0
      packages/client/src/contract.ts
  8. 12 0
      packages/client/src/effect.ts
  9. 5 0
      packages/client/src/generated-effect/.httpapi-codegen.json
  10. 5 0
      packages/client/src/generated-effect/client-error.ts
  11. 164 0
      packages/client/src/generated-effect/client.ts
  12. 2 0
      packages/client/src/generated-effect/index.ts
  13. 6 0
      packages/client/src/generated/.httpapi-codegen.json
  14. 11 0
      packages/client/src/generated/client-error.ts
  15. 349 0
      packages/client/src/generated/client.ts
  16. 3 0
      packages/client/src/generated/index.ts
  17. 631 0
      packages/client/src/generated/types.ts
  18. 1 0
      packages/client/src/index.ts
  19. 60 0
      packages/client/test/contract-identity.test.ts
  20. 98 0
      packages/client/test/effect.test.ts
  21. 68 0
      packages/client/test/import-boundaries.test.ts
  22. 117 0
      packages/client/test/promise.test.ts
  23. 9 0
      packages/client/tsconfig.json
  24. 0 1
      packages/core/package.json
  25. 0 6
      packages/core/src/public/agent.ts
  26. 0 9
      packages/core/src/public/index.ts
  27. 0 6
      packages/core/src/public/location.ts
  28. 0 9
      packages/core/src/public/model.ts
  29. 0 76
      packages/core/src/public/opencode.ts
  30. 0 96
      packages/core/src/public/session.ts
  31. 0 17
      packages/core/src/public/tool.ts
  32. 1 1
      packages/core/test/application-tools.test.ts
  33. 1 1
      packages/core/test/location-layer.test.ts
  34. 0 76
      packages/core/test/public-opencode.test.ts
  35. 0 13
      packages/core/test/public-tool.test.ts
  36. 42 0
      packages/httpapi-codegen/README.md
  37. 22 0
      packages/httpapi-codegen/package.json
  38. 521 0
      packages/httpapi-codegen/src/index.ts
  39. 28 0
      packages/httpapi-codegen/test/effect.ts
  40. 45 0
      packages/httpapi-codegen/test/fixture.ts
  41. 897 0
      packages/httpapi-codegen/test/generate.test.ts
  42. 28 0
      packages/httpapi-codegen/test/generated-consumer.ts
  43. 5 0
      packages/httpapi-codegen/test/generated/client-error.ts
  44. 16 0
      packages/httpapi-codegen/test/generated/client.ts
  45. 39 0
      packages/httpapi-codegen/test/generated/event.ts
  46. 2 0
      packages/httpapi-codegen/test/generated/index.ts
  47. 96 0
      packages/httpapi-codegen/test/generated/session.ts
  48. 25 0
      packages/httpapi-codegen/test/generated/system.ts
  49. 160 0
      packages/httpapi-codegen/test/write.test.ts
  50. 8 0
      packages/httpapi-codegen/tsconfig.json
  51. 27 0
      packages/sdk-next/README.md
  52. 25 0
      packages/sdk-next/package.json
  53. 16 0
      packages/sdk-next/src/index.ts
  54. 53 0
      packages/sdk-next/src/opencode.ts
  55. 2 0
      packages/sdk-next/src/tool.ts
  56. 84 0
      packages/sdk-next/test/embedded.test.ts
  57. 53 0
      packages/sdk-next/test/import-boundaries.test.ts
  58. 8 0
      packages/sdk-next/tsconfig.json
  59. 13 5
      packages/server/src/routes.ts

+ 5 - 0
.github/workflows/test.yml

@@ -69,6 +69,11 @@ jobs:
         env:
           OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
 
+      - name: Check generated client
+        if: runner.os == 'Linux'
+        working-directory: packages/client
+        run: bun run check:generated
+
       - name: Run HttpApi exerciser gates
         if: runner.os == 'Linux'
         working-directory: packages/opencode

+ 77 - 0
CONTEXT.md

@@ -60,6 +60,21 @@ A temporary file created under OpenCode's shared tool-output directory to retain
 **PTY Environment**:
 The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
 
+**OpenCode Client**:
+The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers.
+_Avoid_: Remote client
+
+**SDK Contract IR**:
+The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
+
+**Embedded OpenCode**:
+A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
+_Avoid_: Local implementation
+
+**Page**:
+A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
+_Avoid_: Response envelope
+
 ## Relationships
 
 - A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
@@ -108,6 +123,51 @@ The host-supplied environment overlay applied by the server when creating a PTY,
 - Completed compaction starts a new **Context Epoch** on the next provider attempt, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
 - A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next provider turn.
 - The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
+- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
+- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
+- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
+- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
+- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
+- Server's concrete `HttpApi` is authoritative for shared **OpenCode Client** capabilities. Codegen compiles its Session group directly; the Effect runtime uses an equivalent Protocol-only projection so generated artifacts remain independent of Core and Server.
+- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
+- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
+- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against a Protocol-only client projection whose generated transport output is tested against Server's concrete API; the Promise emitter still derives zero-Effect structural wire types from the same IR.
+- `@opencode-ai/protocol` owns Session endpoint construction and middleware placement. Server supplies concrete middleware keys to produce the authoritative build-time API; the client projection supplies transport-only keys without importing Core or Server at runtime.
+- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
+- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
+- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
+- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
+- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
+- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
+- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
+- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
+- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
+- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
+- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
+- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
+- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
+- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
+- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
+- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
+- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
+- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
+- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
+- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
+- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
+- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
+- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
+- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
+- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
+- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
+- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `SessionMessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
+- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
+- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
+- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
+- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
+- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
+- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
+- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
+- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
 - A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
 - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
 - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
@@ -124,6 +184,23 @@ The host-supplied environment overlay applied by the server when creating a PTY,
 - **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
 - Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
 
+## Client contract architecture
+
+Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
+
+Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
+
+Before stabilizing the client API:
+
+- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
+- Keep concrete Location middleware keys in Server while Protocol owns their placement. Client projections may supply transport-only keys, but must prove generated equivalence with Server's concrete API.
+- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
+- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
+- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
+- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
+- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
+- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
+
 ## Example dialogue
 
 > **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"

+ 55 - 0
bun.lock

@@ -110,6 +110,29 @@
         "@typescript/native-preview": "catalog:",
       },
     },
+    "packages/client": {
+      "name": "@opencode-ai/client",
+      "dependencies": {
+        "@opencode-ai/protocol": "workspace:*",
+        "@opencode-ai/schema": "workspace:*",
+      },
+      "devDependencies": {
+        "@effect/platform-node": "catalog:",
+        "@opencode-ai/core": "workspace:*",
+        "@opencode-ai/httpapi-codegen": "workspace:*",
+        "@opencode-ai/server": "workspace:*",
+        "@tsconfig/bun": "catalog:",
+        "@types/bun": "catalog:",
+        "@typescript/native-preview": "catalog:",
+        "effect": "catalog:",
+      },
+      "peerDependencies": {
+        "effect": "4.0.0-beta.83",
+      },
+      "optionalPeers": [
+        "effect",
+      ],
+    },
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
       "version": "1.17.10",
@@ -479,6 +502,18 @@
         "effect": "4.0.0-beta.83",
       },
     },
+    "packages/httpapi-codegen": {
+      "name": "@opencode-ai/httpapi-codegen",
+      "dependencies": {
+        "effect": "catalog:",
+        "prettier": "3.6.2",
+      },
+      "devDependencies": {
+        "@tsconfig/bun": "catalog:",
+        "@types/bun": "catalog:",
+        "@typescript/native-preview": "catalog:",
+      },
+    },
     "packages/llm": {
       "name": "@opencode-ai/llm",
       "version": "1.17.10",
@@ -690,6 +725,20 @@
         "@types/semver": "^7.5.8",
       },
     },
+    "packages/sdk-next": {
+      "name": "@opencode-ai/sdk-next",
+      "dependencies": {
+        "@opencode-ai/client": "workspace:*",
+        "@opencode-ai/core": "workspace:*",
+        "@opencode-ai/server": "workspace:*",
+        "effect": "catalog:",
+      },
+      "devDependencies": {
+        "@tsconfig/bun": "catalog:",
+        "@types/bun": "catalog:",
+        "@typescript/native-preview": "catalog:",
+      },
+    },
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
       "version": "1.17.10",
@@ -1847,6 +1896,8 @@
 
     "@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"],
 
+    "@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],
+
     "@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
 
     "@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
@@ -1873,6 +1924,8 @@
 
     "@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
 
+    "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
+
     "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
 
     "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
@@ -1885,6 +1938,8 @@
 
     "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
 
+    "@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"],
+
     "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
 
     "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],

+ 27 - 0
packages/client/README.md

@@ -0,0 +1,27 @@
+# @opencode-ai/client
+
+Private generation target for clients derived directly from OpenCode's authoritative Effect `HttpApi`.
+
+## Entrypoints
+
+- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
+- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
+
+The generated surface starts with the Session group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
+
+The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
+
+The Promise root remains structural and has no Core or Effect runtime dependency. `/effect` depends only on Effect, Schema, and Protocol and is browser-bundle safe. Bundle-boundary tests enforce both import graphs.
+
+Effect consumers construct canonical decoded inputs:
+
+```ts
+import { AbsolutePath, Location, OpenCode, Prompt } from "@opencode-ai/client/effect"
+
+const client = yield * OpenCode.make({ baseUrl: "https://opencode.example" })
+yield *
+  client.sessions.create({
+    location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }),
+  })
+yield * client.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Hello" }) })
+```

+ 39 - 0
packages/client/package.json

@@ -0,0 +1,39 @@
+{
+  "$schema": "https://json.schemastore.org/package.json",
+  "name": "@opencode-ai/client",
+  "private": true,
+  "type": "module",
+  "license": "MIT",
+  "exports": {
+    ".": "./src/index.ts",
+    "./effect": "./src/effect.ts"
+  },
+  "scripts": {
+    "generate": "bun run script/build.ts",
+    "check:generated": "bun run generate && git diff --exit-code -- src/generated src/generated-effect",
+    "test": "bun test --timeout 5000",
+    "typecheck": "tsgo --noEmit"
+  },
+  "dependencies": {
+    "@opencode-ai/schema": "workspace:*",
+    "@opencode-ai/protocol": "workspace:*"
+  },
+  "peerDependencies": {
+    "effect": "4.0.0-beta.83"
+  },
+  "peerDependenciesMeta": {
+    "effect": {
+      "optional": true
+    }
+  },
+  "devDependencies": {
+    "@effect/platform-node": "catalog:",
+    "@opencode-ai/core": "workspace:*",
+    "@opencode-ai/httpapi-codegen": "workspace:*",
+    "@opencode-ai/server": "workspace:*",
+    "@tsconfig/bun": "catalog:",
+    "@types/bun": "catalog:",
+    "@typescript/native-preview": "catalog:",
+    "effect": "catalog:"
+  }
+}

+ 23 - 0
packages/client/script/build.ts

@@ -0,0 +1,23 @@
+import { NodeFileSystem } from "@effect/platform-node"
+import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
+import { Api } from "@opencode-ai/server/api"
+import { Effect } from "effect"
+import { HttpApi } from "effect/unstable/httpapi"
+import { fileURLToPath } from "url"
+
+const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), {
+  groupNames: { "server.session": "sessions" },
+})
+
+await Effect.runPromise(
+  Effect.all(
+    [
+      write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
+      write(
+        emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }),
+        fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
+      ),
+    ],
+    { concurrency: 2, discard: true },
+  ).pipe(Effect.provide(NodeFileSystem.layer)),
+)

+ 19 - 0
packages/client/src/contract.ts

@@ -0,0 +1,19 @@
+import { makeDefaultApi } from "@opencode-ai/protocol/api"
+import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
+import { HttpApiMiddleware } from "effect/unstable/httpapi"
+
+class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
+  "@opencode-ai/client/LocationMiddleware",
+) {}
+
+class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocationMiddleware>()(
+  "@opencode-ai/client/SessionLocationMiddleware",
+  { error: [InvalidRequestError, SessionNotFoundError] },
+) {}
+
+const Api = makeDefaultApi({
+  locationMiddleware: LocationMiddleware,
+  sessionLocationMiddleware: SessionLocationMiddleware,
+})
+
+export const SessionGroup = Api.groups["server.session"]

+ 12 - 0
packages/client/src/effect.ts

@@ -0,0 +1,12 @@
+// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
+// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
+export * from "./generated-effect/index"
+export { Agent } from "@opencode-ai/schema/agent"
+export { Location } from "@opencode-ai/schema/location"
+export { Model } from "@opencode-ai/schema/model"
+export { Provider } from "@opencode-ai/schema/provider"
+export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
+export { Session } from "@opencode-ai/schema/session"
+export { SessionInput } from "@opencode-ai/schema/session-input"
+export { SessionMessage } from "@opencode-ai/schema/session-message"
+export { Prompt } from "@opencode-ai/schema/prompt"

+ 5 - 0
packages/client/src/generated-effect/.httpapi-codegen.json

@@ -0,0 +1,5 @@
+[
+  "client-error.ts",
+  "client.ts",
+  "index.ts"
+]

+ 5 - 0
packages/client/src/generated-effect/client-error.ts

@@ -0,0 +1,5 @@
+import { Schema } from "effect"
+
+export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
+  cause: Schema.Defect(),
+}) {}

+ 164 - 0
packages/client/src/generated-effect/client.ts

@@ -0,0 +1,164 @@
+// Generated by @opencode-ai/httpapi-codegen. Do not edit.
+import { Effect, Schema } from "effect"
+import { Sse } from "effect/unstable/encoding"
+import { HttpClientError } from "effect/unstable/http"
+import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
+import { SessionGroup } from "../contract"
+import { ClientError } from "./client-error"
+
+const Api = HttpApi.make("generated").add(SessionGroup)
+
+type RawClient = HttpApiClient.ForApi<typeof Api>
+
+const mapClientError = <E>(error: E) =>
+  HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
+    ? new ClientError({ cause: error })
+    : error
+
+type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
+type Endpoint0_0Input = {
+  readonly workspace?: Endpoint0_0Request["query"]["workspace"]
+  readonly limit?: Endpoint0_0Request["query"]["limit"]
+  readonly order?: Endpoint0_0Request["query"]["order"]
+  readonly search?: Endpoint0_0Request["query"]["search"]
+  readonly directory?: Endpoint0_0Request["query"]["directory"]
+  readonly project?: Endpoint0_0Request["query"]["project"]
+  readonly subpath?: Endpoint0_0Request["query"]["subpath"]
+  readonly cursor?: Endpoint0_0Request["query"]["cursor"]
+}
+const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) =>
+  raw["session.list"]({
+    query: {
+      workspace: input?.workspace,
+      limit: input?.limit,
+      order: input?.order,
+      search: input?.search,
+      directory: input?.directory,
+      project: input?.project,
+      subpath: input?.subpath,
+      cursor: input?.cursor,
+    },
+  }).pipe(Effect.mapError(mapClientError))
+
+type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
+type Endpoint0_1Input = {
+  readonly id?: Endpoint0_1Request["payload"]["id"]
+  readonly agent?: Endpoint0_1Request["payload"]["agent"]
+  readonly model?: Endpoint0_1Request["payload"]["model"]
+  readonly location?: Endpoint0_1Request["payload"]["location"]
+}
+const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) =>
+  raw["session.create"]({
+    payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
+  }).pipe(
+    Effect.mapError(mapClientError),
+    Effect.map((value) => value.data),
+  )
+
+type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0]
+type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] }
+const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) =>
+  raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
+    Effect.mapError(mapClientError),
+    Effect.map((value) => value.data),
+  )
+
+type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
+type Endpoint0_3Input = {
+  readonly sessionID: Endpoint0_3Request["params"]["sessionID"]
+  readonly agent: Endpoint0_3Request["payload"]["agent"]
+}
+const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
+  raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
+    Effect.mapError(mapClientError),
+  )
+
+type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
+type Endpoint0_4Input = {
+  readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
+  readonly model: Endpoint0_4Request["payload"]["model"]
+}
+const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
+  raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
+    Effect.mapError(mapClientError),
+  )
+
+type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
+type Endpoint0_5Input = {
+  readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
+  readonly id?: Endpoint0_5Request["payload"]["id"]
+  readonly prompt: Endpoint0_5Request["payload"]["prompt"]
+  readonly delivery?: Endpoint0_5Request["payload"]["delivery"]
+  readonly resume?: Endpoint0_5Request["payload"]["resume"]
+}
+const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
+  raw["session.prompt"]({
+    params: { sessionID: input.sessionID },
+    payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
+  }).pipe(
+    Effect.mapError(mapClientError),
+    Effect.map((value) => value.data),
+  )
+
+type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
+type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] }
+const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
+  raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
+
+type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
+type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
+const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
+  raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
+
+type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
+type Endpoint0_8Input = {
+  readonly sessionID: Endpoint0_8Request["params"]["sessionID"]
+  readonly messageID: Endpoint0_8Request["payload"]["messageID"]
+  readonly files?: Endpoint0_8Request["payload"]["files"]
+}
+const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
+  raw["session.revert.stage"]({
+    params: { sessionID: input.sessionID },
+    payload: { messageID: input.messageID, files: input.files },
+  }).pipe(
+    Effect.mapError(mapClientError),
+    Effect.map((value) => value.data),
+  )
+
+type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
+type Endpoint0_9Input = { readonly sessionID: Endpoint0_9Request["params"]["sessionID"] }
+const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
+  raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
+
+type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
+type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
+const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
+  raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
+
+type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.context"]>[0]
+type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
+const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
+  raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
+    Effect.mapError(mapClientError),
+    Effect.map((value) => value.data),
+  )
+
+const adaptGroup0 = (raw: RawClient["server.session"]) => ({
+  list: Endpoint0_0(raw),
+  create: Endpoint0_1(raw),
+  get: Endpoint0_2(raw),
+  switchAgent: Endpoint0_3(raw),
+  switchModel: Endpoint0_4(raw),
+  prompt: Endpoint0_5(raw),
+  compact: Endpoint0_6(raw),
+  wait: Endpoint0_7(raw),
+  stage: Endpoint0_8(raw),
+  clear: Endpoint0_9(raw),
+  commit: Endpoint0_10(raw),
+  context: Endpoint0_11(raw),
+})
+
+const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
+
+export const make = (options?: { readonly baseUrl?: URL | string }) =>
+  HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))

+ 2 - 0
packages/client/src/generated-effect/index.ts

@@ -0,0 +1,2 @@
+export { ClientError } from "./client-error"
+export * as OpenCode from "./client"

+ 6 - 0
packages/client/src/generated/.httpapi-codegen.json

@@ -0,0 +1,6 @@
+[
+  "client-error.ts",
+  "client.ts",
+  "index.ts",
+  "types.ts"
+]

+ 11 - 0
packages/client/src/generated/client-error.ts

@@ -0,0 +1,11 @@
+export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"
+
+export class ClientError extends Error {
+  override readonly name = "ClientError"
+  constructor(
+    readonly reason: ClientErrorReason,
+    options?: ErrorOptions,
+  ) {
+    super(reason, options)
+  }
+}

+ 349 - 0
packages/client/src/generated/client.ts

@@ -0,0 +1,349 @@
+import type {
+  SessionsListInput,
+  SessionsListOutput,
+  SessionsCreateInput,
+  SessionsCreateOutput,
+  SessionsGetInput,
+  SessionsGetOutput,
+  SessionsSwitchAgentInput,
+  SessionsSwitchAgentOutput,
+  SessionsSwitchModelInput,
+  SessionsSwitchModelOutput,
+  SessionsPromptInput,
+  SessionsPromptOutput,
+  SessionsCompactInput,
+  SessionsCompactOutput,
+  SessionsWaitInput,
+  SessionsWaitOutput,
+  SessionsStageInput,
+  SessionsStageOutput,
+  SessionsClearInput,
+  SessionsClearOutput,
+  SessionsCommitInput,
+  SessionsCommitOutput,
+  SessionsContextInput,
+  SessionsContextOutput,
+} from "./types"
+import { ClientError } from "./client-error"
+
+export interface ClientOptions {
+  readonly baseUrl: string
+  readonly fetch?: typeof globalThis.fetch
+  readonly headers?: HeadersInit
+}
+
+export interface RequestOptions {
+  readonly signal?: AbortSignal
+  readonly headers?: HeadersInit
+}
+
+interface RequestDescriptor {
+  readonly method: string
+  readonly path: string
+  readonly query?: Record<string, unknown>
+  readonly headers?: Record<string, unknown>
+  readonly body?: unknown
+  readonly successStatus: number
+  readonly declaredStatuses: ReadonlyArray<number>
+  readonly empty: boolean
+}
+
+export function make(options: ClientOptions) {
+  const fetch = options.fetch ?? globalThis.fetch
+
+  const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
+    const url = new URL(descriptor.path, options.baseUrl)
+    for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)
+    const headers = new Headers(options.headers)
+    for (const [key, value] of Object.entries(descriptor.headers ?? {})) {
+      if (value !== undefined && value !== null) headers.set(key, String(value))
+    }
+    for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)
+    if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
+    return {
+      url,
+      init: {
+        method: descriptor.method,
+        signal: requestOptions?.signal,
+        headers,
+        body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),
+      } satisfies RequestInit,
+    }
+  }
+
+  const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
+    try {
+      const prepared = prepare(descriptor, requestOptions)
+      return await fetch(prepared.url, prepared.init)
+    } catch (cause) {
+      throw new ClientError("Transport", { cause })
+    }
+  }
+
+  const responseError = async (response: Response, descriptor: RequestDescriptor): Promise<never> => {
+    if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)
+    try {
+      await response.body?.cancel()
+    } catch {}
+    throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })
+  }
+
+  const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {
+    const response = await execute(descriptor, requestOptions)
+    if (response.status !== descriptor.successStatus) return responseError(response, descriptor)
+    if (descriptor.empty) {
+      try {
+        await response.body?.cancel()
+      } catch {}
+      return undefined as A
+    }
+    return (await json(response)) as A
+  }
+
+  const sse = <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable<A> => ({
+    async *[Symbol.asyncIterator]() {
+      const response = await execute(descriptor, requestOptions)
+      if (response.status !== descriptor.successStatus) await responseError(response, descriptor)
+      if (!isContentType(response, "text/event-stream")) {
+        try {
+          await response.body?.cancel()
+        } catch {}
+        throw new ClientError("UnsupportedContentType")
+      }
+      if (response.body === null) throw new ClientError("MalformedResponse")
+      const reader = response.body.getReader()
+      const decoder = new TextDecoder()
+      let buffer = ""
+      try {
+        while (true) {
+          let next
+          try {
+            next = await reader.read()
+          } catch (cause) {
+            throw new ClientError("Transport", { cause })
+          }
+          buffer += decoder.decode(next.value, { stream: !next.done })
+          if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
+          const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
+          if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
+          buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
+          if (trailingCarriageReturn) buffer += "\r"
+          if (next.done && buffer !== "") buffer += "\n\n"
+          let boundary = buffer.indexOf("\n\n")
+          while (boundary >= 0) {
+            const block = buffer.slice(0, boundary)
+            buffer = buffer.slice(boundary + 2)
+            const data = block
+              .split("\n")
+              .flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : []))
+              .join("\n")
+            if (data !== "") {
+              try {
+                yield JSON.parse(data) as A
+              } catch (cause) {
+                throw new ClientError("MalformedResponse", { cause })
+              }
+            }
+            boundary = buffer.indexOf("\n\n")
+          }
+          if (next.done) return
+        }
+      } finally {
+        try {
+          await reader.cancel()
+        } catch {}
+        reader.releaseLock()
+      }
+    },
+  })
+
+  return {
+    sessions: {
+      list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
+        request<SessionsListOutput>(
+          {
+            method: "GET",
+            path: `/api/session`,
+            query: {
+              workspace: input?.workspace,
+              limit: input?.limit,
+              order: input?.order,
+              search: input?.search,
+              directory: input?.directory,
+              project: input?.project,
+              subpath: input?.subpath,
+              cursor: input?.cursor,
+            },
+            successStatus: 200,
+            declaredStatuses: [400, 401],
+            empty: false,
+          },
+          requestOptions,
+        ),
+      create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) =>
+        request<{ readonly data: SessionsCreateOutput }>(
+          {
+            method: "POST",
+            path: `/api/session`,
+            body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
+            successStatus: 200,
+            declaredStatuses: [401, 400],
+            empty: false,
+          },
+          requestOptions,
+        ).then((value) => value.data),
+      get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
+        request<{ readonly data: SessionsGetOutput }>(
+          {
+            method: "GET",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}`,
+            successStatus: 200,
+            declaredStatuses: [404, 400, 401],
+            empty: false,
+          },
+          requestOptions,
+        ).then((value) => value.data),
+      switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) =>
+        request<SessionsSwitchAgentOutput>(
+          {
+            method: "POST",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
+            body: { agent: input.agent },
+            successStatus: 204,
+            declaredStatuses: [404, 400, 401],
+            empty: true,
+          },
+          requestOptions,
+        ),
+      switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) =>
+        request<SessionsSwitchModelOutput>(
+          {
+            method: "POST",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
+            body: { model: input.model },
+            successStatus: 204,
+            declaredStatuses: [404, 400, 401],
+            empty: true,
+          },
+          requestOptions,
+        ),
+      prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
+        request<{ readonly data: SessionsPromptOutput }>(
+          {
+            method: "POST",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
+            body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
+            successStatus: 200,
+            declaredStatuses: [409, 404, 400, 401],
+            empty: false,
+          },
+          requestOptions,
+        ).then((value) => value.data),
+      compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) =>
+        request<SessionsCompactOutput>(
+          {
+            method: "POST",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
+            successStatus: 204,
+            declaredStatuses: [404, 503, 400, 401],
+            empty: true,
+          },
+          requestOptions,
+        ),
+      wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) =>
+        request<SessionsWaitOutput>(
+          {
+            method: "POST",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`,
+            successStatus: 204,
+            declaredStatuses: [404, 503, 400, 401],
+            empty: true,
+          },
+          requestOptions,
+        ),
+      stage: (input: SessionsStageInput, requestOptions?: RequestOptions) =>
+        request<{ readonly data: SessionsStageOutput }>(
+          {
+            method: "POST",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
+            body: { messageID: input.messageID, files: input.files },
+            successStatus: 200,
+            declaredStatuses: [404, 500, 400, 401],
+            empty: false,
+          },
+          requestOptions,
+        ).then((value) => value.data),
+      clear: (input: SessionsClearInput, requestOptions?: RequestOptions) =>
+        request<SessionsClearOutput>(
+          {
+            method: "POST",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
+            successStatus: 204,
+            declaredStatuses: [404, 500, 400, 401],
+            empty: true,
+          },
+          requestOptions,
+        ),
+      commit: (input: SessionsCommitInput, requestOptions?: RequestOptions) =>
+        request<SessionsCommitOutput>(
+          {
+            method: "POST",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
+            successStatus: 204,
+            declaredStatuses: [404, 400, 401],
+            empty: true,
+          },
+          requestOptions,
+        ),
+      context: (input: SessionsContextInput, requestOptions?: RequestOptions) =>
+        request<{ readonly data: SessionsContextOutput }>(
+          {
+            method: "GET",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
+            successStatus: 200,
+            declaredStatuses: [404, 500, 400, 401],
+            empty: false,
+          },
+          requestOptions,
+        ).then((value) => value.data),
+    },
+  }
+}
+
+function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
+  if (value === undefined || value === null) return
+  if (Array.isArray(value)) {
+    for (const item of value) appendQuery(params, key, item)
+    return
+  }
+  if (typeof value === "object") {
+    for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item)
+    return
+  }
+  params.append(key, String(value))
+}
+
+async function json(response: Response): Promise<unknown> {
+  if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {
+    try {
+      await response.body?.cancel()
+    } catch {}
+    throw new ClientError("UnsupportedContentType")
+  }
+  let text: string
+  try {
+    text = await response.text()
+  } catch (cause) {
+    throw new ClientError("Transport", { cause })
+  }
+  if (text === "") throw new ClientError("MalformedResponse")
+  try {
+    return JSON.parse(text)
+  } catch (cause) {
+    throw new ClientError("MalformedResponse", { cause })
+  }
+}
+
+function isContentType(response: Response, expected: string) {
+  return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected
+}

+ 3 - 0
packages/client/src/generated/index.ts

@@ -0,0 +1,3 @@
+export { ClientError, type ClientErrorReason } from "./client-error"
+export * as OpenCode from "./client"
+export * from "./types"

+ 631 - 0
packages/client/src/generated/types.ts

@@ -0,0 +1,631 @@
+export type JsonValue =
+  | null
+  | boolean
+  | number
+  | string
+  | ReadonlyArray<JsonValue>
+  | { readonly [key: string]: JsonValue }
+
+export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
+export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
+  typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidCursorError"
+
+export type InvalidRequestError = {
+  readonly _tag: "InvalidRequestError"
+  readonly message: string
+  readonly kind?: string | undefined
+  readonly field?: string | undefined
+}
+export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
+  typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidRequestError"
+
+export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
+export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
+  typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnauthorizedError"
+
+export type SessionNotFoundError = {
+  readonly _tag: "SessionNotFoundError"
+  readonly sessionID: string
+  readonly message: string
+}
+export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError =>
+  typeof value === "object" && value !== null && "_tag" in value && value._tag === "SessionNotFoundError"
+
+export type ConflictError = {
+  readonly _tag: "ConflictError"
+  readonly message: string
+  readonly resource?: string | undefined
+}
+export const isConflictError = (value: unknown): value is ConflictError =>
+  typeof value === "object" && value !== null && "_tag" in value && value._tag === "ConflictError"
+
+export type ServiceUnavailableError = {
+  readonly _tag: "ServiceUnavailableError"
+  readonly message: string
+  readonly service?: string | undefined
+}
+export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
+  typeof value === "object" && value !== null && "_tag" in value && value._tag === "ServiceUnavailableError"
+
+export type MessageNotFoundError = {
+  readonly _tag: "MessageNotFoundError"
+  readonly sessionID: string
+  readonly messageID: string
+  readonly message: string
+}
+export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
+  typeof value === "object" && value !== null && "_tag" in value && value._tag === "MessageNotFoundError"
+
+export type UnknownError = {
+  readonly _tag: "UnknownError"
+  readonly message: string
+  readonly ref?: string | undefined
+}
+export const isUnknownError = (value: unknown): value is UnknownError =>
+  typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnknownError"
+
+export type SessionsListInput = {
+  readonly workspace?: {
+    readonly workspace?: string | undefined
+    readonly limit?: string | undefined
+    readonly order?: "asc" | "desc" | undefined
+    readonly search?: string | undefined
+    readonly directory?: string | undefined
+    readonly project?: string | undefined
+    readonly subpath?: string | undefined
+    readonly cursor?: string | undefined
+  }["workspace"]
+  readonly limit?: {
+    readonly workspace?: string | undefined
+    readonly limit?: string | undefined
+    readonly order?: "asc" | "desc" | undefined
+    readonly search?: string | undefined
+    readonly directory?: string | undefined
+    readonly project?: string | undefined
+    readonly subpath?: string | undefined
+    readonly cursor?: string | undefined
+  }["limit"]
+  readonly order?: {
+    readonly workspace?: string | undefined
+    readonly limit?: string | undefined
+    readonly order?: "asc" | "desc" | undefined
+    readonly search?: string | undefined
+    readonly directory?: string | undefined
+    readonly project?: string | undefined
+    readonly subpath?: string | undefined
+    readonly cursor?: string | undefined
+  }["order"]
+  readonly search?: {
+    readonly workspace?: string | undefined
+    readonly limit?: string | undefined
+    readonly order?: "asc" | "desc" | undefined
+    readonly search?: string | undefined
+    readonly directory?: string | undefined
+    readonly project?: string | undefined
+    readonly subpath?: string | undefined
+    readonly cursor?: string | undefined
+  }["search"]
+  readonly directory?: {
+    readonly workspace?: string | undefined
+    readonly limit?: string | undefined
+    readonly order?: "asc" | "desc" | undefined
+    readonly search?: string | undefined
+    readonly directory?: string | undefined
+    readonly project?: string | undefined
+    readonly subpath?: string | undefined
+    readonly cursor?: string | undefined
+  }["directory"]
+  readonly project?: {
+    readonly workspace?: string | undefined
+    readonly limit?: string | undefined
+    readonly order?: "asc" | "desc" | undefined
+    readonly search?: string | undefined
+    readonly directory?: string | undefined
+    readonly project?: string | undefined
+    readonly subpath?: string | undefined
+    readonly cursor?: string | undefined
+  }["project"]
+  readonly subpath?: {
+    readonly workspace?: string | undefined
+    readonly limit?: string | undefined
+    readonly order?: "asc" | "desc" | undefined
+    readonly search?: string | undefined
+    readonly directory?: string | undefined
+    readonly project?: string | undefined
+    readonly subpath?: string | undefined
+    readonly cursor?: string | undefined
+  }["subpath"]
+  readonly cursor?: {
+    readonly workspace?: string | undefined
+    readonly limit?: string | undefined
+    readonly order?: "asc" | "desc" | undefined
+    readonly search?: string | undefined
+    readonly directory?: string | undefined
+    readonly project?: string | undefined
+    readonly subpath?: string | undefined
+    readonly cursor?: string | undefined
+  }["cursor"]
+}
+
+export type SessionsListOutput = {
+  readonly data: ReadonlyArray<{
+    readonly id: string
+    readonly parentID?: string
+    readonly projectID: string
+    readonly agent?: string | null
+    readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
+    readonly cost: number
+    readonly tokens: {
+      readonly input: number
+      readonly output: number
+      readonly reasoning: number
+      readonly cache: { readonly read: number; readonly write: number }
+    }
+    readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
+    readonly title: string
+    readonly location: { readonly directory: string; readonly workspaceID?: string | null | null }
+    readonly subpath?: string | null
+    readonly revert?: {
+      readonly messageID: string
+      readonly partID?: string | null
+      readonly snapshot?: string | null
+      readonly diff?: string | null
+      readonly files?: ReadonlyArray<{
+        readonly path: string
+        readonly status: "added" | "modified" | "deleted"
+        readonly additions: number
+        readonly deletions: number
+        readonly patch: string
+      }> | null
+    } | null
+  }>
+  readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
+}
+
+export type SessionsCreateInput = {
+  readonly id?: {
+    readonly id?: string | null
+    readonly agent?: string | null
+    readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
+    readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null
+  }["id"]
+  readonly agent?: {
+    readonly id?: string | null
+    readonly agent?: string | null
+    readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
+    readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null
+  }["agent"]
+  readonly model?: {
+    readonly id?: string | null
+    readonly agent?: string | null
+    readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
+    readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null
+  }["model"]
+  readonly location?: {
+    readonly id?: string | null
+    readonly agent?: string | null
+    readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
+    readonly location?: { readonly directory: string; readonly workspaceID?: string | null | null } | null
+  }["location"]
+}
+
+export type SessionsCreateOutput = {
+  readonly data: {
+    readonly id: string
+    readonly parentID?: string
+    readonly projectID: string
+    readonly agent?: string | null
+    readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
+    readonly cost: number
+    readonly tokens: {
+      readonly input: number
+      readonly output: number
+      readonly reasoning: number
+      readonly cache: { readonly read: number; readonly write: number }
+    }
+    readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
+    readonly title: string
+    readonly location: { readonly directory: string; readonly workspaceID?: string | null | null }
+    readonly subpath?: string | null
+    readonly revert?: {
+      readonly messageID: string
+      readonly partID?: string | null
+      readonly snapshot?: string | null
+      readonly diff?: string | null
+      readonly files?: ReadonlyArray<{
+        readonly path: string
+        readonly status: "added" | "modified" | "deleted"
+        readonly additions: number
+        readonly deletions: number
+        readonly patch: string
+      }> | null
+    } | null
+  }
+}["data"]
+
+export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
+
+export type SessionsGetOutput = {
+  readonly data: {
+    readonly id: string
+    readonly parentID?: string
+    readonly projectID: string
+    readonly agent?: string | null
+    readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
+    readonly cost: number
+    readonly tokens: {
+      readonly input: number
+      readonly output: number
+      readonly reasoning: number
+      readonly cache: { readonly read: number; readonly write: number }
+    }
+    readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
+    readonly title: string
+    readonly location: { readonly directory: string; readonly workspaceID?: string | null | null }
+    readonly subpath?: string | null
+    readonly revert?: {
+      readonly messageID: string
+      readonly partID?: string | null
+      readonly snapshot?: string | null
+      readonly diff?: string | null
+      readonly files?: ReadonlyArray<{
+        readonly path: string
+        readonly status: "added" | "modified" | "deleted"
+        readonly additions: number
+        readonly deletions: number
+        readonly patch: string
+      }> | null
+    } | null
+  }
+}["data"]
+
+export type SessionsSwitchAgentInput = {
+  readonly sessionID: { readonly sessionID: string }["sessionID"]
+  readonly agent: { readonly agent: string }["agent"]
+}
+
+export type SessionsSwitchAgentOutput = void
+
+export type SessionsSwitchModelInput = {
+  readonly sessionID: { readonly sessionID: string }["sessionID"]
+  readonly model: {
+    readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
+  }["model"]
+}
+
+export type SessionsSwitchModelOutput = void
+
+export type SessionsPromptInput = {
+  readonly sessionID: { readonly sessionID: string }["sessionID"]
+  readonly id?: {
+    readonly id?: string | undefined
+    readonly prompt: {
+      readonly text: string
+      readonly files?:
+        | ReadonlyArray<{
+            readonly uri: string
+            readonly mime: string
+            readonly name?: string | undefined
+            readonly description?: string | undefined
+            readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
+          }>
+        | undefined
+      readonly agents?:
+        | ReadonlyArray<{
+            readonly name: string
+            readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
+          }>
+        | undefined
+    }
+    readonly delivery?: "steer" | "queue" | undefined
+    readonly resume?: boolean | undefined
+  }["id"]
+  readonly prompt: {
+    readonly id?: string | undefined
+    readonly prompt: {
+      readonly text: string
+      readonly files?:
+        | ReadonlyArray<{
+            readonly uri: string
+            readonly mime: string
+            readonly name?: string | undefined
+            readonly description?: string | undefined
+            readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
+          }>
+        | undefined
+      readonly agents?:
+        | ReadonlyArray<{
+            readonly name: string
+            readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
+          }>
+        | undefined
+    }
+    readonly delivery?: "steer" | "queue" | undefined
+    readonly resume?: boolean | undefined
+  }["prompt"]
+  readonly delivery?: {
+    readonly id?: string | undefined
+    readonly prompt: {
+      readonly text: string
+      readonly files?:
+        | ReadonlyArray<{
+            readonly uri: string
+            readonly mime: string
+            readonly name?: string | undefined
+            readonly description?: string | undefined
+            readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
+          }>
+        | undefined
+      readonly agents?:
+        | ReadonlyArray<{
+            readonly name: string
+            readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
+          }>
+        | undefined
+    }
+    readonly delivery?: "steer" | "queue" | undefined
+    readonly resume?: boolean | undefined
+  }["delivery"]
+  readonly resume?: {
+    readonly id?: string | undefined
+    readonly prompt: {
+      readonly text: string
+      readonly files?:
+        | ReadonlyArray<{
+            readonly uri: string
+            readonly mime: string
+            readonly name?: string | undefined
+            readonly description?: string | undefined
+            readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
+          }>
+        | undefined
+      readonly agents?:
+        | ReadonlyArray<{
+            readonly name: string
+            readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
+          }>
+        | undefined
+    }
+    readonly delivery?: "steer" | "queue" | undefined
+    readonly resume?: boolean | undefined
+  }["resume"]
+}
+
+export type SessionsPromptOutput = {
+  readonly data: {
+    readonly admittedSeq: number
+    readonly id: string
+    readonly sessionID: string
+    readonly prompt: {
+      readonly text: string
+      readonly files?: ReadonlyArray<{
+        readonly uri: string
+        readonly mime: string
+        readonly name?: string | null
+        readonly description?: string | null
+        readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
+      }> | null
+      readonly agents?: ReadonlyArray<{
+        readonly name: string
+        readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
+      }> | null
+    }
+    readonly delivery: "steer" | "queue"
+    readonly timeCreated: number
+    readonly promotedSeq?: number | null
+  }
+}["data"]
+
+export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
+
+export type SessionsCompactOutput = void
+
+export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
+
+export type SessionsWaitOutput = void
+
+export type SessionsStageInput = {
+  readonly sessionID: { readonly sessionID: string }["sessionID"]
+  readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"]
+  readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"]
+}
+
+export type SessionsStageOutput = {
+  readonly data: {
+    readonly messageID: string
+    readonly partID?: string | undefined
+    readonly snapshot?: string | undefined
+    readonly diff?: string | undefined
+    readonly files?:
+      | ReadonlyArray<{
+          readonly path: string
+          readonly status: "added" | "modified" | "deleted"
+          readonly additions: number
+          readonly deletions: number
+          readonly patch: string
+        }>
+      | undefined
+  }
+}["data"]
+
+export type SessionsClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
+
+export type SessionsClearOutput = void
+
+export type SessionsCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
+
+export type SessionsCommitOutput = void
+
+export type SessionsContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
+
+export type SessionsContextOutput = {
+  readonly data: ReadonlyArray<
+    | {
+        readonly id: string
+        readonly metadata?: { readonly [x: string]: JsonValue } | null
+        readonly time: { readonly created: number }
+        readonly type: "agent-switched"
+        readonly agent: string
+      }
+    | {
+        readonly id: string
+        readonly metadata?: { readonly [x: string]: JsonValue } | null
+        readonly time: { readonly created: number }
+        readonly type: "model-switched"
+        readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null }
+      }
+    | {
+        readonly id: string
+        readonly metadata?: { readonly [x: string]: JsonValue } | null
+        readonly time: { readonly created: number }
+        readonly text: string
+        readonly files?: ReadonlyArray<{
+          readonly uri: string
+          readonly mime: string
+          readonly name?: string | null
+          readonly description?: string | null
+          readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
+        }> | null
+        readonly agents?: ReadonlyArray<{
+          readonly name: string
+          readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
+        }> | null
+        readonly type: "user"
+      }
+    | {
+        readonly id: string
+        readonly metadata?: { readonly [x: string]: JsonValue } | null
+        readonly time: { readonly created: number }
+        readonly sessionID: string
+        readonly text: string
+        readonly type: "synthetic"
+      }
+    | {
+        readonly id: string
+        readonly metadata?: { readonly [x: string]: JsonValue } | null
+        readonly time: { readonly created: number }
+        readonly type: "system"
+        readonly text: string
+      }
+    | {
+        readonly id: string
+        readonly metadata?: { readonly [x: string]: JsonValue } | null
+        readonly time: { readonly created: number; readonly completed?: number | null }
+        readonly type: "shell"
+        readonly callID: string
+        readonly command: string
+        readonly output: string
+      }
+    | {
+        readonly id: string
+        readonly metadata?: { readonly [x: string]: JsonValue } | null
+        readonly time: { readonly created: number; readonly completed?: number | null }
+        readonly type: "assistant"
+        readonly agent: string
+        readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null }
+        readonly content: ReadonlyArray<
+          | { readonly type: "text"; readonly id: string; readonly text: string }
+          | {
+              readonly type: "reasoning"
+              readonly id: string
+              readonly text: string
+              readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
+            }
+          | {
+              readonly type: "tool"
+              readonly id: string
+              readonly name: string
+              readonly provider?: {
+                readonly executed: boolean
+                readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
+                readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
+              } | null
+              readonly state:
+                | { readonly status: "pending"; readonly input: string }
+                | {
+                    readonly status: "running"
+                    readonly input: { readonly [x: string]: JsonValue }
+                    readonly structured: { readonly [x: string]: any }
+                    readonly content: ReadonlyArray<
+                      | { readonly type: "text"; readonly text: string }
+                      | {
+                          readonly type: "file"
+                          readonly uri: string
+                          readonly mime: string
+                          readonly name?: string | null
+                        }
+                    >
+                  }
+                | {
+                    readonly status: "completed"
+                    readonly input: { readonly [x: string]: JsonValue }
+                    readonly attachments?: ReadonlyArray<{
+                      readonly uri: string
+                      readonly mime: string
+                      readonly name?: string | null
+                      readonly description?: string | null
+                      readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
+                    }> | null
+                    readonly content: ReadonlyArray<
+                      | { readonly type: "text"; readonly text: string }
+                      | {
+                          readonly type: "file"
+                          readonly uri: string
+                          readonly mime: string
+                          readonly name?: string | null
+                        }
+                    >
+                    readonly outputPaths?: ReadonlyArray<string> | null
+                    readonly structured: { readonly [x: string]: any }
+                    readonly result?: JsonValue | null
+                  }
+                | {
+                    readonly status: "error"
+                    readonly input: { readonly [x: string]: JsonValue }
+                    readonly content: ReadonlyArray<
+                      | { readonly type: "text"; readonly text: string }
+                      | {
+                          readonly type: "file"
+                          readonly uri: string
+                          readonly mime: string
+                          readonly name?: string | null
+                        }
+                    >
+                    readonly structured: { readonly [x: string]: any }
+                    readonly error: { readonly type: "unknown"; readonly message: string }
+                    readonly result?: JsonValue | null
+                  }
+              readonly time: {
+                readonly created: number
+                readonly ran?: number | null
+                readonly completed?: number | null
+                readonly pruned?: number | null
+              }
+            }
+        >
+        readonly snapshot?: {
+          readonly start?: string | null
+          readonly end?: string | null
+          readonly files?: ReadonlyArray<string> | null
+        } | null
+        readonly finish?: string | null
+        readonly cost?: number | null
+        readonly tokens?: {
+          readonly input: number
+          readonly output: number
+          readonly reasoning: number
+          readonly cache: { readonly read: number; readonly write: number }
+        } | null
+        readonly error?: { readonly type: "unknown"; readonly message: string } | null
+      }
+    | {
+        readonly type: "compaction"
+        readonly reason: "auto" | "manual"
+        readonly summary: string
+        readonly recent: string
+        readonly id: string
+        readonly metadata?: { readonly [x: string]: JsonValue } | null
+        readonly time: { readonly created: number }
+      }
+  >
+}["data"]

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

@@ -0,0 +1 @@
+export * from "./generated/index"

+ 60 - 0
packages/client/test/contract-identity.test.ts

@@ -0,0 +1,60 @@
+import { expect, test } from "bun:test"
+import { Schema } from "effect"
+import { AgentV2 } from "@opencode-ai/core/agent"
+import { Location as CoreLocation } from "@opencode-ai/core/location"
+import { ModelV2 } from "@opencode-ai/core/model"
+import { SessionV2 } from "@opencode-ai/core/session"
+import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input"
+import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
+import { Prompt as CorePrompt } from "@opencode-ai/core/session/prompt"
+import { Agent } from "@opencode-ai/schema/agent"
+import { Location } from "@opencode-ai/schema/location"
+import { Model } from "@opencode-ai/schema/model"
+import { Project } from "@opencode-ai/schema/project"
+import { Provider } from "@opencode-ai/schema/provider"
+import { Prompt } from "@opencode-ai/schema/prompt"
+import { Session } from "@opencode-ai/schema/session"
+import { SessionInput } from "@opencode-ai/schema/session-input"
+import { SessionMessage } from "@opencode-ai/schema/session-message"
+import { Workspace } from "@opencode-ai/schema/workspace"
+import { Api } from "@opencode-ai/server/api"
+import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
+import { HttpApi } from "effect/unstable/httpapi"
+import { SessionGroup } from "../src/contract"
+
+test("Core and Server reuse the authoritative Schema and Protocol values", () => {
+  expect(AgentV2.ID).toBe(Agent.ID)
+  expect(CoreLocation.Ref).toBe(Location.Ref)
+  expect(ModelV2.Ref).toBe(Model.Ref)
+  expect(SessionV2.Info).toBe(Session.Info)
+  expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
+  expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
+  expect(CorePrompt).toBe(Prompt)
+  expect(Api.groups["server.session"].identifier).toBe("server.session")
+  expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
+  expect(Session.ID.create()).toStartWith("ses_")
+  expect(Project.ID.global).toBe("global")
+  expect(Provider.ID.anthropic).toBe("anthropic")
+  expect(Workspace.ID.create()).toStartWith("wrk_")
+})
+
+test("client and Server Session contracts generate identically", () => {
+  const options = { groupNames: { "server.session": "sessions" } }
+  const server = compile(HttpApi.make("server").add(Api.groups["server.session"]), options)
+  const client = compile(HttpApi.make("client").add(SessionGroup), options)
+
+  expect(emitPromise(client)).toEqual(emitPromise(server))
+})
+
+test("shared DTO schemas construct and decode plain objects", () => {
+  const made = Prompt.make({ text: "hello" })
+  const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
+  const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" })
+
+  expect(Object.getPrototypeOf(made)).toBe(Object.prototype)
+  expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype)
+  expect(Object.getPrototypeOf(content)).toBe(Object.prototype)
+  expect(Prompt.ast.annotations?.identifier).toBe("Prompt")
+  expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text")
+  expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText)
+})

+ 98 - 0
packages/client/test/effect.test.ts

@@ -0,0 +1,98 @@
+import { expect, test } from "bun:test"
+import { DateTime, Effect } from "effect"
+import { HttpClient, HttpClientResponse } from "effect/unstable/http"
+import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session } from "../src/effect"
+
+test("sessions.get returns the decoded Effect projection", async () => {
+  const httpClient = HttpClient.make((request) =>
+    Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
+  )
+  const result = await Effect.gen(function* () {
+    const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
+    return yield* client.sessions.get({ sessionID: Session.ID.make("ses_test") })
+  }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
+
+  expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
+})
+
+test("session methods retain decoded Effect inputs and outputs", async () => {
+  const httpClient = HttpClient.make((request) => {
+    const url = request.url
+    if (url.includes("/prompt")) {
+      return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
+    }
+    if (url.includes("/context")) {
+      return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
+    }
+    if (request.method === "POST" && url.endsWith("/api/session")) {
+      return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
+    }
+    if (request.method === "POST") {
+      return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
+    }
+    return Effect.succeed(
+      HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
+    )
+  })
+  const result = await Effect.gen(function* () {
+    const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
+    const page = yield* client.sessions.list({ limit: 10 })
+    const created = yield* client.sessions.create({
+      location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
+    })
+    yield* client.sessions.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
+    yield* client.sessions.switchModel({
+      sessionID: Session.ID.make("ses_test"),
+      model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
+    })
+    const admitted = yield* client.sessions.prompt({
+      sessionID: Session.ID.make("ses_test"),
+      prompt: Prompt.make({ text: "Hello" }),
+      resume: false,
+    })
+    yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
+    yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
+    const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
+    return { page, created, admitted, context }
+  }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
+
+  expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
+  expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
+  expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
+  expect(result.created.id).toBe("ses_test")
+  expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype)
+  expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
+  expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
+  expect(result.context).toEqual([])
+})
+
+const session = {
+  data: {
+    id: "ses_test",
+    projectID: "project",
+    cost: 0,
+    tokens: {
+      input: 1,
+      output: 2,
+      reasoning: 3,
+      cache: { read: 4, write: 5 },
+    },
+    time: {
+      created: 1_717_171_717_000,
+      updated: 1_717_171_717_000,
+    },
+    title: "Test",
+    location: { directory: "/tmp/project" },
+  },
+}
+
+const admission = {
+  data: {
+    admittedSeq: 0,
+    id: "msg_test",
+    sessionID: "ses_test",
+    prompt: { text: "Hello" },
+    delivery: "steer",
+    timeCreated: 1_717_171_717_000,
+  },
+}

+ 68 - 0
packages/client/test/import-boundaries.test.ts

@@ -0,0 +1,68 @@
+import { describe, expect, test } from "bun:test"
+import { realpathSync } from "node:fs"
+import { mkdtemp, rm } from "node:fs/promises"
+import { join, resolve, sep } from "node:path"
+
+const directory = resolve(import.meta.dir, "..")
+const effect = realpathSync(resolve(import.meta.dir, "../node_modules/effect"))
+const schema = resolve(import.meta.dir, "../../schema")
+const protocol = resolve(import.meta.dir, "../../protocol")
+const core = resolve(import.meta.dir, "../../core")
+const server = resolve(import.meta.dir, "../../server")
+
+describe("public import boundaries", () => {
+  test("isolates each public entrypoint", async () => {
+    const root = await bundleInputs("@opencode-ai/client", "browser")
+
+    expect(within(root, effect)).toEqual([])
+    expect(within(root, schema)).toEqual([])
+    expect(within(root, protocol)).toEqual([])
+    expect(within(root, core)).toEqual([])
+    expect(within(root, server)).toEqual([])
+
+    const network = await bundleInputs("@opencode-ai/client/effect", "browser")
+
+    expect(within(network, effect).length).toBeGreaterThan(0)
+    expect(within(network, schema).length).toBeGreaterThan(0)
+    expect(within(network, protocol).length).toBeGreaterThan(0)
+    expect(within(network, core)).toEqual([])
+    expect(within(network, server)).toEqual([])
+  })
+})
+
+async function bundleInputs(specifier: string, target: "browser" | "bun") {
+  const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-"))
+  const entrypoint = join(temporary, "index.ts")
+  const metafile = join(temporary, "meta.json")
+  try {
+    await Bun.write(entrypoint, `export * from ${JSON.stringify(specifier)}`)
+    const child = Bun.spawn(
+      [
+        process.execPath,
+        "build",
+        entrypoint,
+        `--target=${target}`,
+        "--format=esm",
+        "--packages=bundle",
+        `--metafile=${metafile}`,
+        `--outdir=${join(temporary, "out")}`,
+      ],
+      { cwd: directory, stdout: "pipe", stderr: "pipe" },
+    )
+    const [exitCode, stdout, stderr] = await Promise.all([
+      child.exited,
+      new Response(child.stdout).text(),
+      new Response(child.stderr).text(),
+    ])
+    if (exitCode !== 0) throw new Error(stdout + stderr)
+    const metadata = await Bun.file(metafile).json()
+    return Object.keys(metadata.inputs).map((input) => resolve(directory, input))
+  } finally {
+    await rm(temporary, { recursive: true, force: true })
+  }
+}
+
+function within(inputs: ReadonlyArray<string>, directory: string) {
+  const prefix = directory.endsWith(sep) ? directory : directory + sep
+  return inputs.filter((input) => input === directory || input.startsWith(prefix))
+}

+ 117 - 0
packages/client/test/promise.test.ts

@@ -0,0 +1,117 @@
+import { expect, test } from "bun:test"
+import { isUnauthorizedError, OpenCode } from "../src"
+
+test("sessions.get returns the wire projection", async () => {
+  const client = OpenCode.make({
+    baseUrl: "http://localhost:3000",
+    fetch: async (input) => {
+      expect(typeof input === "string" ? input : input instanceof URL ? input.href : input.url).toBe(
+        "http://localhost:3000/api/session/ses_test",
+      )
+      return Response.json(session)
+    },
+  })
+
+  const result = await client.sessions.get({ sessionID: "ses_test" })
+
+  expect(result.time.created).toBe(1_717_171_717_000)
+})
+
+test("session methods use the public HTTP contract", async () => {
+  const requests: Array<{ url: string; init?: RequestInit }> = []
+  const client = OpenCode.make({
+    baseUrl: "http://localhost:3000",
+    fetch: async (input, init) => {
+      const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
+      requests.push({ url, init })
+      if (url.includes("/prompt")) return Response.json(admission)
+      if (url.includes("/context")) return Response.json({ data: [] })
+      if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
+      if (init?.method === "POST") return new Response(null, { status: 204 })
+      return Response.json({ data: [session.data], cursor: { next: "next" } })
+    },
+  })
+
+  const page = await client.sessions.list({ limit: "10", order: "desc" })
+  const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
+  await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
+  await client.sessions.switchModel({
+    sessionID: "ses_test",
+    model: { id: "claude", providerID: "anthropic" },
+  })
+  const admitted = await client.sessions.prompt({
+    sessionID: "ses_test",
+    prompt: { text: "Hello" },
+    resume: false,
+  })
+  await client.sessions.compact({ sessionID: "ses_test" })
+  await client.sessions.wait({ sessionID: "ses_test" })
+  const context = await client.sessions.context({ sessionID: "ses_test" })
+
+  expect(page.cursor.next).toBe("next")
+  expect(created.id).toBe("ses_test")
+  expect(admitted.id).toBe("msg_test")
+  expect(context).toEqual([])
+  expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
+    ["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
+    ["POST", "http://localhost:3000/api/session"],
+    ["POST", "http://localhost:3000/api/session/ses_test/agent"],
+    ["POST", "http://localhost:3000/api/session/ses_test/model"],
+    ["POST", "http://localhost:3000/api/session/ses_test/prompt"],
+    ["POST", "http://localhost:3000/api/session/ses_test/compact"],
+    ["POST", "http://localhost:3000/api/session/ses_test/wait"],
+    ["GET", "http://localhost:3000/api/session/ses_test/context"],
+  ])
+  const body = requests[4]?.init?.body
+  if (typeof body !== "string") throw new Error("Expected JSON request body")
+  expect(JSON.parse(body)).toEqual({
+    prompt: { text: "Hello" },
+    resume: false,
+  })
+})
+
+test("middleware errors remain declared client errors", async () => {
+  const client = OpenCode.make({
+    baseUrl: "http://localhost:3000",
+    fetch: async () =>
+      Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 }),
+  })
+
+  try {
+    await client.sessions.create({})
+    throw new Error("Expected request to fail")
+  } catch (error) {
+    expect(isUnauthorizedError(error)).toBe(true)
+  }
+})
+
+const session = {
+  data: {
+    id: "ses_test",
+    projectID: "project",
+    cost: 0,
+    tokens: {
+      input: 1,
+      output: 2,
+      reasoning: 3,
+      cache: { read: 4, write: 5 },
+    },
+    time: {
+      created: 1_717_171_717_000,
+      updated: 1_717_171_717_000,
+    },
+    title: "Test",
+    location: { directory: "/tmp/project" },
+  },
+}
+
+const admission = {
+  data: {
+    admittedSeq: 0,
+    id: "msg_test",
+    sessionID: "ses_test",
+    prompt: { text: "Hello" },
+    delivery: "steer",
+    timeCreated: 1_717_171_717_000,
+  },
+}

+ 9 - 0
packages/client/tsconfig.json

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

+ 0 - 1
packages/core/package.json

@@ -16,7 +16,6 @@
     "opencode": "./bin/opencode"
   },
   "exports": {
-    "./public": "./src/public/index.ts",
     "./session/runner": "./src/session/runner/index.ts",
     "./system-context": "./src/system-context/index.ts",
     "./*": "./src/*.ts"

+ 0 - 6
packages/core/src/public/agent.ts

@@ -1,6 +0,0 @@
-export * as Agent from "./agent"
-
-import { AgentV2 } from "../agent"
-
-export const ID = AgentV2.ID
-export type ID = AgentV2.ID

+ 0 - 9
packages/core/src/public/index.ts

@@ -1,9 +0,0 @@
-/** Intentional supported native API. Other core subpaths remain internal implementation surfaces. */
-export { Agent } from "./agent"
-export { Model } from "./model"
-export { OpenCode } from "./opencode"
-export { Session } from "./session"
-export { Tool } from "./tool"
-export { Location } from "./location"
-export { Prompt } from "../session/prompt"
-export { AbsolutePath } from "../schema"

+ 0 - 6
packages/core/src/public/location.ts

@@ -1,6 +0,0 @@
-export * as Location from "./location"
-
-import { Location } from "../location"
-
-export const Ref = Location.Ref
-export type Ref = Location.Ref

+ 0 - 9
packages/core/src/public/model.ts

@@ -1,9 +0,0 @@
-export * as Model from "./model"
-
-import { ModelV2 } from "../model"
-
-export const ID = ModelV2.ID
-export type ID = ModelV2.ID
-
-export const Ref = ModelV2.Ref
-export type Ref = ModelV2.Ref

+ 0 - 76
packages/core/src/public/opencode.ts

@@ -1,76 +0,0 @@
-export * as OpenCode from "./opencode"
-
-import { Context, Effect, Layer } from "effect"
-import { Database } from "../database/database"
-import { EventV2 } from "../event"
-import { LocationServiceMap } from "../location-layer"
-import { ProjectV2 } from "../project"
-import { SessionV2 } from "../session"
-import * as SessionExecutionLocal from "../session/execution/local"
-import { SessionProjector } from "../session/projector"
-import { SessionStore } from "../session/store"
-import { ApplicationTools } from "../tool/application-tools"
-import { Session } from "./session"
-import { Tool } from "./tool"
-
-export interface Interface {
-  readonly sessions: Session.Interface
-  readonly tools: Tool.Interface
-}
-
-/** Intentional public native API for Effect applications embedding OpenCode. */
-export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
-
-const SessionsLayer = SessionV2.layer.pipe(
-  Layer.provide(SessionProjector.layer),
-  Layer.provide(SessionExecutionLocal.layer),
-  Layer.provide(SessionStore.layer),
-  Layer.provide(EventV2.layer),
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(ProjectV2.defaultLayer),
-  Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
-  Layer.orDie,
-)
-// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const sessions = yield* SessionV2.Service
-    const tools = yield* ApplicationTools.Service
-    return Service.of({
-      tools: { register: tools.register },
-      sessions: {
-        create: (input) =>
-          sessions.create({
-            id: input.id,
-            agent: input.agent,
-            model: input.model,
-            location: input.location,
-          }),
-        get: sessions.get,
-        list: sessions.list,
-        switchModel: sessions.switchModel,
-        interrupt: sessions.interrupt,
-        prompt: (input) =>
-          sessions.prompt({
-            id: input.id,
-            sessionID: input.sessionID,
-            prompt: input.prompt,
-            delivery: input.delivery,
-          }),
-        messages: (input) =>
-          sessions.messages({
-            sessionID: input.sessionID,
-            limit: input.limit,
-            order: input.order,
-            cursor: input.cursor,
-          }),
-        message: (input) => sessions.message({ sessionID: input.sessionID, messageID: input.messageID }),
-        context: sessions.context,
-        events: (input) => sessions.events({ sessionID: input.sessionID, after: input.after }),
-      },
-    })
-  }),
-).pipe(Layer.provide(Layer.merge(ApplicationTools.layer, SessionsLayer)))
-
-// TODO: Add OpenCode.create(...) as the Promise facade over the same native API semantics.

+ 0 - 96
packages/core/src/public/session.ts

@@ -1,96 +0,0 @@
-export * as Session from "./session"
-
-import { Effect, Stream } from "effect"
-import { SessionV2 } from "../session"
-import { MessageDecodeError } from "../session/error"
-import { SessionEvent } from "../session/event"
-import { SessionInput } from "../session/input"
-import { SessionMessage } from "../session/message"
-import { Prompt } from "../session/prompt"
-import { Agent } from "./agent"
-import { Location } from "./location"
-import { Model } from "./model"
-
-export const ID = SessionV2.ID
-export type ID = SessionV2.ID
-
-export const Info = SessionV2.Info
-export type Info = SessionV2.Info
-
-export const MessageID = SessionMessage.ID
-export type MessageID = SessionMessage.ID
-
-export const Message = SessionMessage.Message
-export type Message = SessionMessage.Message
-
-export const Admission = SessionInput.Admitted
-export type Admission = SessionInput.Admitted
-
-export const Delivery = SessionInput.Delivery
-export type Delivery = SessionInput.Delivery
-
-export const ListInput = SessionV2.ListInput
-export type ListInput = SessionV2.ListInput
-
-export type Event = SessionEvent.DurableEvent
-
-export const NotFoundError = SessionV2.NotFoundError
-export type NotFoundError = SessionV2.NotFoundError
-
-export const PromptConflictError = SessionV2.PromptConflictError
-export type PromptConflictError = SessionV2.PromptConflictError
-
-export { MessageDecodeError }
-
-export interface CreateInput {
-  readonly id?: ID
-  readonly agent?: Agent.ID
-  readonly model?: Model.Ref
-  readonly location: Location.Ref
-}
-
-export interface PromptInput {
-  readonly id?: MessageID
-  readonly sessionID: ID
-  readonly prompt: Prompt
-  readonly delivery?: Delivery
-}
-
-export interface SwitchModelInput {
-  readonly sessionID: ID
-  readonly model: Model.Ref
-}
-
-export interface MessagesInput {
-  readonly sessionID: ID
-  readonly limit?: number
-  readonly order?: "asc" | "desc"
-  readonly cursor?: {
-    readonly id: MessageID
-    readonly direction: "previous" | "next"
-  }
-}
-
-export interface MessageInput {
-  readonly sessionID: ID
-  readonly messageID: MessageID
-}
-
-export interface EventsInput {
-  readonly sessionID: ID
-  readonly after?: number
-}
-
-export interface Interface {
-  readonly create: (input: CreateInput) => Effect.Effect<Info>
-  readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
-  readonly list: (input?: ListInput) => Effect.Effect<Info[]>
-  readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
-  readonly switchModel: (input: SwitchModelInput) => Effect.Effect<void, NotFoundError>
-  /** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
-  readonly interrupt: (sessionID: ID) => Effect.Effect<void>
-  readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
-  readonly message: (input: MessageInput) => Effect.Effect<Message | undefined>
-  readonly context: (sessionID: ID) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
-  readonly events: (input: EventsInput) => Stream.Stream<Event, NotFoundError>
-}

+ 0 - 17
packages/core/src/public/tool.ts

@@ -1,17 +0,0 @@
-export * as Tool from "./tool"
-
-import { Effect, Scope } from "effect"
-import type { AnyTool, RegistrationError } from "../tool/tool"
-
-export { Failure, RegistrationError, make } from "../tool/tool"
-export type { AnyTool, Content, Context, Definition } from "../tool/tool"
-
-export interface Interface {
-  /**
-   * Register same-process tools on this OpenCode instance for the current Scope.
-   * Location tools with the same name take precedence where they are installed.
-   * Closing the Scope removes the tools immediately, so calls that have not
-   * started settling may fail because the tool is no longer available.
-   */
-  readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
-}

+ 1 - 1
packages/core/test/application-tools.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect } from "bun:test"
-import { Tool } from "@opencode-ai/core/public"
+import { Tool } from "@opencode-ai/core/tool/tool"
 import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { SessionV2 } from "@opencode-ai/core/session"

+ 1 - 1
packages/core/test/location-layer.test.ts

@@ -2,7 +2,7 @@ import fs from "fs/promises"
 import path from "path"
 import { describe, expect } from "bun:test"
 import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
-import { Tool } from "@opencode-ai/core/public"
+import { Tool } from "@opencode-ai/core/tool/tool"
 import { define } from "@opencode-ai/plugin/v2/effect"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { Catalog } from "@opencode-ai/core/catalog"

+ 0 - 76
packages/core/test/public-opencode.test.ts

@@ -1,76 +0,0 @@
-import { describe, expect } from "bun:test"
-import { Effect, Schema } from "effect"
-import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
-import { testEffect } from "./lib/effect"
-
-const it = testEffect(OpenCode.layer)
-
-describe("public native OpenCode API", () => {
-  it.effect("exposes only the intentional Session capabilities", () =>
-    Effect.gen(function* () {
-      const opencode = yield* OpenCode.Service
-
-      expect(Object.keys(opencode).sort()).toEqual(["sessions", "tools"])
-
-      expect(Object.keys(opencode.sessions).sort()).toEqual([
-        "context",
-        "create",
-        "events",
-        "get",
-        "interrupt",
-        "list",
-        "message",
-        "messages",
-        "prompt",
-        "switchModel",
-      ])
-      expect(Session.ID.create()).toStartWith("ses_")
-      expect(Session.MessageID.create()).toStartWith("msg_")
-      expect(yield* opencode.sessions.list()).toBeArray()
-      yield* opencode.tools.register({
-        public_tool: Tool.make({
-          description: "Public tool",
-          input: Schema.Struct({}),
-          output: Schema.Struct({ ok: Schema.Boolean }),
-          execute: () => Effect.succeed({ ok: true }),
-        }),
-      })
-    }),
-  )
-
-  it.effect("records model selection without resolving the Location catalog", () =>
-    Effect.gen(function* () {
-      const opencode = yield* OpenCode.Service
-      const sessionID = Session.ID.make("ses_public_switch_deferred")
-      const model = Schema.decodeUnknownSync(Model.Ref)({
-        id: "missing",
-        providerID: "missing",
-        variant: "unknown",
-      })
-      yield* opencode.sessions.create({
-        id: sessionID,
-        location: Location.Ref.make({ directory: AbsolutePath.make("/public-session-switch-model") }),
-      })
-
-      yield* opencode.sessions.switchModel({ sessionID, model })
-
-      expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
-    }),
-  )
-
-  it.effect("preserves the typed not-found error for a missing Session", () =>
-    Effect.gen(function* () {
-      const opencode = yield* OpenCode.Service
-      const sessionID = Session.ID.make("ses_public_switch_missing")
-      const error = yield* opencode.sessions
-        .switchModel({
-          sessionID,
-          model: Schema.decodeUnknownSync(Model.Ref)({ id: "claude-sonnet-4-5", providerID: "anthropic" }),
-        })
-        .pipe(Effect.flip)
-
-      expect(error).toBeInstanceOf(Session.NotFoundError)
-      if (error instanceof Session.NotFoundError) expect(error.sessionID).toBe(sessionID)
-    }),
-  )
-})

+ 0 - 13
packages/core/test/public-tool.test.ts

@@ -1,13 +0,0 @@
-import { describe, expect, it } from "bun:test"
-import { Tool } from "@opencode-ai/core/public"
-import { Effect } from "effect"
-
-describe("public Tool API", () => {
-  it("keeps the public registration capability narrow", () => {
-    const tools = {
-      register: () => Effect.void,
-    } satisfies Tool.Interface
-
-    expect(Object.keys(tools)).toEqual(["register"])
-  })
-})

+ 42 - 0
packages/httpapi-codegen/README.md

@@ -0,0 +1,42 @@
+# @opencode-ai/httpapi-codegen
+
+Build-time source generation for domain-oriented Promise and Effect APIs derived directly from `HttpApi` and Effect Schema contracts.
+
+The package is private while its API is explored. Its tests are the executable specification for the generator. It must remain independent of OpenCode Core and use synthetic `HttpApi` fixtures.
+
+## Settled rules
+
+- Reflect one authoritative `HttpApi` into a shared contract with `compile(Api)`.
+- Emit clients independently with `emitPromise(contract)` and `emitEffect(contract)`.
+- Give each emitter its own public type projection; the shared contract, not a generated type package, is the common source.
+- Generate a rich Effect client with decoded Effect-native values, runtime schemas, preserved transformations, and `HttpApiClient`.
+- Generate a zero-Effect Promise client with structural wire-oriented values, direct `fetch`, and syntax parsing without runtime structural validation.
+- Keep the Promise surface domain-oriented rather than Hey API compatible: methods return unwrapped values and reject with tagged declared errors or `ClientError`.
+- Return Promise streams as lazy `AsyncIterable` values and Effect streams as `Stream` values. Neither runtime reconnects automatically.
+
+- Flatten path, query, header, and payload fields into one input object.
+- Reject duplicate field names across input channels.
+- Emit no method argument for zero fields, an optional object when every field is optional, and a required object when any field is required.
+- Unwrap exact `{ data: A }` success envelopes.
+- Map no-content success to `void`.
+- Preserve other single success values.
+- Reject ambiguous multiple-success contracts.
+- Expose streaming success as `Stream`, not `Effect<Stream>`.
+- Reject schemas whose wire/domain transformation cannot be generated exactly.
+- Map transport, unexpected-status, and response-decoding failures to one stable generated `ClientError`.
+- Commit generated source for review; CI regenerates and fails when the worktree changes.
+- Track generated files in `.httpapi-codegen.json` so regeneration removes only stale files previously owned by the generator.
+
+## Boundary
+
+This package generates only client APIs derived from `HttpApi`. It does not generate embedded-only capabilities. Networked and embedded OpenCode use the same generated Effect client against network and in-memory `HttpClient` transports respectively; the embedded host structurally extends that client with same-process capabilities.
+
+Codegen generates every endpoint in the `HttpApi` it receives. OpenCode owns the product decision by composing the exact remote API before invoking the generator; the generic package has no endpoint filtering policy.
+
+The existing public `generate(Api, { directory })` operation writes the rich Effect output and remains an Effect requiring `FileSystem`. The staged API uses pure `compile(Api)`, `emitEffect(contract)`, and `emitPromise(contract)` phases before `write(output, directory)`. Compiler tests inspect virtual files directly; writer tests use `FileSystem.makeNoop`.
+
+Generation formats TypeScript with Prettier before writing. Output paths are flat, unique, and checked against traversal, reserved manifest names, and existing symbolic links.
+
+Generated source starts with one self-contained module per `HttpApiGroup`, plus root client and index modules. Schema dependencies may be duplicated across group modules. Cross-group schema partitioning is deferred until measured output or bundle cost requires it.
+
+Codegen preserves group and endpoint identifiers exactly. The composed remote `HttpApi` owns public names such as `session` and `get`; the generator performs no prefix stripping, casing conversion, or public-name annotation mapping.

+ 22 - 0
packages/httpapi-codegen/package.json

@@ -0,0 +1,22 @@
+{
+  "$schema": "https://json.schemastore.org/package.json",
+  "name": "@opencode-ai/httpapi-codegen",
+  "private": true,
+  "type": "module",
+  "exports": {
+    ".": "./src/index.ts"
+  },
+  "scripts": {
+    "test": "bun test --timeout 5000 --only-failures",
+    "typecheck": "tsgo --noEmit"
+  },
+  "dependencies": {
+    "effect": "catalog:",
+    "prettier": "3.6.2"
+  },
+  "devDependencies": {
+    "@tsconfig/bun": "catalog:",
+    "@types/bun": "catalog:",
+    "@typescript/native-preview": "catalog:"
+  }
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 521 - 0
packages/httpapi-codegen/src/index.ts


+ 28 - 0
packages/httpapi-codegen/test/effect.ts

@@ -0,0 +1,28 @@
+import { test } from "bun:test"
+import { Cause, Effect, Exit, Layer } from "effect"
+import type { Scope } from "effect/Scope"
+import { TestClock, TestConsole } from "effect/testing"
+
+type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
+
+const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer())
+
+const effect = <A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
+  test(
+    name,
+    () =>
+      Effect.gen(function* () {
+        const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
+          Effect.scoped,
+          Effect.provide(layer),
+          Effect.exit,
+        )
+        if (Exit.isFailure(exit)) {
+          yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
+        }
+        return yield* exit
+      }).pipe(Effect.runPromise),
+    options,
+  )
+
+export const it = { effect }

+ 45 - 0
packages/httpapi-codegen/test/fixture.ts

@@ -0,0 +1,45 @@
+import { Schema } from "effect"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
+
+export class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {
+  message: Schema.String,
+}) {}
+
+export const Api = HttpApi.make("fixture")
+  .add(
+    HttpApiGroup.make("session")
+      .add(HttpApiEndpoint.get("health", "/session/health", { success: Schema.String }))
+      .add(
+        HttpApiEndpoint.get("list", "/session", {
+          query: { archived: Schema.optional(Schema.Boolean) },
+          success: Schema.Array(Schema.String),
+        }),
+      )
+      .add(
+        HttpApiEndpoint.get("get", "/session/:sessionID", {
+          params: { sessionID: Schema.String },
+          success: Schema.Struct({ data: Schema.String }),
+          error: Missing.pipe(HttpApiSchema.status(404)),
+        }),
+      )
+      .add(
+        HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
+          params: { sessionID: Schema.String },
+          success: HttpApiSchema.NoContent,
+        }),
+      ),
+  )
+  .add(
+    HttpApiGroup.make("event").add(
+      HttpApiEndpoint.get("subscribe", "/event", {
+        success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }).pipe(
+          HttpApiSchema.status(202),
+        ),
+      }),
+    ),
+  )
+  .add(
+    HttpApiGroup.make("system", { topLevel: true }).add(
+      HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
+    ),
+  )

+ 897 - 0
packages/httpapi-codegen/test/generate.test.ts

@@ -0,0 +1,897 @@
+import { describe, expect, test } from "bun:test"
+import { mkdtemp, rm } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi"
+import { format } from "prettier"
+import {
+  compile as compileContract,
+  emitEffect,
+  emitEffectImported,
+  emitPromise,
+  generate,
+  GenerationError,
+} from "../src"
+import { it } from "./effect"
+import { Api as FixtureApi, Missing } from "./fixture"
+
+function api(endpoint: HttpApiEndpoint.Any) {
+  return HttpApi.make("test").add(HttpApiGroup.make("session").add(endpoint))
+}
+
+function compile<Id extends string, Groups extends HttpApiGroup.Any>(source: HttpApi.HttpApi<Id, Groups>) {
+  return emitEffect(compileContract(source))
+}
+
+describe("HttpApiCodegen.generate", () => {
+  test("compiles one contract for Promise and Effect emitters", () => {
+    const contract = compileContract(
+      api(
+        HttpApiEndpoint.get("get", "/session/:sessionID", {
+          params: { sessionID: Schema.String },
+          success: Schema.Struct({ data: Schema.String }),
+        }),
+      ),
+    )
+
+    const promise = emitPromise(contract)
+    const effect = emitEffect(contract)
+
+    expect(promise.operations).toEqual(effect.operations)
+    expect(promise.files.map((file) => file.path)).toEqual(["types.ts", "client-error.ts", "client.ts", "index.ts"])
+    const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content
+    expect(promiseClient).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
+    expect(promiseClient).toContain("`/session/${encodeURIComponent(input.sessionID)}`")
+    expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
+      'params: { "sessionID": input["sessionID"] }',
+    )
+  })
+
+  test("emits an Effect client against an imported authoritative API", () => {
+    const output = emitEffectImported(
+      compileContract(
+        api(
+          HttpApiEndpoint.get("get", "/session/:sessionID", {
+            params: { sessionID: Schema.String },
+            success: Schema.Struct({ data: Schema.String }),
+          }),
+        ),
+      ),
+      { module: "@example/api", api: "Api" },
+    )
+
+    expect(output.files.map((file) => file.path)).toEqual(["client-error.ts", "client.ts", "index.ts"])
+    expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
+      'import { Api } from "@example/api"',
+    )
+    expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
+      "HttpApiClient.ForApi<typeof Api>",
+    )
+  })
+
+  test("projects imported endpoint constants into a generated API", () => {
+    const output = emitEffectImported(
+      compileContract(
+        api(
+          HttpApiEndpoint.get("get", "/session/:sessionID", {
+            params: { sessionID: Schema.String },
+            success: Schema.Struct({ data: Schema.String }),
+          }),
+        ),
+      ),
+      { module: "@example/api", endpoints: { "session.get": "SessionGet" } },
+    )
+    const client = output.files.find((file) => file.path === "client.ts")?.content
+
+    expect(client).toContain('import { SessionGet } from "@example/api"')
+    expect(client).toContain('const Api = HttpApi.make("generated").add(HttpApiGroup.make("session").add(SessionGet))')
+  })
+
+  test("imports an authoritative group without reconstructing it", () => {
+    const output = emitEffectImported(
+      compileContract(
+        api(
+          HttpApiEndpoint.get("get", "/session/:sessionID", {
+            params: { sessionID: Schema.String },
+            success: Schema.String,
+          }),
+        ),
+      ),
+      { module: "@example/api", group: "SessionGroup" },
+    )
+    const client = output.files.find((file) => file.path === "client.ts")?.content
+
+    expect(client).toContain('import { SessionGroup } from "@example/api"')
+    expect(client).toContain('const Api = HttpApi.make("generated").add(SessionGroup)')
+    expect(client).not.toContain("HttpApiGroup")
+  })
+
+  test("separates hosted and consumer group names", () => {
+    const source = HttpApi.make("test").add(
+      HttpApiGroup.make("server.session").add(
+        HttpApiEndpoint.get("session.get", "/session", { success: Schema.String }),
+      ),
+    )
+    const contract = compileContract(source, { groupNames: { "server.session": "sessions" } })
+
+    expect(contract.groups[0]?.identifier).toBe("sessions")
+    expect(contract.groups[0]?.sourceIdentifier).toBe("server.session")
+    expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" })
+  })
+
+  test("rejects consumer group name collisions", () => {
+    const source = HttpApi.make("test")
+      .add(HttpApiGroup.make("first").add(HttpApiEndpoint.get("one", "/one", { success: Schema.String })))
+      .add(HttpApiGroup.make("second").add(HttpApiEndpoint.get("two", "/two", { success: Schema.String })))
+
+    expect(() => compileContract(source, { groupNames: { first: "same", second: "same" } })).toThrow(
+      "Client group name collision: same",
+    )
+  })
+
+  test("uses the unqualified endpoint name for the public client", () => {
+    const contract = compileContract(
+      api(
+        HttpApiEndpoint.get("session.get", "/session/:sessionID", {
+          params: { sessionID: Schema.String },
+          success: Schema.String,
+        }),
+      ),
+    )
+    const promise = emitPromise(contract).files.find((file) => file.path === "client.ts")?.content
+    const effect = emitEffectImported(contract, {
+      module: "@example/api",
+      endpoints: { "session.session.get": "SessionGet" },
+    }).files.find((file) => file.path === "client.ts")?.content
+
+    expect(contract.groups[0]?.endpoints[0]?.operation.name).toBe("get")
+    expect(promise).toContain('"get": (input: SessionGetInput, requestOptions?: RequestOptions)')
+    expect(effect).toContain('const adaptGroup0 = (raw: RawClient["session"]) => ({ "get": Endpoint0_0(raw) })')
+    expect(effect).toContain('raw["session.get"]')
+  })
+
+  test("preserves optional keys in Promise error types", () => {
+    class OptionalError extends Schema.TaggedErrorClass<OptionalError>()(
+      "OptionalError",
+      { message: Schema.String, detail: Schema.String.pipe(Schema.optional) },
+      { httpApiStatus: 400 },
+    ) {}
+    const output = emitPromise(
+      compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String, error: OptionalError }))),
+    )
+
+    expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
+      'readonly "message": string; readonly "detail"?: string | undefined',
+    )
+  })
+
+  test("erases brands from Promise wire types", () => {
+    const output = emitPromise(
+      compileContract(
+        api(
+          HttpApiEndpoint.get("get", "/session/:sessionID", {
+            params: { sessionID: Schema.String.pipe(Schema.brand("SessionID")) },
+            success: Schema.Struct({ data: Schema.String.pipe(Schema.brand("SessionID")) }),
+          }),
+        ),
+      ),
+    )
+    const types = output.files.find((file) => file.path === "types.ts")?.content
+
+    expect(types).toContain('readonly "sessionID": string')
+    expect(types).not.toContain("Brand")
+  })
+
+  test("inlines non-recursive references in Promise wire types", () => {
+    const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
+    const output = emitPromise(
+      compileContract(
+        api(
+          HttpApiEndpoint.get("get", "/session", {
+            success: Schema.Struct({ data: Referenced }),
+          }),
+        ),
+      ),
+    )
+
+    expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
+      'export type SessionGetOutput = ({ readonly "data": ({ readonly "value": string }) })["data"]',
+    )
+  })
+
+  test("emits Effect Json schemas as standalone Promise types", () => {
+    const output = emitPromise(
+      compileContract(
+        api(
+          HttpApiEndpoint.get("get", "/session", {
+            success: Schema.Json,
+          }),
+        ),
+      ),
+    )
+    const types = output.files.find((file) => file.path === "types.ts")?.content
+
+    expect(types).toContain("export type JsonValue =")
+    expect(types).toContain("{ readonly [key: string]: JsonValue }")
+    expect(types).not.toContain("Schema.Json")
+  })
+
+  test("emits an optional Promise input when every field is optional", () => {
+    const output = emitPromise(
+      compileContract(
+        api(
+          HttpApiEndpoint.get("list", "/session", {
+            query: { limit: Schema.optional(Schema.Number) },
+            success: Schema.Array(Schema.String),
+          }),
+        ),
+      ),
+    )
+
+    expect(output.files.find((file) => file.path === "client.ts")?.content).toContain(
+      '"list": (input?: SessionListInput, requestOptions?: RequestOptions)',
+    )
+  })
+
+  test("rejects Promise transports that are not implemented", () => {
+    expect(() =>
+      emitPromise(
+        compileContract(
+          api(
+            HttpApiEndpoint.get("text", "/text", {
+              success: Schema.String.pipe(HttpApiSchema.asText()),
+            }),
+          ),
+        ),
+      ),
+    ).toThrow("Unsupported Promise success encoding: session.text")
+
+    expect(() =>
+      emitPromise(
+        compileContract(
+          api(
+            HttpApiEndpoint.get("events", "/events", {
+              success: HttpApiSchema.StreamSse({ data: Schema.String, error: Missing }),
+            }),
+          ),
+        ),
+      ),
+    ).toThrow("Unsupported Promise stream: session.events")
+  })
+
+  test("executes an emitted Promise GET through fetch", async () => {
+    const output = emitPromise(
+      compileContract(
+        api(
+          HttpApiEndpoint.get("get", "/session/:sessionID", {
+            params: { sessionID: Schema.String },
+            success: Schema.Struct({ data: Schema.String }),
+          }),
+        ),
+      ),
+    )
+    const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
+
+    try {
+      await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
+      const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
+      let request: Request | undefined
+      const client = generated.OpenCode.make({
+        baseUrl: "https://example.com",
+        fetch: async (input: RequestInfo | URL) => {
+          request = input instanceof Request ? input : new Request(input)
+          return Response.json({ data: "hello" })
+        },
+      })
+
+      expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
+      expect(request?.method).toBe("GET")
+      expect(request?.url).toBe("https://example.com/session/a%2Fb")
+    } finally {
+      await rm(directory, { recursive: true, force: true })
+    }
+  })
+
+  test("maps an emitted no-content response to undefined", async () => {
+    const output = emitPromise(
+      compileContract(
+        api(
+          HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", {
+            params: { sessionID: Schema.String },
+            success: HttpApiSchema.NoContent,
+          }),
+        ),
+      ),
+    )
+    const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
+
+    try {
+      await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
+      const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
+      const client = generated.OpenCode.make({
+        baseUrl: "https://example.com",
+        fetch: async () => new Response(null, { status: 204 }),
+      })
+
+      expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
+    } finally {
+      await rm(directory, { recursive: true, force: true })
+    }
+  })
+
+  test("serializes flattened query, header, and JSON payload inputs", async () => {
+    const output = emitPromise(
+      compileContract(
+        api(
+          HttpApiEndpoint.post("prompt", "/session/:sessionID", {
+            params: { sessionID: Schema.String },
+            query: { resume: Schema.optional(Schema.Boolean) },
+            headers: { traceID: Schema.String },
+            payload: Schema.Struct({ prompt: Schema.String }),
+            success: Schema.Struct({ data: Schema.String }),
+          }),
+        ),
+      ),
+    )
+    const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
+
+    try {
+      await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
+      const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
+      let request: Request | undefined
+      const client = generated.OpenCode.make({
+        baseUrl: "https://example.com",
+        fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
+          request = input instanceof Request ? input : new Request(input, init)
+          return Response.json({ data: "admitted" })
+        },
+      })
+
+      expect(
+        await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
+      ).toBe("admitted")
+      expect(request?.url).toBe("https://example.com/session/session?resume=true")
+      expect(request?.headers.get("traceID")).toBe("trace")
+      expect(await request?.json()).toEqual({ prompt: "hello" })
+    } finally {
+      await rm(directory, { recursive: true, force: true })
+    }
+  })
+
+  test("rejects with declared tagged errors and exports a type guard", async () => {
+    const output = emitPromise(
+      compileContract(
+        api(
+          HttpApiEndpoint.get("get", "/session/:sessionID", {
+            params: { sessionID: Schema.String },
+            success: Schema.Struct({ data: Schema.String }),
+            error: Missing.pipe(HttpApiSchema.status(404)),
+          }),
+        ),
+      ),
+    )
+    const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
+
+    try {
+      await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
+      const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
+      const client = generated.OpenCode.make({
+        baseUrl: "https://example.com",
+        fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
+      })
+
+      const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
+      expect(error).toEqual({ _tag: "Missing", message: "gone" })
+      expect(generated.isMissing(error)).toBeTrue()
+    } finally {
+      await rm(directory, { recursive: true, force: true })
+    }
+  })
+
+  test("iterates an emitted SSE stream lazily without reconnecting", async () => {
+    const output = emitPromise(
+      compileContract(
+        api(
+          HttpApiEndpoint.get("subscribe", "/event", {
+            query: { after: Schema.optional(Schema.Number) },
+            success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
+          }),
+        ),
+      ),
+    )
+    const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
+
+    try {
+      await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
+      const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
+      let requests = 0
+      let url: string | undefined
+      const client = generated.OpenCode.make({
+        baseUrl: "https://example.com",
+        fetch: async (input: RequestInfo | URL) => {
+          requests++
+          url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
+          const encoder = new TextEncoder()
+          return new Response(
+            new ReadableStream({
+              start(controller) {
+                controller.enqueue(encoder.encode('data: {"type":"ready"}\r'))
+                controller.enqueue(encoder.encode("\n\r\n"))
+                controller.close()
+              },
+            }),
+            { headers: { "content-type": "text/event-stream" } },
+          )
+        },
+      })
+      const events = client.session.subscribe({ after: 2 })
+
+      expect(requests).toBe(0)
+      const received = []
+      for await (const event of events) received.push(event)
+      expect(received).toEqual([{ type: "ready" }])
+      expect(requests).toBe(1)
+      expect(url).toBe("https://example.com/event?after=2")
+    } finally {
+      await rm(directory, { recursive: true, force: true })
+    }
+  })
+
+  test("preserves public group and endpoint identifiers exactly", () => {
+    const output = compile(
+      HttpApi.make("test").add(
+        HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session/:sessionID", { success: Schema.String })),
+      ),
+    )
+
+    expect(output.operations[0]).toMatchObject({ group: "session", name: "get" })
+  })
+
+  test("emits one client module per HttpApi group", () => {
+    const source = HttpApi.make("test")
+      .add(HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
+      .add(HttpApiGroup.make("tool").add(HttpApiEndpoint.get("list", "/tool", { success: Schema.String })))
+
+    const output = compile(source)
+
+    expect(output.files.map((file) => file.path)).toEqual([
+      "session.ts",
+      "tool.ts",
+      "client-error.ts",
+      "client.ts",
+      "index.ts",
+    ])
+  })
+
+  test("emits syntactically valid TypeScript modules", () => {
+    const output = compile(
+      api(
+        HttpApiEndpoint.get("get", "/session/:sessionID", {
+          params: { sessionID: Schema.String },
+          success: Schema.Struct({ data: Schema.String }),
+        }),
+      ),
+    )
+    const transpiler = new Bun.Transpiler({ loader: "ts" })
+
+    for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
+  })
+
+  it.effect("keeps the strict generated-consumer fixture current", () =>
+    Effect.gen(function* () {
+      const output = compile(FixtureApi)
+      const actual = yield* Effect.promise(() =>
+        Array.fromAsync(new Bun.Glob("*.ts").scan(new URL("generated", import.meta.url).pathname)),
+      )
+      expect(actual.sort((a, b) => a.localeCompare(b))).toEqual(
+        output.files.map((file) => file.path).sort((a, b) => a.localeCompare(b)),
+      )
+      yield* Effect.forEach(output.files, (file) =>
+        Effect.tryPromise(() =>
+          Promise.all([
+            Bun.file(new URL(`generated/${file.path}`, import.meta.url)).text(),
+            format(file.content, { parser: "typescript", semi: false, printWidth: 120 }),
+          ]),
+        ).pipe(Effect.map(([content, expected]) => expect(content).toBe(expected))),
+      )
+    }),
+  )
+
+  test("flattens transport input channels into one domain input", () => {
+    const output = compile(
+      api(
+        HttpApiEndpoint.post("prompt", "/session/:sessionID", {
+          params: { sessionID: Schema.String },
+          query: { resume: Schema.String },
+          headers: { traceID: Schema.String },
+          payload: Schema.Struct({ prompt: Schema.String }),
+          success: Schema.Struct({ data: Schema.String }),
+        }),
+      ),
+    )
+
+    expect(output.operations[0]?.input).toEqual([
+      { name: "sessionID", source: "params" },
+      { name: "resume", source: "query" },
+      { name: "traceID", source: "headers" },
+      { name: "prompt", source: "payload" },
+    ])
+    expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
+      'params: { "sessionID": input["sessionID"] }',
+    )
+  })
+
+  test("uses no argument when an operation has no input fields", () => {
+    const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
+
+    expect(output.operations[0]?.inputMode).toBe("none")
+  })
+
+  test("uses an optional object when every input field is optional", () => {
+    const output = compile(
+      api(
+        HttpApiEndpoint.get("list", "/session", {
+          query: { limit: Schema.optional(Schema.String) },
+          success: Schema.Array(Schema.String),
+        }),
+      ),
+    )
+
+    expect(output.operations[0]?.inputMode).toBe("optional")
+    expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('input?.["limit"]')
+  })
+
+  test("regenerates standard HttpApi transport codecs from decoded schemas", () => {
+    const output = compile(
+      api(
+        HttpApiEndpoint.get("list", "/session", {
+          query: { archived: Schema.optional(Schema.Boolean) },
+          success: Schema.String,
+        }),
+      ),
+    )
+
+    expect(output.files.find((file) => file.path === "session.ts")?.content).toContain("Schema.Boolean")
+  })
+
+  test("uses a required object when any input field is required", () => {
+    const output = compile(
+      api(
+        HttpApiEndpoint.get("get", "/session/:sessionID", {
+          params: { sessionID: Schema.String },
+          query: { includeArchived: Schema.optional(Schema.String) },
+          success: Schema.String,
+        }),
+      ),
+    )
+
+    expect(output.operations[0]?.inputMode).toBe("required")
+  })
+
+  test("rejects colliding input names across transport channels", () => {
+    expect(() =>
+      compile(
+        api(
+          HttpApiEndpoint.post("prompt", "/session/:id", {
+            params: { id: Schema.String },
+            payload: Schema.Struct({ id: Schema.String }),
+            success: Schema.Void,
+          }),
+        ),
+      ),
+    ).toThrow("Input field collision: id")
+  })
+
+  test("rejects multiple payload alternatives until selection semantics are explicit", () => {
+    expect(() =>
+      compile(
+        api(
+          HttpApiEndpoint.post("prompt", "/session", {
+            payload: [Schema.Struct({ text: Schema.String }), Schema.Struct({ count: Schema.Number })],
+            success: Schema.String,
+          }),
+        ),
+      ),
+    ).toThrow("Multiple payload schemas: session.prompt")
+  })
+
+  test("unwraps an exact data success envelope", () => {
+    const output = compile(
+      api(
+        HttpApiEndpoint.get("get", "/session/:sessionID", {
+          params: { sessionID: Schema.String },
+          success: Schema.Struct({ data: Schema.String }),
+        }),
+      ),
+    )
+
+    expect(output.operations[0]?.success).toBe("value")
+    expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
+      "Effect.map((value) => value.data)",
+    )
+  })
+
+  test("maps no-content success to void", () => {
+    const output = compile(
+      api(HttpApiEndpoint.post("interrupt", "/session/:sessionID/interrupt", { success: HttpApiSchema.NoContent })),
+    )
+
+    expect(output.operations[0]?.success).toBe("void")
+    expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 204')
+  })
+
+  test("preserves non-default empty response statuses", () => {
+    const output = compile(api(HttpApiEndpoint.post("create", "/session", { success: HttpApiSchema.Created })))
+
+    expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('"httpApiStatus": 201')
+  })
+
+  test("returns a non-envelope success unchanged", () => {
+    const output = compile(api(HttpApiEndpoint.get("health", "/health", { success: Schema.String })))
+
+    expect(output.operations[0]?.success).toBe("value")
+  })
+
+  test("rejects multiple success shapes until their public semantics are explicit", () => {
+    expect(() =>
+      compile(
+        api(
+          HttpApiEndpoint.get("get", "/session", {
+            success: [Schema.String, Schema.Number],
+          }),
+        ),
+      ),
+    ).toThrow("Multiple success schemas: session.get")
+  })
+
+  test("models an SSE success as a direct stream", () => {
+    const output = compile(
+      api(
+        HttpApiEndpoint.get("subscribe", "/event", {
+          success: HttpApiSchema.StreamSse({ data: Schema.Struct({ type: Schema.String }) }),
+        }),
+      ),
+    )
+
+    expect(output.operations[0]?.success).toBe("stream")
+  })
+
+  test("preserves annotated stream response statuses", () => {
+    const output = compile(
+      api(
+        HttpApiEndpoint.get("subscribe", "/event", {
+          success: HttpApiSchema.StreamSse({ data: Schema.String }).pipe(HttpApiSchema.status(202)),
+        }),
+      ),
+    )
+
+    expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
+      ".pipe(HttpApiSchema.status(202))",
+    )
+  })
+
+  test("rejects schemas whose semantics cannot be emitted exactly", () => {
+    const OpaqueUrl = Schema.declare((input): input is URL => input instanceof URL)
+
+    expect(() => compile(api(HttpApiEndpoint.get("get", "/url", { success: OpaqueUrl })))).toThrow(
+      "Unportable schema: session.get.success",
+    )
+  })
+
+  test("rejects custom transformations hidden beneath standard HttpApi codecs", () => {
+    const QueryBoolean = Schema.Literals(["yes", "no"]).pipe(
+      Schema.decodeTo(Schema.Boolean, {
+        decode: SchemaGetter.transform((value) => value === "yes"),
+        encode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
+      }),
+    )
+
+    expect(() =>
+      compile(
+        api(
+          HttpApiEndpoint.get("get", "/session", {
+            query: { archived: QueryBoolean },
+            success: Schema.String,
+          }),
+        ),
+      ),
+    ).toThrow("Effect schema requires authoritative import: session.get")
+  })
+
+  test("rejects custom validation checks without portable metadata", () => {
+    const Positive = Schema.Number.check(Schema.makeFilter((value) => (value > 0 ? undefined : "positive")))
+
+    expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Positive })))).toThrow(
+      "Unportable schema: session.get.success",
+    )
+  })
+
+  test("rejects spoofed and aborted validation checks", () => {
+    const Spoofed = Schema.Number.check(
+      Schema.makeFilter(() => "always fails", { meta: { _tag: "isFinite" }, arbitrary: {} }),
+    )
+    const Aborted = Schema.Number.check(Schema.isFinite().abort())
+
+    expect(() => compile(api(HttpApiEndpoint.get("spoofed", "/session", { success: Spoofed })))).toThrow(
+      "Unportable schema: session.spoofed.success",
+    )
+    expect(() => compile(api(HttpApiEndpoint.get("aborted", "/session", { success: Aborted })))).toThrow(
+      "Unportable schema: session.aborted.success",
+    )
+  })
+
+  test("rejects altered wire-side schemas even when the codec transformation is canonical", () => {
+    const JsonNumber = Schema.toCodecJson(Schema.Number)
+    const link = JsonNumber.ast.encoding?.[0]
+    if (link === undefined) throw new Error("Expected JSON number encoding")
+    // This helper is present at runtime but omitted from the public declaration surface.
+    const replaceEncoding: unknown = Reflect.get(SchemaAST, "replaceEncoding")
+    if (typeof replaceEncoding !== "function") throw new Error("Expected SchemaAST.replaceEncoding")
+    const ast: unknown = replaceEncoding(JsonNumber.ast, [
+      new SchemaAST.Link(Schema.String.check(Schema.isMinLength(2)).ast, link.transformation),
+    ])
+    if (!SchemaAST.isAST(ast)) throw new Error("Expected altered schema AST")
+    const Altered = Schema.make(ast)
+
+    expect(() => compile(api(HttpApiEndpoint.get("get", "/session", { success: Altered })))).toThrow(
+      "Effect schema requires authoritative import: session.get",
+    )
+  })
+
+  test("rejects lexical generation and annotation values", () => {
+    const Generated = Schema.declare((input): input is string => typeof input === "string").annotate({
+      generation: { runtime: "LocalOnly", Type: "string" },
+    })
+    const Annotated = Schema.declare((input): input is string => typeof input === "string").annotate({
+      custom: () => "local",
+    })
+
+    expect(() => compile(api(HttpApiEndpoint.get("generated", "/session", { success: Generated })))).toThrow(
+      "Unportable schema: session.generated.success",
+    )
+    expect(() => compile(api(HttpApiEndpoint.get("annotated", "/session", { success: Annotated })))).toThrow(
+      "Unportable schema: session.annotated.success",
+    )
+  })
+
+  test("preserves errors from server-only middleware", () => {
+    class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("Unauthorized", {}) {}
+    class Authorization extends HttpApiMiddleware.Service<Authorization>()("Authorization", {
+      error: Unauthorized,
+    }) {}
+
+    const output = compile(
+      api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(Authorization)),
+    )
+
+    expect(output.operations[0]).toBeDefined()
+    expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
+      'extends Schema.TaggedErrorClass<Endpoint0Error0Class>("Unauthorized")',
+    )
+  })
+
+  test("preserves tagged error response statuses", () => {
+    class Missing extends Schema.TaggedErrorClass<Missing>()("Missing", {}) {}
+    const output = compile(
+      api(
+        HttpApiEndpoint.get("get", "/session", {
+          success: Schema.String,
+          error: Missing.pipe(HttpApiSchema.status(404)),
+        }),
+      ),
+    )
+
+    expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
+      'Endpoint0Error0Class.annotate({ "httpApiStatus": 404 })',
+    )
+  })
+
+  test("supports every HttpApi method through the generic constructor", () => {
+    const output = compile(api(HttpApiEndpoint.make("TRACE")("trace", "/trace", { success: Schema.String })))
+
+    expect(output.files.find((file) => file.path === "session.ts")?.content).toContain('HttpApiEndpoint.make("TRACE")')
+  })
+
+  test("uses safe unique module paths without changing public group identifiers", () => {
+    const output = compile(
+      HttpApi.make("test")
+        .add(HttpApiGroup.make("../session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })))
+        .add(HttpApiGroup.make("GROUP-0").add(HttpApiEndpoint.get("list", "/session", { success: Schema.String }))),
+    )
+
+    expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["group-0.ts", "GROUP-0-1.ts"])
+    expect(output.files[0]?.content).toContain('HttpApiGroup.make("../session"')
+  })
+
+  test("reserves support module names case-insensitively", () => {
+    const output = compile(
+      HttpApi.make("test")
+        .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("get", "/client", { success: Schema.String })))
+        .add(HttpApiGroup.make("INDEX").add(HttpApiEndpoint.get("get", "/index", { success: Schema.String }))),
+    )
+
+    expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-0.ts", "INDEX-1.ts"])
+  })
+
+  test("keeps searching when a reserved-name fallback is also occupied", () => {
+    const output = compile(
+      HttpApi.make("test")
+        .add(HttpApiGroup.make("client-1").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
+        .add(HttpApiGroup.make("client").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))),
+    )
+
+    expect(output.files.slice(0, 2).map((file) => file.path)).toEqual(["client-1.ts", "client-1-1.ts"])
+  })
+
+  test("rejects collisions in the flattened client namespace", () => {
+    expect(() =>
+      compile(
+        HttpApi.make("test")
+          .add(HttpApiGroup.make("status").add(HttpApiEndpoint.get("get", "/nested", { success: Schema.String })))
+          .add(
+            HttpApiGroup.make("system", { topLevel: true }).add(
+              HttpApiEndpoint.get("status", "/status", { success: Schema.String }),
+            ),
+          ),
+      ),
+    ).toThrow("Client name collision: status")
+  })
+
+  test("emits a usable raw type for top-level groups", () => {
+    const output = compile(
+      HttpApi.make("test").add(
+        HttpApiGroup.make("health", { topLevel: true }).add(
+          HttpApiEndpoint.get("check", "/health", { success: Schema.String }),
+        ),
+      ),
+    )
+
+    expect(output.files[0]?.content).toContain("type RawGroup = HttpApiClient.Client<typeof Group0")
+  })
+
+  it.effect("reports compiler failures in the generate Effect", () =>
+    Effect.gen(function* () {
+      const error = yield* generate(
+        api(
+          HttpApiEndpoint.get("get", "/url", {
+            success: Schema.declare((input): input is URL => input instanceof URL),
+          }),
+        ),
+        {
+          directory: "/generated",
+        },
+      ).pipe(Effect.flip)
+
+      expect(error).toBeInstanceOf(GenerationError)
+      if (error instanceof GenerationError) expect(error.reason).toBe("Unportable schema: session.get.success")
+    }).pipe(Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({}))),
+  )
+
+  test("rejects required client middleware without an adapter", () => {
+    class SignedRequest extends HttpApiMiddleware.Service<SignedRequest>()("SignedRequest", {
+      requiredForClient: true,
+    }) {}
+
+    expect(() =>
+      compile(api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }).middleware(SignedRequest))),
+    ).toThrow("Client middleware requires adapter: SignedRequest")
+  })
+
+  test("maps transport and decode failures to one stable client error", () => {
+    const output = compile(
+      api(
+        HttpApiEndpoint.get("get", "/session", {
+          success: Schema.String,
+        }),
+      ),
+    )
+
+    expect(output.operations[0]?.errors).toContain("ClientError")
+    expect(output.operations[0]?.errors).not.toContain("HttpClientError")
+    expect(output.operations[0]?.errors).not.toContain("SchemaError")
+    expect(output.files.find((file) => file.path === "session.ts")?.content).toContain(
+      "new ClientError({ cause: error })",
+    )
+  })
+})

+ 28 - 0
packages/httpapi-codegen/test/generated-consumer.ts

@@ -0,0 +1,28 @@
+import { Effect, Stream } from "effect"
+import { HttpClient } from "effect/unstable/http"
+import { ClientError, OpenCode } from "./generated"
+import { Missing } from "./fixture"
+
+export const program = OpenCode.make().pipe(
+  Effect.map((client) => {
+    const health = client.session.health()
+    const list = client.session.list()
+    const filtered = client.session.list({ archived: true })
+    const get = client.session.get({ sessionID: "session" })
+    const interrupt = client.session.interrupt({ sessionID: "session" })
+    const status = client.status()
+    const subscribe = client.event.subscribe()
+
+    const _health: Effect.Effect<string, ClientError> = health
+    const _list: Effect.Effect<ReadonlyArray<string>, ClientError> = list
+    const _filtered: Effect.Effect<ReadonlyArray<string>, ClientError> = filtered
+    const _get: Effect.Effect<string, Missing | ClientError> = get
+    const _interrupt: Effect.Effect<void, ClientError> = interrupt
+    const _status: Effect.Effect<string, ClientError> = status
+    const _subscribe: Stream.Stream<{ readonly type: string }, ClientError> = subscribe
+
+    return { _health, _list, _filtered, _get, _interrupt, _status, _subscribe }
+  }),
+)
+
+const _requiresHttpClient: Effect.Effect<unknown, never, HttpClient.HttpClient> = program

+ 5 - 0
packages/httpapi-codegen/test/generated/client-error.ts

@@ -0,0 +1,5 @@
+import { Schema } from "effect"
+
+export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
+  cause: Schema.Defect(),
+}) {}

+ 16 - 0
packages/httpapi-codegen/test/generated/client.ts

@@ -0,0 +1,16 @@
+// Generated by @opencode-ai/httpapi-codegen. Do not edit.
+import { Effect } from "effect"
+import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
+import { adaptGroup0, Group0 } from "./session"
+import { adaptGroup1, Group1 } from "./event"
+import { adaptGroup2, Group2 } from "./system"
+
+const Api = HttpApi.make("generated").add(Group0).add(Group1).add(Group2)
+const adaptClient = (raw: HttpApiClient.ForApi<typeof Api>) => ({
+  session: adaptGroup0(raw["session"]),
+  event: adaptGroup1(raw["event"]),
+  ...adaptGroup2({ status: raw["status"] }),
+})
+
+export const make = (options?: { readonly baseUrl?: URL | string }) =>
+  HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))

+ 39 - 0
packages/httpapi-codegen/test/generated/event.ts

@@ -0,0 +1,39 @@
+// Generated by @opencode-ai/httpapi-codegen. Do not edit.
+import { Effect, Schema, Stream } from "effect"
+import { Sse } from "effect/unstable/encoding"
+import { HttpClientError } from "effect/unstable/http"
+import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
+import { ClientError } from "./client-error"
+
+const Endpoint0SuccessData = Schema.Struct({ type: Schema.String })
+
+const Endpoint0SuccessError = Schema.Never
+
+export const Group1 = HttpApiGroup.make("event", { topLevel: false }).add(
+  HttpApiEndpoint.make("GET")("subscribe", "/event", {
+    success: HttpApiSchema.StreamSse({
+      data: Endpoint0SuccessData,
+      error: Endpoint0SuccessError,
+      contentType: "text/event-stream",
+    }).pipe(HttpApiSchema.status(202)),
+  }),
+)
+
+type RawGroup = HttpApiClient.Client.Group<typeof Group1, "event", never, never>
+
+const Endpoint0DeclaredError = Schema.Union([Endpoint0SuccessError])
+const mapEndpoint0Error = (error: unknown) =>
+  HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
+    ? new ClientError({ cause: error })
+    : Schema.is(Endpoint0DeclaredError)(error)
+      ? error
+      : new ClientError({ cause: error })
+const Endpoint0 = (raw: RawGroup) => () =>
+  Stream.unwrap(
+    raw["subscribe"]({}).pipe(
+      Effect.mapError(mapEndpoint0Error),
+      Effect.map((stream) => stream.pipe(Stream.mapError(mapEndpoint0Error))),
+    ),
+  )
+
+export const adaptGroup1 = (raw: RawGroup) => ({ subscribe: Endpoint0(raw) })

+ 2 - 0
packages/httpapi-codegen/test/generated/index.ts

@@ -0,0 +1,2 @@
+export { ClientError } from "./client-error"
+export * as OpenCode from "./client"

+ 96 - 0
packages/httpapi-codegen/test/generated/session.ts

@@ -0,0 +1,96 @@
+// Generated by @opencode-ai/httpapi-codegen. Do not edit.
+import { Effect, Schema } from "effect"
+import { Sse } from "effect/unstable/encoding"
+import { HttpClientError } from "effect/unstable/http"
+import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
+import { ClientError } from "./client-error"
+
+const Endpoint0Success = Schema.String
+
+const Endpoint1Query = Schema.Struct({ archived: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Undefined])) })
+
+const Endpoint1Success = Schema.Array(Schema.String)
+
+const Endpoint2Params = Schema.Struct({ sessionID: Schema.String })
+
+const Endpoint2Success = Schema.Struct({ data: Schema.String })
+
+class Endpoint2Error0Class extends Schema.TaggedErrorClass<Endpoint2Error0Class>("Missing")("Missing", {
+  message: Schema.String,
+}) {}
+const Endpoint2Error0 = Endpoint2Error0Class.annotate({ httpApiStatus: 404 })
+
+const Endpoint3Params = Schema.Struct({ sessionID: Schema.String })
+
+const Endpoint3Success = Schema.Void.annotate({ httpApiStatus: 204 })
+
+export const Group0 = HttpApiGroup.make("session", { topLevel: false })
+  .add(HttpApiEndpoint.make("GET")("health", "/session/health", { success: Endpoint0Success }))
+  .add(HttpApiEndpoint.make("GET")("list", "/session", { query: Endpoint1Query, success: Endpoint1Success }))
+  .add(
+    HttpApiEndpoint.make("GET")("get", "/session/:sessionID", {
+      params: Endpoint2Params,
+      success: Endpoint2Success,
+      error: Endpoint2Error0,
+    }),
+  )
+  .add(
+    HttpApiEndpoint.make("POST")("interrupt", "/session/:sessionID/interrupt", {
+      params: Endpoint3Params,
+      success: Endpoint3Success,
+    }),
+  )
+
+type RawGroup = HttpApiClient.Client.Group<typeof Group0, "session", never, never>
+
+const Endpoint0DeclaredError = Schema.Never
+const mapEndpoint0Error = (error: unknown) =>
+  HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
+    ? new ClientError({ cause: error })
+    : Schema.is(Endpoint0DeclaredError)(error)
+      ? error
+      : new ClientError({ cause: error })
+const Endpoint0 = (raw: RawGroup) => () => raw["health"]({}).pipe(Effect.mapError(mapEndpoint0Error))
+
+type Endpoint1Input = { readonly archived?: (typeof Endpoint1Query.Type)["archived"] }
+const Endpoint1DeclaredError = Schema.Never
+const mapEndpoint1Error = (error: unknown) =>
+  HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
+    ? new ClientError({ cause: error })
+    : Schema.is(Endpoint1DeclaredError)(error)
+      ? error
+      : new ClientError({ cause: error })
+const Endpoint1 = (raw: RawGroup) => (input?: Endpoint1Input) =>
+  raw["list"]({ query: { archived: input?.["archived"] } }).pipe(Effect.mapError(mapEndpoint1Error))
+
+type Endpoint2Input = { readonly sessionID: (typeof Endpoint2Params.Type)["sessionID"] }
+const Endpoint2DeclaredError = Schema.Union([Endpoint2Error0])
+const mapEndpoint2Error = (error: unknown) =>
+  HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
+    ? new ClientError({ cause: error })
+    : Schema.is(Endpoint2DeclaredError)(error)
+      ? error
+      : new ClientError({ cause: error })
+const Endpoint2 = (raw: RawGroup) => (input: Endpoint2Input) =>
+  raw["get"]({ params: { sessionID: input["sessionID"] } }).pipe(
+    Effect.mapError(mapEndpoint2Error),
+    Effect.map((value) => value.data),
+  )
+
+type Endpoint3Input = { readonly sessionID: (typeof Endpoint3Params.Type)["sessionID"] }
+const Endpoint3DeclaredError = Schema.Never
+const mapEndpoint3Error = (error: unknown) =>
+  HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
+    ? new ClientError({ cause: error })
+    : Schema.is(Endpoint3DeclaredError)(error)
+      ? error
+      : new ClientError({ cause: error })
+const Endpoint3 = (raw: RawGroup) => (input: Endpoint3Input) =>
+  raw["interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapEndpoint3Error))
+
+export const adaptGroup0 = (raw: RawGroup) => ({
+  health: Endpoint0(raw),
+  list: Endpoint1(raw),
+  get: Endpoint2(raw),
+  interrupt: Endpoint3(raw),
+})

+ 25 - 0
packages/httpapi-codegen/test/generated/system.ts

@@ -0,0 +1,25 @@
+// Generated by @opencode-ai/httpapi-codegen. Do not edit.
+import { Effect, Schema } from "effect"
+import { Sse } from "effect/unstable/encoding"
+import { HttpClientError } from "effect/unstable/http"
+import { HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
+import { ClientError } from "./client-error"
+
+const Endpoint0Success = Schema.String
+
+export const Group2 = HttpApiGroup.make("system", { topLevel: true }).add(
+  HttpApiEndpoint.make("GET")("status", "/status", { success: Endpoint0Success }),
+)
+
+type RawGroup = HttpApiClient.Client<typeof Group2>
+
+const Endpoint0DeclaredError = Schema.Never
+const mapEndpoint0Error = (error: unknown) =>
+  HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
+    ? new ClientError({ cause: error })
+    : Schema.is(Endpoint0DeclaredError)(error)
+      ? error
+      : new ClientError({ cause: error })
+const Endpoint0 = (raw: RawGroup) => () => raw["status"]({}).pipe(Effect.mapError(mapEndpoint0Error))
+
+export const adaptGroup2 = (raw: RawGroup) => ({ status: Endpoint0(raw) })

+ 160 - 0
packages/httpapi-codegen/test/write.test.ts

@@ -0,0 +1,160 @@
+import { describe, expect } from "bun:test"
+import { Effect, FileSystem, Option } from "effect"
+import { write, type Output } from "../src"
+import { it } from "./effect"
+
+describe("HttpApiCodegen.write", () => {
+  it.effect("writes compiled files beneath the output directory", () => {
+    const writes: Array<{ readonly path: string; readonly content: string }> = []
+    const output: Output = {
+      operations: [],
+      files: [{ path: "session.ts", content: "export const session = {}" }],
+    }
+
+    return Effect.gen(function* () {
+      yield* write(output, "/generated")
+
+      expect(writes).toEqual([
+        { path: "/generated/session.ts", content: "export const session = {}\n" },
+        { path: "/generated/.httpapi-codegen.json", content: '[\n  "session.ts"\n]\n' },
+      ])
+    }).pipe(
+      Effect.provideService(
+        FileSystem.FileSystem,
+        FileSystem.makeNoop({
+          exists: () => Effect.succeed(false),
+          makeDirectory: () => Effect.void,
+          writeFileString: (path, content) => {
+            writes.push({ path, content })
+            return Effect.void
+          },
+        }),
+      ),
+    )
+  })
+
+  it.effect("removes only stale files owned by the previous manifest", () => {
+    const removed: Array<string> = []
+    return write(
+      {
+        operations: [],
+        files: [{ path: "session.ts", content: "" }],
+      },
+      "/generated",
+    ).pipe(
+      Effect.provideService(
+        FileSystem.FileSystem,
+        FileSystem.makeNoop({
+          exists: (path) => Effect.succeed(path.endsWith(".httpapi-codegen.json")),
+          makeDirectory: () => Effect.void,
+          readFileString: () => Effect.succeed('["old.ts", "session.ts"]'),
+          remove: (path) => {
+            removed.push(path)
+            return Effect.void
+          },
+          writeFileString: () => Effect.void,
+        }),
+      ),
+      Effect.tap(() => Effect.sync(() => expect(removed).toEqual(["/generated/old.ts"]))),
+    )
+  })
+
+  it.effect("rejects unsafe and duplicate output paths before writing", () => {
+    const writes: Array<string> = []
+    return Effect.gen(function* () {
+      const error = yield* write(
+        {
+          operations: [],
+          files: [
+            { path: "../outside.ts", content: "" },
+            { path: "client.ts", content: "" },
+            { path: "CLIENT.ts", content: "" },
+          ],
+        },
+        "/generated",
+      ).pipe(Effect.flip)
+
+      expect(error._tag).toBe("GenerationError")
+      expect(writes).toEqual([])
+    }).pipe(
+      Effect.provideService(
+        FileSystem.FileSystem,
+        FileSystem.makeNoop({
+          writeFileString: (path) => {
+            writes.push(path)
+            return Effect.void
+          },
+        }),
+      ),
+    )
+  })
+
+  it.effect("rejects case-insensitive duplicate output paths", () => {
+    const writes: Array<string> = []
+    return Effect.gen(function* () {
+      const error = yield* write(
+        {
+          operations: [],
+          files: [
+            { path: "client.ts", content: "" },
+            { path: "CLIENT.ts", content: "" },
+          ],
+        },
+        "/generated",
+      ).pipe(Effect.flip)
+
+      expect(error._tag).toBe("GenerationError")
+      expect(error.reason).toBe("Duplicate output path: CLIENT.ts")
+      expect(writes).toEqual([])
+    }).pipe(
+      Effect.provideService(
+        FileSystem.FileSystem,
+        FileSystem.makeNoop({
+          writeFileString: (path) => {
+            writes.push(path)
+            return Effect.void
+          },
+        }),
+      ),
+    )
+  })
+
+  it.effect("reserves the private manifest path", () =>
+    write({ operations: [], files: [{ path: ".httpapi-codegen.json", content: "" }] }, "/generated").pipe(
+      Effect.flip,
+      Effect.tap((error) => Effect.sync(() => expect(error.reason).toContain("Unsafe output path"))),
+      Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})),
+    ),
+  )
+
+  it.effect("rejects existing symbolic-link output targets", () =>
+    write({ operations: [], files: [{ path: "session.ts", content: "" }] }, "/generated").pipe(
+      Effect.flip,
+      Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("Unsafe output path: session.ts"))),
+      Effect.provideService(
+        FileSystem.FileSystem,
+        FileSystem.makeNoop({
+          exists: (path) => Effect.succeed(path.endsWith("session.ts")),
+          makeDirectory: () => Effect.void,
+          stat: () =>
+            Effect.succeed({
+              type: "SymbolicLink",
+              mtime: Option.none(),
+              atime: Option.none(),
+              birthtime: Option.none(),
+              dev: 0,
+              ino: Option.none(),
+              mode: 0,
+              nlink: Option.none(),
+              uid: Option.none(),
+              gid: Option.none(),
+              rdev: Option.none(),
+              size: FileSystem.Size(0),
+              blksize: Option.none(),
+              blocks: Option.none(),
+            }),
+        }),
+      ),
+    ),
+  )
+})

+ 8 - 0
packages/httpapi-codegen/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
+  }
+}

+ 27 - 0
packages/sdk-next/README.md

@@ -0,0 +1,27 @@
+# @opencode-ai/sdk-next
+
+Effect-native scoped OpenCode host for in-process applications. This transitional package will replace the existing generated `@opencode-ai/sdk` after its consumers migrate.
+
+The SDK executes Server's assembled HTTP router in memory. It opens no listener and performs no network I/O, while preserving the same routing, middleware, handlers, codecs, and errors as the network client.
+
+```ts
+import { OpenCode } from "@opencode-ai/sdk-next"
+
+const opencode = yield * OpenCode.create()
+const session = yield * opencode.sessions.get({ sessionID })
+```
+
+It also exposes local-only `tools.register(...)`. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
+
+The same constructor is available as a service Layer:
+
+```ts
+const program = Effect.gen(function* () {
+  const opencode = yield* OpenCode.Service
+  return yield* opencode.sessions.get({ sessionID })
+})
+
+yield * program.pipe(Effect.provide(OpenCode.layer))
+```
+
+`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation.

+ 25 - 0
packages/sdk-next/package.json

@@ -0,0 +1,25 @@
+{
+  "$schema": "https://json.schemastore.org/package.json",
+  "name": "@opencode-ai/sdk-next",
+  "private": true,
+  "type": "module",
+  "license": "MIT",
+  "exports": {
+    ".": "./src/index.ts"
+  },
+  "scripts": {
+    "test": "bun test --timeout 5000",
+    "typecheck": "tsgo --noEmit"
+  },
+  "dependencies": {
+    "@opencode-ai/client": "workspace:*",
+    "@opencode-ai/core": "workspace:*",
+    "@opencode-ai/server": "workspace:*",
+    "effect": "catalog:"
+  },
+  "devDependencies": {
+    "@tsconfig/bun": "catalog:",
+    "@types/bun": "catalog:",
+    "@typescript/native-preview": "catalog:"
+  }
+}

+ 16 - 0
packages/sdk-next/src/index.ts

@@ -0,0 +1,16 @@
+export * as OpenCode from "./opencode"
+export * as Tool from "./tool"
+
+export { ClientError } from "@opencode-ai/client/effect"
+export {
+  AbsolutePath,
+  Agent,
+  Location,
+  Model,
+  Prompt,
+  Provider,
+  RelativePath,
+  Session,
+  SessionInput,
+  SessionMessage,
+} from "@opencode-ai/client/effect"

+ 53 - 0
packages/sdk-next/src/opencode.ts

@@ -0,0 +1,53 @@
+import { OpenCode } from "@opencode-ai/client/effect"
+import { PermissionSaved } from "@opencode-ai/core/permission/saved"
+import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
+import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
+import { Cause, Context, Effect, Layer } from "effect"
+import {
+  HttpClient,
+  HttpRouter,
+  HttpServer,
+  HttpServerError,
+  HttpServerRequest,
+  HttpServerResponse,
+} from "effect/unstable/http"
+
+export const create = Effect.fn("OpenCode.create")(function* () {
+  const applicationTools = ApplicationTools.layer
+  const { handler, permissions, tools } = yield* Effect.all({
+    // Reusing this Layer value lets registration and every Location share one memoized host-level registry.
+    handler: HttpRouter.toHttpEffect(
+      createEmbeddedRoutes().pipe(Layer.provide(applicationTools), Layer.provide(HttpServer.layerServices)),
+    ),
+    permissions: PermissionSaved.Service,
+    tools: ApplicationTools.Service,
+  }).pipe(Effect.provide(Layer.merge(applicationTools, PermissionSaved.defaultLayer)))
+  const httpClient = HttpClient.make(
+    Effect.fnUntraced(function* (request) {
+      const response = yield* handler.pipe(
+        Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(request)),
+        Effect.provideService(ApplicationTools.Service, tools),
+        Effect.provideService(PermissionSaved.Service, permissions),
+        Effect.catchCause((cause) =>
+          Cause.hasInterruptsOnly(cause)
+            ? Effect.interrupt
+            : HttpServerError.causeResponse(cause).pipe(Effect.map(([response]) => response)),
+        ),
+      )
+      return HttpServerResponse.toClientResponse(response, { request })
+    }, Effect.scoped),
+  )
+  const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
+    Effect.provideService(HttpClient.HttpClient, httpClient),
+  )
+  return {
+    ...client,
+    tools: { register: tools.register },
+  }
+})
+
+export type Interface = Effect.Success<ReturnType<typeof create>>
+
+export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk-next/OpenCode") {}
+
+export const layer = Layer.effect(Service, create())

+ 2 - 0
packages/sdk-next/src/tool.ts

@@ -0,0 +1,2 @@
+export { Failure, RegistrationError, make } from "@opencode-ai/core/tool/tool"
+export type { AnyTool, Content, Context, Definition } from "@opencode-ai/core/tool/tool"

+ 84 - 0
packages/sdk-next/test/embedded.test.ts

@@ -0,0 +1,84 @@
+import { expect, test } from "bun:test"
+import { mkdtemp, rm } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { Effect, Schema } from "effect"
+
+test("embedded client uses the real router and handlers", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-"))
+  const database = Flag.OPENCODE_DB
+  Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
+  const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src")
+  const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
+  const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") })
+
+  try {
+    const program = Effect.gen(function* () {
+      const opencode = yield* OpenCode.create()
+      yield* opencode.tools.register({
+        embedded_tool: Tool.make({
+          description: "Embedded test tool",
+          input: Schema.Struct({}),
+          output: Schema.Struct({ ok: Schema.Boolean }),
+          execute: () => Effect.succeed({ ok: true }),
+        }),
+      })
+
+      const created = yield* opencode.sessions.create({
+        id: sessionID,
+        agent: Agent.ID.make("build"),
+        location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
+      })
+      yield* opencode.sessions.switchModel({ sessionID, model })
+      const selected = yield* opencode.sessions.get({ sessionID })
+      const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) })
+      const admitted = yield* opencode.sessions.prompt({
+        sessionID,
+        prompt: Prompt.make({ text: "Do not run" }),
+        resume: false,
+      })
+      const context = yield* opencode.sessions.context({ sessionID })
+      const missing = yield* Effect.flip(
+        opencode.sessions.get({ sessionID: Session.ID.make(`ses_missing_${crypto.randomUUID()}`) }),
+      )
+
+      expect(created.id).toBe(sessionID)
+      expect(selected.model?.id).toBe(model.id)
+      expect(selected.model?.providerID).toBe(model.providerID)
+      expect(page.data.some((session) => session.id === sessionID)).toBe(true)
+      expect(admitted.sessionID).toBe(sessionID)
+      expect(context.some((message) => message.type === "model-switched")).toBe(true)
+      expect(missing._tag).toBe("SessionNotFoundError")
+    })
+    await Effect.runPromise(Effect.scoped(program))
+  } finally {
+    Flag.OPENCODE_DB = database
+    await rm(directory, { recursive: true, force: true })
+  }
+})
+
+test("embedded client is available as a Layer service", async () => {
+  const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-"))
+  const database = Flag.OPENCODE_DB
+  Flag.OPENCODE_DB = join(directory, "opencode.sqlite")
+  const { AbsolutePath, Location, OpenCode, Session } = await import("../src")
+  const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`)
+
+  try {
+    const created = await Effect.runPromise(
+      Effect.gen(function* () {
+        const opencode = yield* OpenCode.Service
+        return yield* opencode.sessions.create({
+          id: sessionID,
+          location: Location.Ref.make({ directory: AbsolutePath.make(directory) }),
+        })
+      }).pipe(Effect.provide(OpenCode.layer), Effect.scoped),
+    )
+
+    expect(created.id).toBe(sessionID)
+  } finally {
+    Flag.OPENCODE_DB = database
+    await rm(directory, { recursive: true, force: true })
+  }
+})

+ 53 - 0
packages/sdk-next/test/import-boundaries.test.ts

@@ -0,0 +1,53 @@
+import { expect, test } from "bun:test"
+import { mkdtemp, rm } from "node:fs/promises"
+import { join, resolve, sep } from "node:path"
+
+const directory = resolve(import.meta.dir, "..")
+const client = resolve(import.meta.dir, "../../client")
+const core = resolve(import.meta.dir, "../../core")
+const server = resolve(import.meta.dir, "../../server")
+
+test("bundles the client and in-memory host", async () => {
+  const inputs = await bundleInputs()
+
+  expect(within(inputs, client).length).toBeGreaterThan(0)
+  expect(within(inputs, core).length).toBeGreaterThan(0)
+  expect(within(inputs, server).length).toBeGreaterThan(0)
+})
+
+async function bundleInputs() {
+  const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-"))
+  const entrypoint = join(temporary, "index.ts")
+  const metafile = join(temporary, "meta.json")
+  try {
+    await Bun.write(entrypoint, 'export * from "@opencode-ai/sdk-next"')
+    const child = Bun.spawn(
+      [
+        process.execPath,
+        "build",
+        entrypoint,
+        "--target=bun",
+        "--format=esm",
+        "--packages=bundle",
+        `--metafile=${metafile}`,
+        `--outdir=${join(temporary, "out")}`,
+      ],
+      { cwd: directory, stdout: "pipe", stderr: "pipe" },
+    )
+    const [exitCode, stdout, stderr] = await Promise.all([
+      child.exited,
+      new Response(child.stdout).text(),
+      new Response(child.stderr).text(),
+    ])
+    if (exitCode !== 0) throw new Error(stdout + stderr)
+    const metadata = await Bun.file(metafile).json()
+    return Object.keys(metadata.inputs).map((input) => resolve(directory, input))
+  } finally {
+    await rm(temporary, { recursive: true, force: true })
+  }
+}
+
+function within(inputs: ReadonlyArray<string>, directory: string) {
+  const prefix = directory.endsWith(sep) ? directory : directory + sep
+  return inputs.filter((input) => input === directory || input.startsWith(prefix))
+}

+ 8 - 0
packages/sdk-next/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
+  }
+}

+ 13 - 5
packages/server/src/routes.ts

@@ -12,16 +12,24 @@ import { schemaErrorLayer } from "./middleware/schema-error"
 import { PtyEnvironment } from "./pty-environment"
 
 export function createRoutes(password?: string) {
+  return makeRoutes(
+    password
+      ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) })
+      : ServerAuth.Config.defaultLayer,
+  )
+}
+
+export function createEmbeddedRoutes() {
+  return makeRoutes(ServerAuth.Config.layer({ username: "opencode", password: Option.none() }))
+}
+
+function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>) {
   return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
     Layer.provide(handlers),
     Layer.provide(PtyEnvironment.defaultLayer),
     Layer.provide(authorizationLayer),
     Layer.provide(schemaErrorLayer),
-    Layer.provide(
-      password
-        ? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) })
-        : ServerAuth.Config.defaultLayer,
-    ),
+    Layer.provide(auth),
     Layer.provide(LocationServiceMap.layer),
     Layer.provide(Database.defaultLayer),
     Layer.provide(EventV2.defaultLayer),

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است