feat(cli): add preview server daemon

This commit is contained in:
Dax Raad 2026-06-04 00:38:42 -04:00
commit 23a87f8dc5
64 changed files with 2496 additions and 1666 deletions

View file

@ -1,5 +1,5 @@
{
"lockfileVersion": 1,
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
@ -87,14 +87,18 @@
"name": "@opencode-ai/cli",
"version": "1.15.13",
"bin": {
"opencode": "./src/index.ts",
"lildax": "./src/index.ts",
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@parcel/watcher": "2.5.1",
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@ -512,6 +516,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.8.1",
"@opentelemetry/api": "1.9.0",
@ -649,6 +654,20 @@
"typescript": "catalog:",
},
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.15.13",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.13",
@ -839,23 +858,23 @@
},
},
"trustedDependencies": [
"esbuild",
"tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
"esbuild",
"electron",
"protobufjs",
],
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
},
"overrides": {
"@opentui/core": "catalog:",
@ -1729,6 +1748,8 @@
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],

View file

@ -6,7 +6,7 @@
"type": "module",
"license": "MIT",
"bin": {
"opencode": "./src/index.ts"
"lildax": "./src/index.ts"
},
"scripts": {
"build": "bun run script/build.ts",
@ -16,9 +16,13 @@
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@parcel/watcher": "2.5.1",
"effect": "catalog:"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"

View file

@ -0,0 +1,92 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
import { modelsData } from "./generate"
import pkg from "../package.json"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "lildax"
process.chdir(dir)
await rm("dist", { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const sourcemapsFlag = process.argv.includes("--sourcemaps")
const allTargets: {
os: string
arch: "arm64" | "x64"
abi?: "musl"
avx2?: false
}[] = [
{ os: "linux", arch: "arm64" },
{ os: "linux", arch: "x64" },
{ os: "linux", arch: "x64", avx2: false },
{ os: "linux", arch: "arm64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl" },
{ os: "linux", arch: "x64", abi: "musl", avx2: false },
{ os: "darwin", arch: "arm64" },
{ os: "darwin", arch: "x64" },
{ os: "darwin", arch: "x64", avx2: false },
{ os: "win32", arch: "arm64" },
{ os: "win32", arch: "x64" },
{ os: "win32", arch: "x64", avx2: false },
]
const targets = singleFlag
? allTargets.filter((item) => {
if (item.os !== process.platform || item.arch !== process.arch) return false
if (item.avx2 === false) return baselineFlag
return item.abi === undefined
})
: allTargets
if (!skipInstall) await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}`
for (const item of targets) {
const name = [
binary,
item.os === "win32" ? "windows" : item.os,
item.arch,
item.avx2 === false ? "baseline" : undefined,
item.abi,
]
.filter(Boolean)
.join("-")
console.log(`building ${name}`)
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
external: ["node-gyp"],
format: "esm",
minify: true,
sourcemap: sourcemapsFlag ? "linked" : "none",
splitting: true,
compile: {
autoloadBunfig: false,
autoloadDotenv: false,
autoloadTsconfig: true,
autoloadPackageJson: true,
target: name.replace(binary, "bun") as Bun.Build.CompileTarget,
outfile: `./dist/${name}/bin/${binary}`,
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
windows: {},
},
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_CLI_NAME: `'${binary}'`,
OPENCODE_MODELS_DEV: modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
},
})
if (result.success) continue
for (const log of result.logs) console.error(log)
process.exit(1)
}

View file

@ -0,0 +1,7 @@
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
export const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
console.log("Loaded models.dev snapshot")

View file

@ -1,12 +0,0 @@
import { CliApi } from "./cli-api"
export const Api = CliApi.make("opencode", {
description: "OpenCode command line interface",
commands: [
CliApi.make("debug", {
description: "Debugging and troubleshooting tools",
commands: [CliApi.make("agents", { description: "List all agents" })],
}),
CliApi.make("migrate", { description: "Migrate v1 data to v2" }),
],
})

View file

@ -0,0 +1,36 @@
import { Argument, Flag } from "effect/unstable/cli"
import { Spec } from "../framework/spec"
declare const OPENCODE_CLI_NAME: string | undefined
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
commands: [
Spec.make("debug", {
description: "Debugging and troubleshooting tools",
commands: [Spec.make("agents", { description: "List all agents" })],
}),
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("service", {
description: "Manage the background server",
commands: [
Spec.make("start", { description: "Start the background server" }),
Spec.make("restart", { description: "Restart the background server" }),
Spec.make("status", { description: "Show background server status" }),
Spec.make("stop", { description: "Stop the background server" }),
Spec.make("password", {
description: "Get or set the server password",
params: { value: Argument.string("value").pipe(Argument.optional) },
}),
],
}),
Spec.make("serve", {
description: "Start the v2 API server",
params: {
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
port: Flag.integer("port").pipe(Flag.optional),
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
},
}),
],
})

View file

@ -0,0 +1,21 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(function* () {
const daemon = yield* Daemon.Service
const client = yield* daemon.client()
const response = yield* Effect.promise(() => client.v2.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write(
JSON.stringify(
response.data?.data.toSorted((a, b) => a.id.localeCompare(b.id)),
null,
2,
) + EOL,
)
}),
)

View file

@ -0,0 +1,5 @@
import * as Effect from "effect/Effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))

View file

@ -0,0 +1,39 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Context, Layer, Option } from "effect"
import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon"
export default Runtime.handler(
Commands.commands.serve,
Effect.fn("cli.serve")(function* (input) {
return yield* Effect.scoped(
Effect.gen(function* () {
const daemon = yield* Daemon.Service
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
if (input.register) yield* daemon.register(address)
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
return yield* Effect.never
}),
)
}),
)
function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password)
// Preserve the familiar default when available, but let the OS choose a free
// port when another local server already owns 4096.
return bind(hostname, 4096, password).pipe(Effect.catch(() => bind(hostname, 0, password)))
}
function bind(hostname: string, port: number, password: string) {
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
),
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
}

View file

@ -0,0 +1,16 @@
import { EOL } from "os"
import { Option } from "effect"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.password,
Effect.fn("cli.service.password")(function* (input) {
const daemon = yield* Daemon.Service
const value = Option.getOrUndefined(input.value)
if (value !== undefined) yield* daemon.stop()
process.stdout.write((yield* daemon.password(value)) + EOL)
}),
)

View file

@ -0,0 +1,14 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const daemon = yield* Daemon.Service
yield* daemon.stop()
process.stdout.write((yield* daemon.start()) + EOL)
}),
)

View file

@ -0,0 +1,12 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.start,
Effect.fn("cli.service.start")(function* () {
process.stdout.write((yield* (yield* Daemon.Service).start()) + EOL)
}),
)

View file

@ -0,0 +1,13 @@
import { EOL } from "os"
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () {
const url = yield* (yield* Daemon.Service).status()
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
}),
)

View file

@ -0,0 +1,11 @@
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.stop,
Effect.fn("cli.service.stop")(function* () {
yield* (yield* Daemon.Service).stop()
}),
)

View file

@ -1,19 +1,20 @@
import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command"
import { CliApi } from "./cli-api"
import { Spec } from "./spec"
import { Daemon } from "../services/daemon"
export type Input<Value> =
Value extends CliApi.Node<infer _Name, infer Spec, infer _Commands>
? Input<Spec>
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
? Input<Command>
: Value extends Command.Command<infer _Name, infer Input, infer _Context, infer _Error, infer _Requirements>
? Input
: never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown>
type Loader<Node extends CliApi.Any> = () => Promise<{ default: (input: Input<Node>) => Effect.Effect<void, any> }>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, never>
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
type Loader<Node extends Spec.Any> = () => Promise<{ default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service> }>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
export type Handlers<Node extends CliApi.Any> = keyof Node["commands"] extends never
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node>
: { readonly $?: Loader<Node> } & { readonly [Key in keyof Node["commands"]]: Handlers<Node["commands"][Key]> }
@ -29,17 +30,17 @@ type RuntimeHandlers =
readonly [key: string]: RuntimeHandlers | (() => Promise<{ default: RuntimeHandler }>) | undefined
}
export function handler<const Node extends CliApi.Any, Error>(
export function handler<const Node extends Spec.Any, Error, Requirements>(
_node: Node,
run: (input: Input<Node>) => Effect.Effect<void, Error>,
run: (input: Input<Node>) => Effect.Effect<void, Error, Requirements>,
) {
return run
}
export function handlers<const Root extends CliApi.Any>(root: Root, handlers: Handlers<Root>) {
export function handlers<const Root extends Spec.Any>(root: Root, handlers: Handlers<Root>) {
const result: LazyHandler[] = []
function add(node: CliApi.Any, value: RuntimeHandlers) {
function add(node: Spec.Any, value: RuntimeHandlers) {
if (typeof value === "function") {
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
return
@ -52,11 +53,11 @@ export function handlers<const Root extends CliApi.Any>(root: Root, handlers: Ha
return result
}
export function run(api: CliApi.Any, handlers: ReadonlyArray<LazyHandler>, options: { readonly version: string }) {
return Command.run(provide(api, handlers), options) as Effect.Effect<void, unknown, Command.Environment>
export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, options: { readonly version: string }) {
return Command.run(provide(commands, handlers), options) as Effect.Effect<void, unknown, Command.Environment>
}
function provide(node: CliApi.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
const spec: Command.Command.Any = Object.keys(node.commands).length
? (node.spec as Command.Command<string, unknown>).pipe(
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
@ -65,8 +66,12 @@ function provide(node: CliApi.Any, handlers: ReadonlyArray<LazyHandler>): Provid
const handler = handlers.find((handler) => handler.spec === node.spec)
if (!handler) return spec as ProvidedCommand
return spec.pipe(
Command.withHandler((input) => Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))),
Command.withHandler((input) =>
Effect.gen(function* () {
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
}),
),
) as ProvidedCommand
}
export * as CliBuilder from "./cli-builder"
export * as Runtime from "./runtime"

View file

@ -39,4 +39,4 @@ type ChildrenOf<Commands extends ReadonlyArray<Any>> = {
readonly [Node in Commands[number] as Node["name"]]: Node
}
export * as CliApi from "./cli-api"
export * as Spec from "./spec"

View file

@ -1,30 +0,0 @@
import { EOL } from "os"
import { AgentV2 } from "@opencode-ai/core/agent"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { AbsolutePath } from "@opencode-ai/core/schema"
import * as Effect from "effect/Effect"
import { Api } from "../../api"
import { CliBuilder } from "../../cli-builder"
export default CliBuilder.handler(
Api.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(
function* () {
const svc = {
plugin: yield* PluginBoot.Service,
agent: yield* AgentV2.Service,
}
yield* svc.plugin.wait()
process.stdout.write(
JSON.stringify(
(yield* svc.agent.all()).sort((a, b) => a.id.localeCompare(b.id)),
null,
2,
) + EOL,
)
},
Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(process.cwd()) })),
Effect.provide(LocationServiceMap.layer),
),
)

View file

@ -1,5 +0,0 @@
import * as Effect from "effect/Effect"
import { Api } from "../api"
import { CliBuilder } from "../cli-builder"
export default CliBuilder.handler(Api.commands.migrate, (_input) => Effect.log("No migrations to run."))

View file

@ -3,17 +3,27 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices"
import * as Effect from "effect/Effect"
import { Api } from "./api"
import { CliBuilder } from "./cli-builder"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon"
const Handlers = CliBuilder.handlers(Api, {
const Handlers = Runtime.handlers(Commands, {
debug: {
agents: () => import("./handlers/debug/agents"),
agents: () => import("./commands/handlers/debug/agents"),
},
migrate: () => import("./handlers/migrate"),
migrate: () => import("./commands/handlers/migrate"),
service: {
start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"),
status: () => import("./commands/handlers/service/status"),
stop: () => import("./commands/handlers/service/stop"),
password: () => import("./commands/handlers/service/password"),
},
serve: () => import("./commands/handlers/serve"),
})
CliBuilder.run(Api, Handlers, { version: "local" }).pipe(
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
Effect.provide(Daemon.defaultLayer),
Effect.provide(NodeServices.layer),
Effect.scoped,
NodeRuntime.runMain,

View file

@ -0,0 +1,145 @@
import { Global } from "@opencode-ai/core/global"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
import { HttpServer } from "effect/unstable/http"
import { randomBytes } from "crypto"
import path from "path"
export interface Interface {
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
readonly start: () => Effect.Effect<string, Error>
readonly status: () => Effect.Effect<string | undefined>
readonly stop: () => Effect.Effect<void, unknown>
readonly password: (value?: string) => Effect.Effect<string, unknown>
readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Daemon") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const directory = Global.Path.state
const file = path.join(directory, "server.json")
const passwordFile = path.join(directory, "password")
const decodeRegistration = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Struct({ url: Schema.String, pid: Schema.Number })),
)
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && existing) return existing
// Keep one private credential across server restarts so discovered clients
// can reconnect without exposing a password flag or environment variable.
const generated = value ?? randomBytes(32).toString("base64url")
const temp = passwordFile + ".tmp"
yield* fs.makeDirectory(directory, { recursive: true })
yield* fs.writeFileString(temp, generated, { mode: 0o600 })
yield* fs.rename(temp, passwordFile)
return generated
})
const registration = Effect.fnUntraced(function* () {
return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration))
})
const createClient = Effect.fnUntraced(function* (url: string) {
return createOpencodeClient({ baseUrl: url, headers: ServerAuth.headers({ password: yield* password() }) })
})
const healthy = Effect.fnUntraced(function* () {
const info = yield* registration()
const client = yield* createClient(info.url)
const response = yield* Effect.tryPromise(() => client.v2.health.get())
if (response.data?.healthy === true) return info
return yield* Effect.fail(new Error("Registered server is not healthy"))
})
const start = Effect.fn("cli.daemon.start")(function* () {
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
if (found) return found.url
yield* Effect.sync(() => {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
}).unref()
})
return yield* healthy().pipe(
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
Effect.map((info) => info.url),
Effect.mapError(() => new Error("Failed to start server")),
)
})
const client = Effect.fn("cli.daemon.client")(function* () {
return yield* createClient(yield* start())
})
const status = Effect.fn("cli.daemon.status")(function* () {
const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing)
if (found) return found.url
yield* fs.remove(file).pipe(Effect.ignore)
return undefined
})
const signal = (pid: number, signal: NodeJS.Signals) =>
Effect.try({ try: () => process.kill(pid, signal), catch: (cause) => cause }).pipe(Effect.ignore)
const awaitStopped = Effect.fnUntraced(function* (pid: number) {
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
Effect.orElseSucceed(() => false),
)
if (!running) return true
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
})
const stop = Effect.fn("cli.daemon.stop")(function* () {
const existing = yield* healthy().pipe(Effect.option)
// A stale registration may point at a PID that has since been reused by
// another process. Only signal the PID after authenticating the server.
if (Option.isNone(existing)) return yield* fs.remove(file).pipe(Effect.ignore)
const pid = existing.value.pid
yield* signal(pid, "SIGTERM")
const stopped = yield* awaitStopped(pid).pipe(
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
Effect.option,
)
if (Option.isNone(stopped)) {
yield* signal(pid, "SIGKILL")
yield* awaitStopped(pid).pipe(
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))),
)
}
yield* fs.remove(file).pipe(Effect.ignore)
})
const register = Effect.fn("cli.daemon.register")(function* (address: HttpServer.Address) {
const temp = file + ".tmp"
yield* fs.makeDirectory(directory, { recursive: true })
yield* fs.writeFileString(
temp,
JSON.stringify({ url: HttpServer.formatAddress(address), pid: process.pid }),
{ mode: 0o600 },
)
yield* fs.rename(temp, file)
// The metadata file represents this live listener, not persistent config.
// Scope shutdown removes it when the server exits normally.
yield* Effect.addFinalizer(() => fs.remove(file).pipe(Effect.ignore))
})
return Service.of({ client, start, status, stop, password, register })
}),
)
export const defaultLayer = layer
export * as Daemon from "./daemon"

View file

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

View file

@ -87,6 +87,7 @@
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.8.1",

View file

@ -20,7 +20,7 @@ import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace"
import { V2Api } from "./groups/v2"
import { V2Api } from "@opencode-ai/server/api"
// GlobalEventSchema snapshots the registry after event-producing groups register their variants.
import { GlobalApi } from "./groups/global"
import { Authorization } from "./middleware/authorization"
@ -60,7 +60,6 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(ProviderApi)
.addHttpApi(SessionApi)
.addHttpApi(SyncApi)
.addHttpApi(V2Api)
.addHttpApi(TuiApi)
.addHttpApi(WorkspaceApi)
.middleware(SchemaErrorMiddleware)
@ -69,6 +68,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(RootHttpApi)
.addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi)
.addHttpApi(V2Api)
.addHttpApi(PtyConnectApi)
.annotate(HttpApi.AdditionalSchemas, [EventSchema, Question.Replied, Question.Rejected])

View file

@ -1,30 +0,0 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { MessageGroup } from "./v2/message"
import { ModelGroup } from "./v2/model"
import { ProviderGroup } from "./v2/provider"
import { SessionGroup } from "./v2/session"
import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission"
import { FileSystemGroup } from "./v2/fs"
import { CommandGroup } from "./v2/command"
import { SkillGroup } from "./v2/skill"
import { EventGroup } from "./v2/event"
export const V2Api = HttpApi.make("v2")
.add(SessionGroup)
.add(MessageGroup)
.add(ModelGroup)
.add(ProviderGroup)
.add(PermissionGroup)
.add(SessionPermissionGroup)
.add(PermissionSavedGroup)
.add(FileSystemGroup)
.add(CommandGroup)
.add(SkillGroup)
.add(EventGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)

View file

@ -1,60 +0,0 @@
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Effect, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { InstanceHttpApi } from "../../api"
function eventData(data: unknown): Sse.Event {
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
}
}
export const eventHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.event", (handlers) =>
handlers.handleRaw("events", () =>
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const location = yield* Location.Service
const connected = {
id: EventV2.ID.create(),
type: "server.connected",
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: {},
}
return HttpServerResponse.stream(
Stream.make(connected).pipe(
Stream.concat(
events.all().pipe(
Stream.filter(
(event) =>
event.location?.directory === location.directory &&
event.location.workspaceID === location.workspaceID,
),
),
),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
)
}),
),
)

View file

@ -4,7 +4,7 @@ import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "e
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
import { isPublicUIPath } from "@/server/shared/public-ui"
import { UnauthorizedError } from "../errors"
export { V2Authorization, v2AuthorizationLayer } from "@opencode-ai/server/middleware/authorization"
const AUTH_TOKEN_QUERY = "auth_token"
const UNAUTHORIZED = 401
@ -20,13 +20,6 @@ export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
},
) {}
export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>()(
"@opencode/ExperimentalHttpApiV2Authorization",
{
error: UnauthorizedError,
},
) {}
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
"@opencode/ExperimentalHttpApiPtyConnectAuthorization",
{
@ -152,27 +145,3 @@ export const ptyConnectAuthorizationLayer = Layer.effect(
)
}),
)
export const v2AuthorizationLayer = Layer.effect(
V2Authorization,
Effect.gen(function* () {
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return V2Authorization.of((effect) => effect)
return V2Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* credentialFromRequest(request).pipe(
Effect.flatMap((credential) =>
Effect.gen(function* () {
if (ServerAuth.authorized(credential, config)) return yield* effect
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
)
return yield* new UnauthorizedError({ message: "Authentication required" })
}),
),
)
}),
)
}),
)

View file

@ -44,6 +44,7 @@ import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share/share-next"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Database } from "@opencode-ai/core/database/database"
import { Skill } from "@/skill"
import { Snapshot } from "@/snapshot"
@ -56,6 +57,7 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors
import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { V2Api } from "@opencode-ai/server/api"
import { PublicApi } from "./public"
import {
authorizationLayer,
@ -82,7 +84,8 @@ import { questionHandlers } from "./handlers/question"
import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { v2Handlers } from "./handlers/v2"
import { v2Handlers } from "@opencode-ai/server/handlers"
import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer } from "./middleware/instance-context"
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
@ -144,14 +147,17 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
providerHandlers,
sessionHandlers,
syncHandlers,
v2Handlers,
tuiHandlers,
workspaceHandlers,
]),
)
const instanceRoutes = instanceApiRoutes.pipe(
Layer.provide([httpApiAuthLayer, v2HttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
)
const v2Routes = HttpApiBuilder.layer(V2Api).pipe(
Layer.provide(v2Handlers),
Layer.provide([v2HttpApiAuthLayer, v2SchemaErrorLayer]),
)
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
@ -186,7 +192,7 @@ type RouteRequirements =
export function createRoutes(
corsOptions?: CorsOptions,
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, v2Routes, docRoute, uiRoute).pipe(
Layer.provide([
errorLayer,
compressionLayer,
@ -226,6 +232,7 @@ export function createRoutes(
ShareNext.defaultLayer,
Snapshot.defaultLayer,
EventV2Bridge.defaultLayer,
EventV2.defaultLayer,
Skill.defaultLayer,
Todo.defaultLayer,
ToolRegistry.defaultLayer,

View file

@ -624,6 +624,11 @@ const scenarios: Scenario[] = [
check(auth.test === undefined, "auth remove should delete provider from isolated auth file")
}),
),
http.protected.get("/api/health", "v2.health.get").json(200, (body) => {
object(body)
check(body.healthy === true, "v2 server should report healthy")
}),
http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
http.protected.get("/api/command", "v2.command.list").json(200, locationData(array)),

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,36 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { SchemaErrorMiddleware } from "./middleware/schema-error"
import { MessageGroup } from "./groups/v2/message"
import { ModelGroup } from "./groups/v2/model"
import { ProviderGroup } from "./groups/v2/provider"
import { SessionGroup } from "./groups/v2/session"
import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./groups/v2/permission"
import { FileSystemGroup } from "./groups/v2/fs"
import { CommandGroup } from "./groups/v2/command"
import { SkillGroup } from "./groups/v2/skill"
import { EventGroup } from "./groups/v2/event"
import { AgentGroup } from "./groups/v2/agent"
import { HealthGroup } from "./groups/v2/health"
export const V2Api = HttpApi.make("v2")
.add(HealthGroup)
.add(AgentGroup)
.add(SessionGroup)
.add(MessageGroup)
.add(ModelGroup)
.add(ProviderGroup)
.add(PermissionGroup)
.add(SessionPermissionGroup)
.add(PermissionSavedGroup)
.add(FileSystemGroup)
.add(CommandGroup)
.add(SkillGroup)
.add(EventGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
.middleware(SchemaErrorMiddleware)

View file

@ -0,0 +1,63 @@
export * as ServerAuth from "./auth"
import { Config as EffectConfig, Context, Effect, Layer, Option, Redacted } from "effect"
export type Credentials = {
password?: string
username?: string
}
export type DecodedCredentials = {
readonly username: string
readonly password: Redacted.Redacted
}
export type Info = {
readonly password: Option.Option<string>
readonly username: string
}
export class Config extends Context.Service<Config, Info>()("@opencode/ServerAuthConfig") {
static layer(input: Info) {
return Layer.succeed(this, this.of(input))
}
static get defaultLayer() {
return Layer.effect(
this,
Effect.gen(function* () {
return Config.of(
yield* EffectConfig.all({
password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option),
username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")),
}),
)
}),
)
}
}
export function required(config: Info) {
return Option.isSome(config.password) && config.password.value !== ""
}
export function authorized(credentials: DecodedCredentials, config: Info) {
return (
Option.isSome(config.password) &&
credentials.username === config.username &&
Redacted.value(credentials.password) === config.password.value
)
}
export function header(credentials?: Credentials) {
const password = credentials?.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
return `Basic ${Buffer.from(`${credentials?.username ?? process.env.OPENCODE_SERVER_USERNAME ?? "opencode"}:${password}`).toString("base64")}`
}
export function headers(credentials?: Credentials) {
const authorization = header(credentials)
if (!authorization) return undefined
return { Authorization: authorization }
}

View file

@ -0,0 +1,68 @@
import { Schema } from "effect"
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()(
"InvalidRequestError",
{
message: Schema.String,
kind: Schema.optional(Schema.String),
field: Schema.optional(Schema.String),
},
{ httpApiStatus: 400 },
) {}
export class UnauthorizedError extends Schema.TaggedErrorClass<UnauthorizedError>()(
"UnauthorizedError",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
export class ServiceUnavailableError extends Schema.TaggedErrorClass<ServiceUnavailableError>()(
"ServiceUnavailableError",
{
message: Schema.String,
service: Schema.optional(Schema.String),
},
{ httpApiStatus: 503 },
) {}
export class UnknownError extends Schema.TaggedErrorClass<UnknownError>()(
"UnknownError",
{
message: Schema.String,
ref: Schema.optional(Schema.String),
},
{ httpApiStatus: 500 },
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"ProviderNotFoundError",
{
providerID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
"SessionNotFoundError",
{
sessionID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class InvalidCursorError extends Schema.TaggedErrorClass<InvalidCursorError>()(
"InvalidCursorError",
{ message: Schema.String },
{ httpApiStatus: 400 },
) {}
export class PermissionNotFoundError extends Schema.TaggedErrorClass<PermissionNotFoundError>()(
"PermissionNotFoundError",
{
requestID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}

View file

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

View file

@ -0,0 +1,17 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { V2Authorization } from "../../middleware/authorization"
export const HealthGroup = HttpApiGroup.make("v2.health")
.add(
HttpApiEndpoint.get("health", "/api/health", {
success: Schema.Struct({ healthy: Schema.Literal(true) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.health.get",
summary: "Check v2 server health",
description: "Check whether the v2 API server is ready to accept requests.",
}),
),
)
.middleware(V2Authorization)

View file

@ -1,4 +1,5 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { AgentV2 } from "@opencode-ai/core/agent"
import { CommandV2 } from "@opencode-ai/core/command"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
@ -55,7 +56,9 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service<
{
provides:
| Catalog.Service
| AgentV2.Service
| CommandV2.Service
| Location.Service
| PluginBoot.Service
| PermissionV2.Service
| ProjectReference.Service

View file

@ -1,14 +1,12 @@
import { SessionID } from "@/session/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
import { data } from "./response"
export const MessagesQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
limit: Schema.optional(
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
).annotate({
@ -28,7 +26,7 @@ export const MessagesQuery = Schema.Struct({
export const MessageGroup = HttpApiGroup.make("v2.message")
.add(
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
params: { sessionID: SessionID },
params: { sessionID: SessionV2.ID },
query: MessagesQuery,
success: data(Schema.Struct({
items: Schema.Array(SessionMessage.Message),

View file

@ -1,4 +1,3 @@
import { SessionID } from "@/session/schema"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionV2 } from "@opencode-ai/core/session"
@ -15,7 +14,6 @@ import {
UnknownError,
} from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQuery } from "../../middleware/workspace-routing"
import { data } from "./response"
const SessionsQueryFields = {
@ -108,8 +106,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
)
.add(
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
params: { sessionID: SessionV2.ID },
payload: Schema.Struct({
prompt: Prompt,
delivery: SessionV2.Delivery.pipe(Schema.optional),
@ -126,8 +123,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
)
.add(
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
params: { sessionID: SessionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
}).annotateMerge(
@ -140,8 +136,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
)
.add(
HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
params: { sessionID: SessionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
}).annotateMerge(
@ -154,8 +149,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
)
.add(
HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
params: { sessionID: SessionV2.ID },
success: data(Schema.Array(SessionMessage.Message)),
error: [SessionNotFoundError, UnknownError],
}).annotateMerge(

View file

@ -2,18 +2,22 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Layer } from "effect"
import { layer as v2LocationLayer } from "../groups/v2/location"
import { messageHandlers } from "./v2/message"
import { modelHandlers } from "./v2/model"
import { providerHandlers } from "./v2/provider"
import { sessionHandlers } from "./v2/session"
import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./v2/permission"
import { fileSystemHandlers } from "./v2/fs"
import { commandHandlers } from "./v2/command"
import { skillHandlers } from "./v2/skill"
import { eventHandlers } from "./v2/event"
import { layer as v2LocationLayer } from "./groups/v2/location"
import { messageHandlers } from "./handlers/v2/message"
import { modelHandlers } from "./handlers/v2/model"
import { providerHandlers } from "./handlers/v2/provider"
import { sessionHandlers } from "./handlers/v2/session"
import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./handlers/v2/permission"
import { fileSystemHandlers } from "./handlers/v2/fs"
import { commandHandlers } from "./handlers/v2/command"
import { skillHandlers } from "./handlers/v2/skill"
import { eventHandlers } from "./handlers/v2/event"
import { agentHandlers } from "./handlers/v2/agent"
import { healthHandlers } from "./handlers/v2/health"
export const v2Handlers = Layer.mergeAll(
healthHandlers,
agentHandlers,
sessionHandlers,
messageHandlers,
modelHandlers,

View file

@ -0,0 +1,15 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
import { response } from "../../groups/v2/location"
export const agentHandlers = HttpApiBuilder.group(V2Api, "v2.agent", (handlers) =>
handlers.handle("agents", () =>
Effect.gen(function* () {
yield* PluginBoot.Service.use((plugin) => plugin.wait())
return yield* response(AgentV2.Service.use((agent) => agent.all()))
}),
),
)

View file

@ -1,9 +1,9 @@
import { CommandV2 } from "@opencode-ai/core/command"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { V2Api } from "../../api"
import { response } from "../../groups/v2/location"
export const commandHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.command", (handlers) =>
export const commandHandlers = HttpApiBuilder.group(V2Api, "v2.command", (handlers) =>
handlers.handle("commands", () => response(CommandV2.Service.use((command) => command.list()))),
)

View file

@ -0,0 +1,61 @@
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Effect, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { V2Api } from "../../api"
function eventData(data: unknown): Sse.Event {
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
}
}
export const eventHandlers = HttpApiBuilder.group(V2Api, "v2.event", (handlers) =>
Effect.gen(function* () {
const events = yield* EventV2.Service
return handlers.handleRaw("events", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const connected = {
id: EventV2.ID.create(),
type: "server.connected",
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: {},
}
return HttpServerResponse.stream(
Stream.make(connected).pipe(
Stream.concat(
events.all().pipe(
Stream.filter(
(event) =>
event.location?.directory === location.directory &&
event.location.workspaceID === location.workspaceID,
),
),
),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
)
}),
)
}),
)

View file

@ -1,10 +1,10 @@
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { V2Api } from "../../api"
import { response } from "../../groups/v2/location"
export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) =>
export const fileSystemHandlers = HttpApiBuilder.group(V2Api, "v2.fs", (handlers) =>
Effect.gen(function* () {
return handlers
.handle("read", (ctx) => response(FileSystem.Service.use((fs) => fs.read(ctx.query))))

View file

@ -0,0 +1,7 @@
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { V2Api } from "../../api"
export const healthHandlers = HttpApiBuilder.group(V2Api, "v2.health", (handlers) =>
handlers.handle("health", () => Effect.succeed({ healthy: true as const })),
)

View file

@ -3,7 +3,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { Effect, Schema } from "effect"
import * as DateTime from "effect/DateTime"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { V2Api } from "../../api"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
import { make } from "../../groups/v2/response"
@ -29,7 +29,7 @@ const cursor = {
},
}
export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message", (handlers) =>
export const messageHandlers = HttpApiBuilder.group(V2Api, "v2.message", (handlers) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service

View file

@ -2,7 +2,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { V2Api } from "../../api"
import { ServiceUnavailableError } from "../../errors"
import { response } from "../../groups/v2/location"
@ -11,7 +11,7 @@ const catalogUnavailable = new ServiceUnavailableError({
service: "catalog",
})
export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) =>
export const modelHandlers = HttpApiBuilder.group(V2Api, "v2.model", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"models",

View file

@ -7,7 +7,7 @@ import { SessionTable } from "@opencode-ai/core/session/sql"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { V2Api } from "../../api"
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
import { response } from "../../groups/v2/location"
import { make } from "../../groups/v2/response"
@ -16,7 +16,7 @@ function missingRequest(id: PermissionV2.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
}
export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission", (handlers) =>
export const permissionHandlers = HttpApiBuilder.group(V2Api, "v2.permission", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"permissionRequests",
@ -27,7 +27,7 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.perm
}),
)
export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session.permission", (handlers) =>
export const sessionPermissionHandlers = HttpApiBuilder.group(V2Api, "v2.session.permission", (handlers) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap
@ -86,7 +86,7 @@ export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "
}),
)
export const savedPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission.saved", (handlers) =>
export const savedPermissionHandlers = HttpApiBuilder.group(V2Api, "v2.permission.saved", (handlers) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
return handlers

View file

@ -2,7 +2,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { V2Api } from "../../api"
import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors"
import { response } from "../../groups/v2/location"
@ -11,7 +11,7 @@ const catalogUnavailable = new ServiceUnavailableError({
service: "catalog",
})
export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provider", (handlers) =>
export const providerHandlers = HttpApiBuilder.group(V2Api, "v2.provider", (handlers) =>
Effect.gen(function* () {
return handlers
.handle(

View file

@ -1,14 +1,14 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { DateTime, Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { V2Api } from "../../api"
import { SessionsCursor } from "../../groups/v2/session"
import { InvalidCursorError, ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../../errors"
import { make } from "../../groups/v2/response"
const DefaultSessionsLimit = 50
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) =>
export const sessionHandlers = HttpApiBuilder.group(V2Api, "v2.session", (handlers) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service

View file

@ -1,8 +1,8 @@
import { SkillV2 } from "@opencode-ai/core/skill"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { V2Api } from "../../api"
import { response } from "../../groups/v2/location"
export const skillHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.skill", (handlers) =>
export const skillHandlers = HttpApiBuilder.group(V2Api, "v2.skill", (handlers) =>
handlers.handle("skills", () => response(SkillV2.Service.use((skill) => skill.list()))),
)

View file

@ -0,0 +1,60 @@
import { ServerAuth } from "../auth"
import { UnauthorizedError } from "../errors"
import { Effect, Encoding, Layer, Redacted } from "effect"
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
const AUTH_TOKEN_QUERY = "auth_token"
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>()(
"@opencode/ExperimentalHttpApiV2Authorization",
{
error: UnauthorizedError,
},
) {}
function emptyCredential() {
return { username: "", password: Redacted.make("") }
}
function decodeCredential(input: string) {
return Effect.fromResult(Encoding.decodeBase64String(input)).pipe(
Effect.match({
onFailure: emptyCredential,
onSuccess: (header) => {
const separator = header.indexOf(":")
if (separator === -1) return emptyCredential()
return { username: header.slice(0, separator), password: Redacted.make(header.slice(separator + 1)) }
},
}),
)
}
function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
const url = new URL(request.url, "http://localhost")
const token = url.searchParams.get(AUTH_TOKEN_QUERY)
if (token) return decodeCredential(token)
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
if (match) return decodeCredential(match[1])
return Effect.succeed(emptyCredential())
}
export const v2AuthorizationLayer = Layer.effect(
V2Authorization,
Effect.gen(function* () {
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return V2Authorization.of((effect) => effect)
return V2Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const credential = yield* credentialFromRequest(request)
if (ServerAuth.authorized(credential, config)) return yield* effect
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
)
return yield* new UnauthorizedError({ message: "Authentication required" })
}),
)
}),
)

View file

@ -0,0 +1,23 @@
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError } from "../errors"
const log = Log.create({ service: "server" })
const REASON_LIMIT = 1024
function truncateReason(reason: string) {
if (reason.length <= REASON_LIMIT) return reason
return reason.slice(0, REASON_LIMIT) + `... (${reason.length - REASON_LIMIT} more chars)`
}
export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
"@opencode/HttpApiSchemaError",
{ error: InvalidRequestError },
) {}
export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => {
const reason = truncateReason(error.cause.message)
log.warn("schema rejection", { kind: error.kind, reason })
return Effect.fail(new InvalidRequestError({ message: reason, kind: error.kind }))
})

View file

@ -0,0 +1,36 @@
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { SessionV2 } from "@opencode-ai/core/session"
import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Layer, Option } from "effect"
import { V2Api } from "./api"
import { ServerAuth } from "./auth"
import { v2Handlers } from "./handlers"
import { v2AuthorizationLayer } from "./middleware/authorization"
import { schemaErrorLayer } from "./middleware/schema-error"
export function createRoutes(password?: string) {
return HttpApiBuilder.layer(V2Api).pipe(
Layer.provide(v2Handlers),
Layer.provide(v2AuthorizationLayer),
Layer.provide(schemaErrorLayer),
Layer.provide(
password
? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) })
: ServerAuth.Config.defaultLayer,
),
Layer.provide(LocationServiceMap.layer),
Layer.provide(PermissionSaved.layer),
Layer.provide(SessionV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
}
export const routes = createRoutes()
export const webHandler = () => HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)), { disableLogger: true })

View file

@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false
}
}