Skip to content

Commit e03f5dd

Browse files
committed
feat(client): add embedded V2 host
1 parent 924e007 commit e03f5dd

25 files changed

Lines changed: 507 additions & 432 deletions

CONTEXT.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ _Avoid_: Response envelope
123123
- The public `HttpApi` is authoritative for shared **OpenCode Client** capabilities: the server hosts those exact endpoint declarations and code generation consumes them directly. Public endpoints are not duplicated or projected from a separately named internal contract.
124124
- 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.
125125
- 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.
126-
- 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 the dependency-leaf `@opencode-ai/api` package instead; the Promise emitter still derives zero-Effect structural wire types from the same IR.
127-
- `@opencode-ai/api` owns the lightweight authoritative public `HttpApi` and its runtime schemas. It depends only on Effect, does not import Core or server implementation packages, and is hosted by the server with server-only middleware added during composition.
126+
- 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 the canonical V2 server `HttpApi`; the Promise emitter still derives zero-Effect structural wire types from the same IR.
127+
- `@opencode-ai/server` owns the authoritative V2 `HttpApi`. The real server and client generation consume that same API value; generator selection controls emitted capabilities without redefining their contracts.
128128
- 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.
129129
- 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.
130130
- 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.

bun.lock

Lines changed: 2 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/api/package.json

Lines changed: 0 additions & 22 deletions
This file was deleted.

packages/api/tsconfig.json

Lines changed: 0 additions & 4 deletions
This file was deleted.

packages/client/README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ Private generation target for clients derived directly from OpenCode's authorita
88
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
99
- `@opencode-ai/client/effect/embedded`: scoped embedded OpenCode host backed by Core and the in-memory HTTP router.
1010

11-
The initial generated surface contains `sessions.list`, `create`, `get`, `switchAgent`, `switchModel`, and `prompt`, sourced from the public `HttpApi` in `@opencode-ai/api` and hosted by `@opencode-ai/server`. Run `bun run generate` after changing that contract and `bun run check:generated` to detect committed-output drift.
11+
The initial generated surface contains `sessions.list`, `create`, `get`, `switchAgent`, `switchModel`, and `prompt`, sourced directly from the V2 `HttpApi` hosted by `@opencode-ai/server`. Run `bun run generate` after changing that contract and `bun run check:generated` to detect committed-output drift.
1212

13-
The embedded entrypoint remains intentionally empty until the scoped in-memory host is implemented.
13+
The embedded entrypoint exposes a scoped host backed by the same server router, middleware, handlers, and HTTP codecs as the network client:
14+
15+
```ts
16+
import { OpenCode } from "@opencode-ai/client/effect/embedded"
17+
18+
const opencode = yield * OpenCode.create()
19+
const session = yield * opencode.sessions.get({ sessionID })
20+
```
21+
22+
It also exposes embedded-only `tools.register(...)`. Closing the owning Effect Scope releases the router resources, location services, fibers, and scoped tool registrations.

packages/client/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"typecheck": "tsgo --noEmit"
1717
},
1818
"dependencies": {
19-
"@opencode-ai/api": "workspace:*"
19+
"@opencode-ai/core": "workspace:*",
20+
"@opencode-ai/server": "workspace:*"
2021
},
2122
"peerDependencies": {
2223
"effect": "4.0.0-beta.83"

packages/client/script/build.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,43 @@
11
import { NodeFileSystem } from "@effect/platform-node"
2-
import { Api } from "@opencode-ai/api"
2+
import {
3+
SessionsCreate,
4+
SessionsGet,
5+
SessionsList,
6+
SessionsPrompt,
7+
SessionsSwitchAgent,
8+
SessionsSwitchModel,
9+
} from "@opencode-ai/server/groups/session-endpoints"
310
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
411
import { Effect } from "effect"
12+
import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi"
513

14+
const Api = HttpApi.make("opencode-client").add(
15+
HttpApiGroup.make("sessions")
16+
.add(SessionsList)
17+
.add(SessionsCreate)
18+
.add(SessionsGet)
19+
.add(SessionsSwitchAgent)
20+
.add(SessionsSwitchModel)
21+
.add(SessionsPrompt),
22+
)
623
const contract = compile(Api)
724

825
await Effect.runPromise(
926
Effect.all(
1027
[
1128
write(emitPromise(contract), new URL("../src/generated", import.meta.url).pathname),
1229
write(
13-
emitEffectImported(contract, { module: "@opencode-ai/api", api: "Api" }),
30+
emitEffectImported(contract, {
31+
module: "@opencode-ai/server/groups/session-endpoints",
32+
endpoints: {
33+
"sessions.list": "SessionsList",
34+
"sessions.create": "SessionsCreate",
35+
"sessions.get": "SessionsGet",
36+
"sessions.switchAgent": "SessionsSwitchAgent",
37+
"sessions.switchModel": "SessionsSwitchModel",
38+
"sessions.prompt": "SessionsPrompt",
39+
},
40+
}),
1441
new URL("../src/generated-effect", import.meta.url).pathname,
1542
),
1643
],
Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,37 @@
1-
// Embedded Effect host target. Intentionally empty until the public HttpApi is available.
2-
export {}
1+
export * as OpenCode from "./effect-embedded"
2+
3+
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
4+
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
5+
import { Context, Effect, Layer } from "effect"
6+
import { HttpClient, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
7+
import { OpenCode as Generated } from "./generated-effect/index"
8+
9+
export const create = Effect.fn("OpenCode.create")(function* () {
10+
const context = yield* Layer.build(
11+
Layer.merge(
12+
createEmbeddedRoutes().pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(HttpRouter.layer)),
13+
ApplicationTools.layer,
14+
),
15+
)
16+
const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect()
17+
const httpClient = HttpClient.make(
18+
Effect.fnUntraced(function* (request) {
19+
const response = yield* handler.pipe(
20+
Effect.provideService(HttpServerRequest.HttpServerRequest, HttpServerRequest.fromClientRequest(request)),
21+
Effect.orDie,
22+
)
23+
return HttpServerResponse.toClientResponse(response)
24+
}, Effect.scoped),
25+
)
26+
const client = yield* Generated.make({ baseUrl: "http://opencode.local" }).pipe(
27+
Effect.provideService(HttpClient.HttpClient, httpClient),
28+
)
29+
const tools = Context.get(context, ApplicationTools.Service)
30+
return {
31+
...client,
32+
tools: { register: tools.register },
33+
}
34+
})
35+
36+
export { ClientError } from "./generated-effect/index"
37+
export { Tool } from "@opencode-ai/core/public/tool"

packages/client/src/generated-effect/client.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,27 @@
22
import { Effect, Schema } from "effect"
33
import { Sse } from "effect/unstable/encoding"
44
import { HttpClientError } from "effect/unstable/http"
5-
import { HttpApiClient } from "effect/unstable/httpapi"
6-
import { Api } from "@opencode-ai/api"
5+
import { HttpApi, HttpApiClient, HttpApiGroup } from "effect/unstable/httpapi"
6+
import {
7+
SessionsList,
8+
SessionsCreate,
9+
SessionsGet,
10+
SessionsSwitchAgent,
11+
SessionsSwitchModel,
12+
SessionsPrompt,
13+
} from "@opencode-ai/server/groups/session-endpoints"
714
import { ClientError } from "./client-error"
815

16+
const Api = HttpApi.make("generated").add(
17+
HttpApiGroup.make("sessions")
18+
.add(SessionsList)
19+
.add(SessionsCreate)
20+
.add(SessionsGet)
21+
.add(SessionsSwitchAgent)
22+
.add(SessionsSwitchModel)
23+
.add(SessionsPrompt),
24+
)
25+
926
type RawClient = HttpApiClient.ForApi<typeof Api>
1027

1128
const mapClientError = <E,>(error: E) =>

packages/client/src/generated/types.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export type SessionsListInput = {
113113
export type SessionsListOutput = {
114114
readonly data: ReadonlyArray<{
115115
readonly id: string
116-
readonly parentID?: string
116+
readonly parentID?: string | null
117117
readonly projectID: string
118118
readonly agent?: string | null
119119
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
@@ -124,7 +124,11 @@ export type SessionsListOutput = {
124124
readonly reasoning: number
125125
readonly cache: { readonly read: number; readonly write: number }
126126
}
127-
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
127+
readonly time: {
128+
readonly created: number | "Infinity" | "-Infinity" | "NaN"
129+
readonly updated: number | "Infinity" | "-Infinity" | "NaN"
130+
readonly archived?: number | "Infinity" | "-Infinity" | "NaN" | null
131+
}
128132
readonly title: string
129133
readonly location: { readonly directory: string; readonly workspaceID?: string | null }
130134
readonly subpath?: string | null
@@ -170,7 +174,7 @@ export type SessionsCreateInput = {
170174
export type SessionsCreateOutput = {
171175
readonly data: {
172176
readonly id: string
173-
readonly parentID?: string
177+
readonly parentID?: string | null
174178
readonly projectID: string
175179
readonly agent?: string | null
176180
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
@@ -181,7 +185,11 @@ export type SessionsCreateOutput = {
181185
readonly reasoning: number
182186
readonly cache: { readonly read: number; readonly write: number }
183187
}
184-
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
188+
readonly time: {
189+
readonly created: number | "Infinity" | "-Infinity" | "NaN"
190+
readonly updated: number | "Infinity" | "-Infinity" | "NaN"
191+
readonly archived?: number | "Infinity" | "-Infinity" | "NaN" | null
192+
}
185193
readonly title: string
186194
readonly location: { readonly directory: string; readonly workspaceID?: string | null }
187195
readonly subpath?: string | null
@@ -193,7 +201,7 @@ export type SessionsGetInput = { readonly sessionID: { readonly sessionID: strin
193201
export type SessionsGetOutput = {
194202
readonly data: {
195203
readonly id: string
196-
readonly parentID?: string
204+
readonly parentID?: string | null
197205
readonly projectID: string
198206
readonly agent?: string | null
199207
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
@@ -204,7 +212,11 @@ export type SessionsGetOutput = {
204212
readonly reasoning: number
205213
readonly cache: { readonly read: number; readonly write: number }
206214
}
207-
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
215+
readonly time: {
216+
readonly created: number | "Infinity" | "-Infinity" | "NaN"
217+
readonly updated: number | "Infinity" | "-Infinity" | "NaN"
218+
readonly archived?: number | "Infinity" | "-Infinity" | "NaN" | null
219+
}
208220
readonly title: string
209221
readonly location: { readonly directory: string; readonly workspaceID?: string | null }
210222
readonly subpath?: string | null
@@ -343,7 +355,7 @@ export type SessionsPromptOutput = {
343355
}> | null
344356
}
345357
readonly delivery: "steer" | "queue"
346-
readonly timeCreated: number
358+
readonly timeCreated: number | "Infinity" | "-Infinity" | "NaN"
347359
readonly promotedSeq?: number | null
348360
}
349361
}["data"]

0 commit comments

Comments
 (0)