Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
17189d1
refactor(client): nest experimental endpoints under client.experimental
kitlangton Oct 25, 2026
e4fdeca
test(httpapi-codegen): cover namespaced groups in the effect client
kitlangton Oct 25, 2026
3ff1e46
fix(httpapi-codegen): key projected raw clients by client group name;…
kitlangton Oct 25, 2026
1411067
Merge branch 'dev' into kit/sdk-experimental-namespace
kitlangton Oct 25, 2026
ee63362
chore(app): clear lint:changed findings in files touched by the exper…
kitlangton Oct 26, 2026
faa72dc
Merge remote-tracking branch 'origin/dev' into kit/sdk-experimental-n…
kitlangton Oct 26, 2026
b6449b3
chore(app): clear lint:changed findings in renamed experimental callers
kitlangton Oct 26, 2026
bb5669f
docs: add experimental API rules to AGENTS.md and enforce experimenta…
kitlangton Oct 26, 2026
b824ae5
Merge remote-tracking branch 'origin/dev' into kit/sdk-experimental-n…
kitlangton Oct 26, 2026
8820bdd
feat(plugin): move experimental-backed plugin methods under ctx.exper…
kitlangton Oct 26, 2026
b5b7122
Merge remote-tracking branch 'origin/dev' into kit/sdk-experimental-n…
kitlangton Oct 26, 2026
363eda2
feat(sdk): keep pre-experimental SDK and plugin paths as deprecated a…
kitlangton Oct 26, 2026
da96c1c
fix(sdk): keep pre-experimental members on the Effect group interfaces
kitlangton Oct 26, 2026
fbf56c2
Merge remote-tracking branch 'origin/dev' into kit/sdk-experimental-n…
kitlangton Oct 26, 2026
5bee8ee
feat(client): generate deprecated SDK aliases from the codegen
kitlangton Oct 26, 2026
28ea6d1
Merge remote-tracking branch 'origin/dev' into kit/sdk-experimental-n…
kitlangton Oct 27, 2026
7109413
Merge remote-tracking branch 'origin/dev' into kit/sdk-experimental-n…
kitlangton Oct 27, 2026
10d53e3
chore: drop release reminders doc
kitlangton Oct 28, 2026
e7fec90
Merge remote-tracking branch 'origin/dev' into kit/sdk-experimental-n…
kitlangton Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/RELEASE_REMINDERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Release reminders

Read this before cutting a minor or major release, act on every item in its section, and delete each item once it ships. Add an item whenever a change leaves something to finish in a later release, such as a deprecated alias to remove.

## Remove at next minor

- [ ] The deprecated pre-experimental SDK aliases from [#59072](https://github.com/anomalyco/opencode/pull/59072). Each old path is now `client.experimental.<same path>`, and each old type name is now `Experimental<name>`:
- Methods: `client.session.{stats,import,export,skill,wait,log}`, `client.session.instructions.entry.{list,put,remove}`, `client.generate.text`, `client.voice.{models,session}`, `client.migration.v1.status`, `client.usage.limits`, `client.integration.wellknown.add`, `client.mcp.{add,remove,connect,disconnect}`, `client.mcp.client.token`, `client.file.{watch,unwatch}`, `client.config.update`, and `client.file.write` (Promise client only).
- Types: the Input, Output and (Effect) Operation types of those methods, such as `SessionWaitInput`, plus the Effect `GenerateApi`, `VoiceApi`, `MigrationApi` and `UsageApi`, and the Promise `GenerateApi` in `packages/client/src/promise/api.ts`.
- To remove: drop `aliases: true` from both `compile` calls in `packages/client/script/generate.ts` and run `bun run generate` in `packages/client`; delete `GenerateApi` from `packages/client/src/promise/api.ts`; delete `packages/client/test/deprecated-aliases.test.ts` and the alias entries in `packages/client/test/promise.test.ts`; drop the `Omit<IntegrationApi, "wellknown">` in `packages/plugin/src/{effect,promise}/integration.ts`.

## Remove at next major

No items yet.
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,21 @@ const table = sqliteTable("session", {
})
```

### Experimental API

- An experimental endpoint lives at `/api/experimental/<group>/...`, its OpenAPI identifier is `experimental.<group>.<method>`, and it lands in the SDK as `client.experimental.<group>.<method>`. Its description says "Experimental: may change without compatibility guarantees."
- `packages/protocol/test/experimental-identifiers.test.ts` enforces the path and identifier match in both directions; the SDK namespace follows from the path.
- An experimental field or parameter on a stable endpoint goes under an `experimental` key (`experimental.<name>`), the same way config does (`config.experimental.*`).
- New endpoints and new default behaviors start experimental.
- To graduate one, move the route out of `/api/experimental`, drop the `experimental.` prefix, and note it in `CHANGELOG.md`.
- Experimental HTTP routes change without aliases: rename or remove the route, then update the callers.
- A breaking move of shipped SDK or plugin API (including moving it under `experimental`) keeps the old path and type names as deprecated aliases of the new ones until the next minor, each marked `@deprecated` with the replacement and "Removed in the next minor". Generated SDK moves get them from the codegen (`compile(..., { aliases: true })` in `packages/client/script/generate.ts`), not by hand. Add the removal to `.github/RELEASE_REMINDERS.md`.
- Experimental plugin hooks are named `experimental.*` (`experimental.ws.handshake`).

## Releases

- Before cutting a minor or major release, read `.github/RELEASE_REMINDERS.md` and act on its section for that release.

## Testing

- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/home/projects/controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function createHomeProjectsController(home: HomeController) {
Schema.fromJsonString(Schema.Record(Schema.String, Schema.Json)),
)(await file.text())

const api = home.server.context(conn).sdk.api.session
const api = home.server.context(conn).sdk.api.experimental.session

// SAFETY: the import endpoint validates the transcript, including exports written by earlier releases.
const imported = await api.import({
Expand Down
17 changes: 10 additions & 7 deletions packages/app/src/providers/connect/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type McpControls = {
change: (name: string, enabled: boolean) => void
}

export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => unknown) {
export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => void) {
const data = useData()
const serverSDK = useServerSDK()
const platform = usePlatform()
Expand All @@ -27,19 +27,22 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess

return useMutation(() => ({
mutationFn: async (input: string | { name: string; enabled: boolean; directory?: string }) => {
const name = typeof input === "string" ? input : input.name
const ref = typeof input !== "string" && input.directory ? { directory: input.directory } : location()
// A bare name toggles the server; an object sets an explicit state.
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- discriminates this function's own input union
const request: { name: string; enabled?: boolean; directory?: string } = typeof input === "string" ? { name: input } : input
const name = request.name
const ref = request.directory ? { directory: request.directory } : location()
const server = (await serverSDK.api.mcp.list({ location: ref })).data.find((item) => item.name === name)

if (!server || (server.status.status === "pending" && typeof input === "string")) return
const enabled = typeof input === "string" ? server.status.status !== "connected" : input.enabled
if (!server || (server.status.status === "pending" && request.enabled === undefined)) return
const enabled = request.enabled ?? server.status.status !== "connected"

if (!enabled) {
await serverSDK.api.mcp.disconnect({ server: name, location: ref })
await serverSDK.api.experimental.mcp.disconnect({ server: name, location: ref })
}

if (enabled && server.status.status !== "needs_auth") {
await serverSDK.api.mcp.connect({ server: name, location: ref })
await serverSDK.api.experimental.mcp.connect({ server: name, location: ref })
}

data.location.mcp.server.invalidate(ref)
Expand Down
7 changes: 4 additions & 3 deletions packages/app/src/runtime/server/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
if (update.time.archived) return rest

const previous = projects.find((project) => project.id === update.id)

// The wire payload carries no worktrees; keep the inventory this project already loaded.
const next = withWorktreeInventory(
previous ? updateProjectInfo(previous, update) : normalizeProjectInfo(update),
Expand Down Expand Up @@ -314,7 +315,7 @@ function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
const updateConfigMutation = useMutation(() => ({
mutationFn: async (config: Config) => {
// TODO: Restore config updates when the V2 client exposes a config API.
// await serverSDK.api.config.update({ config })
// await serverSDK.api.experimental.config.update({ config })
throw new Error(`Config updates are unavailable: ${Object.keys(config).length} fields were not saved`)
},
onSuccess: () => {
Expand Down Expand Up @@ -343,10 +344,10 @@ function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
await toggleMcp({
status,
connect: async () => {
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
await serverSDK.api.experimental.mcp.connect({ server: name, location: { directory: key } })
},
disconnect: async () => {
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
await serverSDK.api.experimental.mcp.disconnect({ server: name, location: { directory: key } })
},
authenticate: async () => {
const server = (await serverSDK.api.mcp.list({ location: { directory: key } })).data.find(
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/session/revert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function createSessionRevert(input: {
// Like the TUI, stop at the first failure instead of waiting on work that was never interrupted.
if (!(await request(() => server.api.session.interrupt({ sessionID })))) return

if (!(await request(() => server.api.session.wait({ sessionID })))) return
if (!(await request(() => server.api.experimental.session.wait({ sessionID })))) return

// Read before staging: the staged event can land before the request resolves.
const prior = input.session.data.revertMessageID()
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/settings/general/controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function createServerShellController(server: Accessor<ServerConnection.An
const previous = state.latest
actions.mutate({ ...previous, shell: value || undefined })
void serverCtx()
.sdk.api.config.update({ shell: value || null })
.sdk.api.experimental.config.update({ shell: value || null })
.catch((error) => {
actions.mutate(previous)
showToast({
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/voice/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export class VoiceTools {

const seen = new Set<string>()
const tabs = new Set(this.screen.tabs())

// Best match first; among equal matches, top-level sessions with an open tab, then the most recent.
const rank = (session: SessionInfo) =>
(search.score(session) ?? 0) * 4 + (session.parentID ? 0 : 2) + (tabs.has(session.id) ? 1 : 0)
Expand Down Expand Up @@ -300,7 +301,7 @@ export class VoiceTools {

if (call.name === "read_session" || call.name === "wait_for_session") {
if (call.name === "wait_for_session") {
await api.session.wait({ sessionID }, { signal: AbortSignal.any([signal, AbortSignal.timeout(90_000)]) })
await api.experimental.session.wait({ sessionID }, { signal: AbortSignal.any([signal, AbortSignal.timeout(90_000)]) })
}

const [session, messages] = await Promise.all([
Expand Down
13 changes: 10 additions & 3 deletions packages/app/src/voice/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ export class VoiceFailure extends Error {
}
}

/** A client event sent over the Live data channel; the transport adds `event_id`. */
export type VoiceClientEvent =
| { type: "session.input_audio.mute" | "session.input_audio.unmute" | "response.create" }
| { type: "session.update"; session: { delegation: { type: "responses"; responses: { instructions: string } } } }
| { type: "response.item.create"; item: { type: "function_call_output"; call_id: string; output: string } }
| { type: "session.commentary.append"; delegation_id: null; content: string }

export class VoiceTransport {
readonly abort = new AbortController()
private peer = new RTCPeerConnection({ iceServers: [], bundlePolicy: "max-bundle", rtcpMuxPolicy: "require" })
Expand Down Expand Up @@ -78,7 +85,7 @@ export class VoiceTransport {
async join(viewing: { id: string; title?: string }, tail: string) {
const signal = AbortSignal.any([this.abort.signal, AbortSignal.timeout(35_000)])
await this.context.resume()
const offered = await this.inputOptions.api.voice.models({ signal })
const offered = await this.inputOptions.api.experimental.voice.models({ signal })

if (
!offered.models.some(
Expand Down Expand Up @@ -106,7 +113,7 @@ export class VoiceTransport {
})
signal.throwIfAborted()

const response = await this.inputOptions.api.voice.session(
const response = await this.inputOptions.api.experimental.voice.session(
{
model: "gpt-live-1",
session: {
Expand Down Expand Up @@ -153,7 +160,7 @@ export class VoiceTransport {
this.timer = setInterval(() => this.inputOptions.levels(this.level(this.input), this.level(this.output)), 100)
}

send(event: object) {
send(event: VoiceClientEvent) {
if (this.ended || this.channel.readyState !== "open") throw new VoiceFailure("dropped")
this.channel.send(JSON.stringify({ event_id: crypto.randomUUID(), ...event }))
}
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/workspaces/files/model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,14 +296,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
// and the open tabs ask again on reconnect (a restarted server forgets its watches). Older servers lack the route.
const watch = (input: string) => {
const file = path.normalize(input)

if (!file || path.absolute(file)) return
void serverSDK.api.file.watch({ path: file, location: { directory: scope() } }).catch(() => {})
void serverSDK.api.experimental.file.watch({ path: file, location: { directory: scope() } }).catch(() => {})
}

const unwatch = (input: string) => {
const file = path.normalize(input)

if (!file || path.absolute(file)) return
void serverSDK.api.file.unwatch({ path: file, location: { directory: scope() } }).catch(() => {})
void serverSDK.api.experimental.file.unwatch({ path: file, location: { directory: scope() } }).catch(() => {})
}

createEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/acp/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export const make = Effect.fnUntraced(function* (input: {
const config = mcpConfig(server)
if (isDeepStrictEqual(registered.get(server.name), config)) return Effect.void
registered.set(server.name, config)
return input.client.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }).pipe(
return input.client.experimental.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }).pipe(
Effect.catch(ACPClient.classify),
Effect.onError(() => Effect.sync(() => registered.delete(server.name))),
Effect.uninterruptible,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/handlers/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const findIntegration = Effect.fn("cli.auth.login.integration")(function* (clien
if (protocol === "http:" || protocol === "https:") {
const progress = spinner()
progress.start("Discovering authentication provider...")
yield* request((signal) => client.integration.wellknown.add({ url: target, location }, { signal })).pipe(
yield* request((signal) => client.experimental.integration.wellknown.add({ url: target, location }, { signal })).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Authentication provider discovered"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Discovery failed", 1))),
)
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/handlers/session/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export default Runtime.handler(
})
if (!sessionID) return
const data = yield* Effect.tryPromise({
try: () => client.session.export({ sessionID, sanitize: input.sanitize }),
try: () => client.experimental.session.export({ sessionID, sanitize: input.sanitize }),
catch: (cause) => cause,
})
process.stdout.write(JSON.stringify(data, null, 2) + EOL)
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/handlers/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const handler = Effect.fn("cli.stats")(function* (input: Runtime.Input<typeof Co
: project
const details = input.models || input.tools || input.cost || input.full
const stats = yield* request(server.endpoint.url, (signal) =>
client.session.stats(
client.experimental.session.stats(
{
from: range.from,
to: range.to,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/run/noninteractive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ export async function runNonInteractivePrompt(input: Input) {
return
}

const waiting = input.client.session.wait({ sessionID: input.sessionID })
const waiting = input.client.experimental.session.wait({ sessionID: input.sessionID })
await Promise.race([waiting, completed.then(() => waiting)])
finalizing = true
const projected = await reconcile()
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/test/fixture/mini-wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ const timeout = (error: unknown) =>
error.cause.name === "TimeoutError"

await Promise.all([
connection.sdk.session.wait({ sessionID: "ses_initial" }),
replacement.session.wait({ sessionID: "ses_reconnected" }),
assert.rejects(OpenCode.make({ baseUrl: endpoint.url }).session.wait({ sessionID: "ses_control" }), timeout),
connection.sdk.experimental.session.wait({ sessionID: "ses_initial" }),
replacement.experimental.session.wait({ sessionID: "ses_reconnected" }),
assert.rejects(OpenCode.make({ baseUrl: endpoint.url }).experimental.session.wait({ sessionID: "ses_control" }), timeout),
assert.rejects(
connection.sdk.session.wait({ sessionID: "ses_cancelled" }, { signal: AbortSignal.timeout(100) }),
connection.sdk.experimental.session.wait({ sessionID: "ses_cancelled" }, { signal: AbortSignal.timeout(100) }),
timeout,
),
])
4 changes: 2 additions & 2 deletions packages/cli/test/run/noninteractive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ async function run(input: {
)
spyOn(sdk.session.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
let promptID = "msg_prompt"
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.experimental.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
ok({
data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }],
Expand Down Expand Up @@ -479,7 +479,7 @@ describe("runNonInteractivePrompt", () => {
])
expect(output.stderr).toBe("")
const sdk = await run({ compatibility: "v1", turn: (messageID) => [prompted(messageID), settled()] })
expect(sdk.session.wait).not.toHaveBeenCalled()
expect(sdk.experimental.session.wait).not.toHaveBeenCalled()
expect(sdk.message.list).not.toHaveBeenCalled()
})

Expand Down
22 changes: 19 additions & 3 deletions packages/client/script/generate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { NodeFileSystem } from "@effect/platform-node"
import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from "@opencode/httpapi-codegen"
import { ClientApi, effectOmitEndpoints, groupNames, promiseOmitEndpoints } from "@opencode/protocol/client"
import {
ClientApi,
clientNamespace,
effectOmitEndpoints,
groupNames,
promiseOmitEndpoints,
} from "@opencode/protocol/client"
import { Agent } from "@opencode/schema/agent"
import { Command } from "@opencode/schema/command"
import { Config } from "@opencode/schema/config"
Expand Down Expand Up @@ -43,8 +49,18 @@ import { Workspace } from "@opencode/schema/workspace"
import { Effect, Schema } from "effect"
import { fileURLToPath } from "url"

const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints })
const promiseContract = compile(ClientApi, {
groupNames,
omitEndpoints: promiseOmitEndpoints,
namespace: clientNamespace,
aliases: true,
})
const effectContract = compile(ClientApi, {
groupNames,
omitEndpoints: effectOmitEndpoints,
namespace: clientNamespace,
aliases: true,
})
const effectTypeReferences = [
...namespaceTypes("Agent", "@opencode/schema/agent", Agent),
...namespaceTypes("Command", "@opencode/schema/command", Command),
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/contract.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { ClientApi, effectOmitEndpoints, groupNames, promiseOmitEndpoints } from "@opencode/protocol/client"
export { ClientApi, clientNamespace, effectOmitEndpoints, groupNames, promiseOmitEndpoints } from "@opencode/protocol/client"
Loading
Loading