diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9887cbe4d..083a0a9e8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -90,6 +90,7 @@ jobs: id: build run: | ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} + ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }} @@ -107,6 +108,12 @@ jobs: with: name: opencode-cli-windows path: packages/opencode/dist/opencode-windows* + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: opencode-preview-cli + path: packages/cli/dist/lildax-* + outputs: version: ${{ needs.version.outputs.version }} @@ -446,6 +453,11 @@ jobs: name: opencode-cli-signed-windows path: packages/opencode/dist + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: opencode-preview-cli + path: packages/cli/dist + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 if: needs.version.outputs.release with: diff --git a/bun.lock b/bun.lock index 7ebc6c13a..00f841655 100644 --- a/bun.lock +++ b/bun.lock @@ -87,14 +87,18 @@ "name": "@opencode-ai/cli", "version": "1.15.13", "bin": { - "opencode": "./src/index.ts", + "lildax": "./bin/lildax.cjs", }, "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:", @@ -517,6 +521,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", @@ -654,6 +659,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", @@ -1715,6 +1734,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"], diff --git a/packages/cli/bin/lildax.cjs b/packages/cli/bin/lildax.cjs new file mode 100644 index 000000000..e3491f3a4 --- /dev/null +++ b/packages/cli/bin/lildax.cjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node + +const childProcess = require("child_process") +const fs = require("fs") +const path = require("path") +const os = require("os") + +const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"] + +function run(target) { + const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" }) + child.on("error", (error) => { + console.error(error.message) + process.exit(1) + }) + const forwarders = {} + for (const signal of forwardedSignals) { + forwarders[signal] = () => { + try { + child.kill(signal) + } catch {} + } + process.on(signal, forwarders[signal]) + } + child.on("exit", (code, signal) => { + for (const forwardedSignal of forwardedSignals) process.removeListener(forwardedSignal, forwarders[forwardedSignal]) + if (signal) return process.kill(process.pid, signal) + process.exit(typeof code === "number" ? code : 0) + }) +} + +const envPath = process.env.OPENCODE_BIN_PATH +const scriptDir = path.dirname(fs.realpathSync(__filename)) +const cached = path.join(scriptDir, ".lildax") +const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform() +const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch() +const base = "@opencode-ai/lildax-" + platform + "-" + arch +const binary = platform === "windows" ? "lildax.exe" : "lildax" + +function supportsAvx2() { + if (arch !== "x64") return false + if (platform === "linux") { + try { + return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8")) + } catch { + return false + } + } + if (platform === "darwin") { + try { + const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { encoding: "utf8", timeout: 1500 }) + return result.status === 0 && (result.stdout || "").trim() === "1" + } catch { + return false + } + } + if (platform === "windows") { + const command = + '(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)' + for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) { + try { + const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], { + encoding: "utf8", + timeout: 3000, + windowsHide: true, + }) + if (result.status !== 0) continue + const output = (result.stdout || "").trim().toLowerCase() + if (output === "true" || output === "1") return true + if (output === "false" || output === "0") return false + } catch { + continue + } + } + } + return false +} + +const names = (() => { + const baseline = arch === "x64" && !supportsAvx2() + if (platform === "linux") { + const musl = (() => { + try { + if (fs.existsSync("/etc/alpine-release")) return true + const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) + return ((result.stdout || "") + (result.stderr || "")).toLowerCase().includes("musl") + } catch { + return false + } + })() + if (musl) return arch === "x64" ? (baseline ? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] : [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]) : [`${base}-musl`, base] + return arch === "x64" ? (baseline ? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] : [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]) : [base, `${base}-musl`] + } + return arch === "x64" ? (baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`]) : [base] +})() + +function findBinary(startDir) { + let current = startDir + for (;;) { + const modules = path.join(current, "node_modules") + if (fs.existsSync(modules)) for (const name of names) { + const candidate = path.join(modules, name, "bin", binary) + if (fs.existsSync(candidate)) return candidate + } + const parent = path.dirname(current) + if (parent === current) return + current = parent + } +} + +const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir)) +if (!resolved) { + console.error("It seems that your package manager failed to install the right lildax CLI package. Try manually installing " + names.map((name) => `"${name}"`).join(" or ") + " package") + process.exit(1) +} +run(resolved) diff --git a/packages/cli/package.json b/packages/cli/package.json index 822195356..6f6cf7f1c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,12 +2,14 @@ "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", "version": "1.15.13", - "private": true, "type": "module", "license": "MIT", "bin": { - "opencode": "./src/index.ts" + "lildax": "./bin/lildax.cjs" }, + "files": [ + "bin" + ], "scripts": { "build": "bun run script/build.ts", "dev": "bun run src/index.ts", @@ -16,9 +18,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:" diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts new file mode 100644 index 000000000..f91653c26 --- /dev/null +++ b/packages/cli/script/build.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env bun + +import { rm } from "fs/promises" +import path from "path" +import { Script } from "@opencode-ai/script" +import { modelsData } from "./generate" + +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 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 + +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) { + for (const log of result.logs) console.error(log) + process.exit(1) + } + + await Bun.write( + `./dist/${name}/package.json`, + JSON.stringify( + { + name: `@opencode-ai/${name}`, + version: Script.version, + license: "MIT", + os: [item.os], + cpu: [item.arch], + }, + null, + 2, + ), + ) +} diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts new file mode 100644 index 000000000..d98565e29 --- /dev/null +++ b/packages/cli/script/generate.ts @@ -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") diff --git a/packages/cli/script/publish.ts b/packages/cli/script/publish.ts new file mode 100644 index 000000000..5c2ca591f --- /dev/null +++ b/packages/cli/script/publish.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env bun +import { $ } from "bun" +import pkg from "../package.json" +import { Script } from "@opencode-ai/script" +import { fileURLToPath } from "url" + +const dir = fileURLToPath(new URL("..", import.meta.url)) +process.chdir(dir) + +async function published(name: string, version: string) { + return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0 +} + +async function publish(dir: string, name: string, version: string) { + if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) + if (await published(name, version)) return console.log(`already published ${name}@${version}`) + await $`bun pm pack`.cwd(dir) + await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir) +} + +const binaries: Record = {} +for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) { + const item = await Bun.file(`./dist/${filepath}`).json() + binaries[item.name] = item.version +} +console.log("binaries", binaries) +const version = Object.values(binaries)[0] + +await $`mkdir -p ./dist/${pkg.name}/bin` +await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax` +await Bun.file(`./dist/${pkg.name}/package.json`).write( + JSON.stringify( + { + name: pkg.name, + bin: { lildax: "./bin/lildax" }, + version, + license: pkg.license, + os: ["darwin", "linux", "win32"], + cpu: ["arm64", "x64"], + optionalDependencies: binaries, + }, + null, + 2, + ), +) + +await Promise.all(Object.entries(binaries).map(([name, version]) => publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version))) +await publish(`./dist/${pkg.name}`, pkg.name, version) diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts deleted file mode 100644 index d4a4a4fa7..000000000 --- a/packages/cli/src/api.ts +++ /dev/null @@ -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" }), - ], -}) diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts new file mode 100644 index 000000000..39594e995 --- /dev/null +++ b/packages/cli/src/commands/commands.ts @@ -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)), + }, + }), + ], +}) diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts new file mode 100644 index 000000000..3a0c20cb0 --- /dev/null +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -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, + ) + }), +) diff --git a/packages/cli/src/commands/handlers/migrate.ts b/packages/cli/src/commands/handlers/migrate.ts new file mode 100644 index 000000000..c73c7750d --- /dev/null +++ b/packages/cli/src/commands/handlers/migrate.ts @@ -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.")) diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts new file mode 100644 index 000000000..62c64df43 --- /dev/null +++ b/packages/cli/src/commands/handlers/serve.ts @@ -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, 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)) +} diff --git a/packages/cli/src/commands/handlers/service/password.ts b/packages/cli/src/commands/handlers/service/password.ts new file mode 100644 index 000000000..6bf49d50d --- /dev/null +++ b/packages/cli/src/commands/handlers/service/password.ts @@ -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) + }), +) diff --git a/packages/cli/src/commands/handlers/service/restart.ts b/packages/cli/src/commands/handlers/service/restart.ts new file mode 100644 index 000000000..d348987d1 --- /dev/null +++ b/packages/cli/src/commands/handlers/service/restart.ts @@ -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) + }), +) diff --git a/packages/cli/src/commands/handlers/service/start.ts b/packages/cli/src/commands/handlers/service/start.ts new file mode 100644 index 000000000..0d6fbaada --- /dev/null +++ b/packages/cli/src/commands/handlers/service/start.ts @@ -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) + }), +) diff --git a/packages/cli/src/commands/handlers/service/status.ts b/packages/cli/src/commands/handlers/service/status.ts new file mode 100644 index 000000000..d409970e8 --- /dev/null +++ b/packages/cli/src/commands/handlers/service/status.ts @@ -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) + }), +) diff --git a/packages/cli/src/commands/handlers/service/stop.ts b/packages/cli/src/commands/handlers/service/stop.ts new file mode 100644 index 000000000..8da9b04cf --- /dev/null +++ b/packages/cli/src/commands/handlers/service/stop.ts @@ -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() + }), +) diff --git a/packages/cli/src/cli-builder.ts b/packages/cli/src/framework/runtime.ts similarity index 59% rename from packages/cli/src/cli-builder.ts rename to packages/cli/src/framework/runtime.ts index e3dbaf8cb..eee9ff795 100644 --- a/packages/cli/src/cli-builder.ts +++ b/packages/cli/src/framework/runtime.ts @@ -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 extends CliApi.Node - ? Input + Value extends Spec.Node + ? Input : Value extends Command.Command ? Input : never -type RuntimeHandler = (input: unknown) => Effect.Effect -type Loader = () => Promise<{ default: (input: Input) => Effect.Effect }> -type ProvidedCommand = Command.Command +type RuntimeHandler = (input: unknown) => Effect.Effect +type Loader = () => Promise<{ default: (input: Input) => Effect.Effect }> +type ProvidedCommand = Command.Command -export type Handlers = keyof Node["commands"] extends never +export type Handlers = keyof Node["commands"] extends never ? Loader : { readonly $?: Loader } & { readonly [Key in keyof Node["commands"]]: Handlers } @@ -29,17 +30,17 @@ type RuntimeHandlers = readonly [key: string]: RuntimeHandlers | (() => Promise<{ default: RuntimeHandler }>) | undefined } -export function handler( +export function handler( _node: Node, - run: (input: Input) => Effect.Effect, + run: (input: Input) => Effect.Effect, ) { return run } -export function handlers(root: Root, handlers: Handlers) { +export function handlers(root: Root, handlers: Handlers) { 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(root: Root, handlers: Ha return result } -export function run(api: CliApi.Any, handlers: ReadonlyArray, options: { readonly version: string }) { - return Command.run(provide(api, handlers), options) as Effect.Effect +export function run(commands: Spec.Any, handlers: ReadonlyArray, options: { readonly version: string }) { + return Command.run(provide(commands, handlers), options) as Effect.Effect } -function provide(node: CliApi.Any, handlers: ReadonlyArray): ProvidedCommand { +function provide(node: Spec.Any, handlers: ReadonlyArray): ProvidedCommand { const spec: Command.Command.Any = Object.keys(node.commands).length ? (node.spec as Command.Command).pipe( Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))), @@ -65,8 +66,12 @@ function provide(node: CliApi.Any, handlers: ReadonlyArray): 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" diff --git a/packages/cli/src/cli-api.ts b/packages/cli/src/framework/spec.ts similarity index 97% rename from packages/cli/src/cli-api.ts rename to packages/cli/src/framework/spec.ts index 038428d66..3bb47e5e5 100644 --- a/packages/cli/src/cli-api.ts +++ b/packages/cli/src/framework/spec.ts @@ -39,4 +39,4 @@ type ChildrenOf> = { readonly [Node in Commands[number] as Node["name"]]: Node } -export * as CliApi from "./cli-api" +export * as Spec from "./spec" diff --git a/packages/cli/src/handlers/debug/agents.ts b/packages/cli/src/handlers/debug/agents.ts deleted file mode 100644 index 85eec4555..000000000 --- a/packages/cli/src/handlers/debug/agents.ts +++ /dev/null @@ -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), - ), -) diff --git a/packages/cli/src/handlers/migrate.ts b/packages/cli/src/handlers/migrate.ts deleted file mode 100644 index 0d9c1e6ac..000000000 --- a/packages/cli/src/handlers/migrate.ts +++ /dev/null @@ -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.")) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0a1f21a21..75837af53 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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, diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts new file mode 100644 index 000000000..8500add61 --- /dev/null +++ b/packages/cli/src/services/daemon.ts @@ -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, unknown> + readonly start: () => Effect.Effect + readonly status: () => Effect.Effect + readonly stop: () => Effect.Effect + readonly password: (value?: string) => Effect.Effect + readonly register: (address: HttpServer.Address) => Effect.Effect +} + +export class Service extends Context.Service()("@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" diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index fe5c4d217..00ef12546 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,6 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], "noUncheckedIndexedAccess": false } } diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts new file mode 100644 index 000000000..b9a5ae15d --- /dev/null +++ b/packages/core/src/command.ts @@ -0,0 +1,68 @@ +export * as CommandV2 from "./command" + +import { Context, Effect, Layer, Schema } from "effect" +import { castDraft, type Draft } from "immer" +import { ModelV2 } from "./model" +import { State } from "./state" + +export class Info extends Schema.Class("CommandV2.Info")({ + name: Schema.String, + template: Schema.String, + description: Schema.String.pipe(Schema.optional), + agent: Schema.String.pipe(Schema.optional), + model: ModelV2.Ref.pipe(Schema.optional), + subtask: Schema.Boolean.pipe(Schema.optional), +}) {} + +export type Data = { + commands: Map +} + +export type Editor = { + list: () => readonly Info[] + get: (name: string) => Info | undefined + update: (name: string, update: (command: Draft) => void) => void + remove: (name: string) => void +} + +export interface Interface { + readonly transform: State.Interface["transform"] + readonly get: (name: string) => Effect.Effect + readonly list: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Command") {} + +export const layer = Layer.effect( + Service, + Effect.sync(() => { + const state = State.create({ + initial: () => ({ commands: new Map() }), + editor: (draft) => ({ + list: () => Array.from(draft.commands.values()) as Info[], + get: (name) => draft.commands.get(name), + update: (name, update) => { + const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" })) + if (!draft.commands.has(name)) draft.commands.set(name, current) + update(current) + current.name = name + }, + remove: (name) => { + draft.commands.delete(name) + }, + }), + }) + + return Service.of({ + transform: state.transform, + get: Effect.fn("CommandV2.get")(function* (name) { + return state.get().commands.get(name) + }), + list: Effect.fn("CommandV2.list")(function* () { + return Array.from(state.get().commands.values()) + }), + }) + }), +) + +export const locationLayer = layer diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 55fb5c02d..286fa2618 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -12,6 +12,7 @@ import { AbsolutePath } from "./schema" import { ConfigAgent } from "./config/agent" import { ConfigAttachments } from "./config/attachments" import { ConfigCompaction } from "./config/compaction" +import { ConfigCommand } from "./config/command" import { ConfigExperimental } from "./config/experimental" import { ConfigFormatter } from "./config/formatter" import { ConfigLSP } from "./config/lsp" @@ -85,6 +86,9 @@ export class Info extends Schema.Class("Config.Info")({ skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ description: "Additional paths or URLs to discover skills from", }), + commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({ + description: "Named slash command definitions", + }), instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({ description: "Additional paths or URLs supplying ambient instructions", }), diff --git a/packages/core/src/config/command.ts b/packages/core/src/config/command.ts new file mode 100644 index 000000000..394079b1e --- /dev/null +++ b/packages/core/src/config/command.ts @@ -0,0 +1,12 @@ +export * as ConfigCommand from "./command" + +import { Schema } from "effect" + +export class Info extends Schema.Class("ConfigV2.Command")({ + template: Schema.String, + description: Schema.String.pipe(Schema.optional), + agent: Schema.String.pipe(Schema.optional), + model: Schema.String.pipe(Schema.optional), + variant: Schema.String.pipe(Schema.optional), + subtask: Schema.Boolean.pipe(Schema.optional), +}) {} diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts new file mode 100644 index 000000000..d90552954 --- /dev/null +++ b/packages/core/src/config/plugin/command.ts @@ -0,0 +1,82 @@ +export * as ConfigCommandPlugin from "./command" + +import path from "path" +import { Effect, Option, Schema } from "effect" +import { CommandV2 } from "../../command" +import { Config } from "../../config" +import { FSUtil } from "../../fs-util" +import { ModelV2 } from "../../model" +import { PluginV2 } from "../../plugin" +import { ConfigCommand } from "../command" +import { ConfigMarkdown } from "../markdown" + +const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info) + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("config-command"), + effect: Effect.gen(function* () { + const command = yield* CommandV2.Service + const config = yield* Config.Service + const fs = yield* FSUtil.Service + const transform = yield* command.transform() + const documents = yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }]) + return loadDirectory(fs, entry.path).pipe( + Effect.map((commands) => [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]), + ) + }).pipe(Effect.map((documents) => documents.flat())) + + yield* transform((editor) => { + for (const document of documents) { + for (const [name, command] of Object.entries(document.commands ?? {})) { + editor.update(name, (item) => { + item.template = command.template + if (command.description !== undefined) item.description = command.description + if (command.agent !== undefined) item.agent = command.agent + if (command.model !== undefined) { + const model = ModelV2.parse(command.model) + item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant } + } + if (command.variant !== undefined && item.model !== undefined) { + item.model.variant = ModelV2.VariantID.make(command.variant) + } + if (command.subtask !== undefined) item.subtask = command.subtask + }) + } + } + }) + }), +}) + +function loadDirectory(fs: FSUtil.Interface, directory: string) { + return Effect.gen(function* () { + const files = yield* fs + .glob("{command,commands}/**/*.md", { cwd: directory, absolute: true, dot: true, symlink: true }) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + return yield* Effect.forEach(files.toSorted(), (filepath) => + fs.readFileStringSafe(filepath).pipe( + Effect.map((content) => (content === undefined ? undefined : decode(directory, filepath, content))), + Effect.catch(() => Effect.succeed(undefined)), + ), + ).pipe( + Effect.map((commands) => + commands.filter((command): command is { name: string; info: ConfigCommand.Info } => command !== undefined), + ), + ) + }) +} + +function decode(directory: string, filepath: string, content: string) { + const markdown = ConfigMarkdown.parseOption(content) + if (!markdown) return + const info = Option.getOrUndefined(decodeCommand({ ...markdown.data, template: markdown.content.trim() })) + if (!info) return + return { + name: path + .relative(directory, filepath) + .replaceAll("\\", "/") + .replace(/^(command|commands)\//, "") + .replace(/\.md$/, ""), + info, + } +} diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 445ad4e2e..c4c4b6c0f 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -18,13 +18,14 @@ export const Plugin = PluginV2.define({ const skill = yield* SkillV2.Service const transform = yield* skill.transform() const entries = yield* config.entries() - const items = entries.flatMap((entry) => - entry.type === "document" - ? (entry.info.skills ?? []) - : [path.join(entry.path, "skill"), path.join(entry.path, "skills")], - ) + const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) + const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : [])) yield* transform((editor) => { + for (const directory of directories) { + editor.source(new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) })) + editor.source(new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) })) + } for (const item of items) { if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) { editor.source(new SkillV2.UrlSource({ type: "url", url: item })) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index 10b6aa537..93c59ef53 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -4,6 +4,7 @@ import { Policy } from "./policy" import { Config } from "./config" import { PluginV2 } from "./plugin" import { Catalog } from "./catalog" +import { CommandV2 } from "./command" import { AgentV2 } from "./agent" import { PluginBoot } from "./plugin/boot" import { Project } from "./project" @@ -51,6 +52,7 @@ export class LocationServiceMap extends LayerMap.Service()(" ProjectReference.locationLayer, PluginV2.locationLayer, Catalog.locationLayer, + CommandV2.locationLayer, AgentV2.locationLayer, PluginBoot.locationLayer, FileSystem.locationLayer, diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index f3d4ff10a..b8020b3c7 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -11,16 +11,23 @@ export const Ref = Schema.Struct({ }).annotate({ identifier: "Location.Ref" }) export type Ref = typeof Ref.Type -export interface Interface { - readonly directory: AbsolutePath - readonly workspaceID?: WorkspaceV2.ID - readonly project: { - readonly id: Project.ID - readonly directory: AbsolutePath - } +export class Info extends Schema.Class("Location.Info")({ + directory: AbsolutePath, + workspaceID: WorkspaceV2.ID.pipe(Schema.optional), + project: Schema.Struct({ + id: Project.ID, + directory: AbsolutePath, + }), +}) {} + +export interface Interface extends Info { readonly vcs?: Project.Vcs } +export function response(data: S) { + return Schema.Struct({ location: Info, data }) +} + export class Service extends Context.Service()("@opencode/Location") {} export const layer = (ref: Ref) => diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index a98775bee..a57647c4d 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -52,18 +52,6 @@ export const Api = Schema.Union([ ]).pipe(Schema.toTaggedUnion("type")) export type Api = typeof Api.Type -export const PublicApi = Schema.Union([ - Schema.Struct({ - id: ID, - ...ProviderV2.PublicAISDK.fields, - }), - Schema.Struct({ - id: ID, - ...ProviderV2.PublicNative.fields, - }), -]).pipe(Schema.toTaggedUnion("type")) -export type PublicApi = typeof PublicApi.Type - export class Info extends Schema.Class("ModelV2.Info")({ id: ID, providerID: ProviderV2.ID, @@ -125,64 +113,6 @@ export class Info extends Schema.Class("ModelV2.Info")({ } } -export class PublicInfo extends Schema.Class("ModelV2.PublicInfo")({ - id: ID, - providerID: ProviderV2.ID, - family: Family.pipe(Schema.optional), - name: Schema.String, - api: PublicApi, - capabilities: Capabilities, - variants: Schema.Struct({ - id: VariantID, - }).pipe(Schema.Array), - time: Schema.Struct({ - released: DateTimeUtcFromMillis, - }), - cost: Cost.pipe(Schema.Array), - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), - enabled: Schema.Boolean, - limit: Schema.Struct({ - context: Schema.Int, - input: Schema.Int.pipe(Schema.optional), - output: Schema.Int, - }), -}) {} - -export function toPublic(info: Info): PublicInfo { - const api = - info.api.type === "aisdk" - ? { - id: info.api.id, - type: info.api.type, - package: info.api.package, - url: ProviderV2.sanitizePublicUrl(info.api.url), - } - : { id: info.api.id, type: info.api.type, url: ProviderV2.sanitizePublicUrl(info.api.url) } - return new PublicInfo({ - id: info.id, - providerID: info.providerID, - family: info.family, - name: info.name, - api, - capabilities: { - tools: info.capabilities.tools, - input: [...info.capabilities.input], - output: [...info.capabilities.output], - }, - variants: info.variants.map((variant) => ({ id: variant.id })), - time: { released: info.time.released }, - cost: info.cost.map((cost) => ({ - tier: cost.tier && { ...cost.tier }, - input: cost.input, - output: cost.output, - cache: { ...cost.cache }, - })), - status: info.status, - enabled: info.enabled, - limit: { ...info.limit }, - }) -} - export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { const [providerID, ...modelID] = input.split("/") return { diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 554547fc8..6a589a1ef 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -4,8 +4,10 @@ import { Context, Deferred, Effect, Layer } from "effect" import { Auth } from "../auth" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" +import { CommandV2 } from "../command" import { Config } from "../config" import { ConfigAgentPlugin } from "../config/plugin/agent" +import { ConfigCommandPlugin } from "../config/plugin/command" import { ConfigSkillPlugin } from "../config/plugin/skill" import { EventV2 } from "../event" import { FSUtil } from "../fs-util" @@ -16,6 +18,8 @@ import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { AccountPlugin } from "./account" import { AgentPlugin } from "./agent" +import { CommandPlugin } from "./command" +import { SkillPlugin } from "./skill" import { ConfigProviderPlugin } from "../config/plugin/provider" import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" @@ -26,6 +30,7 @@ type Plugin = { id: PluginV2.ID effect: PluginV2.Effect< | Catalog.Service + | CommandV2.Service | Auth.Service | AgentV2.Service | Npm.Service @@ -50,6 +55,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service const plugin = yield* PluginV2.Service const accounts = yield* Auth.Service const agents = yield* AgentV2.Service @@ -68,6 +74,7 @@ export const layer = Layer.effect( id: input.id, effect: input.effect.pipe( Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), Effect.provideService(Auth.Service, accounts), Effect.provideService(AgentV2.Service, agents), Effect.provideService(Config.Service, config), @@ -87,12 +94,15 @@ export const layer = Layer.effect( yield* add(EnvPlugin) yield* add(AccountPlugin) yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) + yield* add(SkillPlugin.Plugin) for (const item of ProviderPlugins) { yield* add(item) } yield* add(ModelsDevPlugin) yield* add(ConfigProviderPlugin.Plugin) yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigSkillPlugin.Plugin) }).pipe(Effect.withSpan("PluginBoot.boot")) @@ -110,6 +120,7 @@ export const layer = Layer.effect( export const locationLayer = layer.pipe( Layer.provideMerge(Catalog.locationLayer), + Layer.provideMerge(CommandV2.locationLayer), Layer.provideMerge(Config.locationLayer), Layer.provideMerge(AgentV2.locationLayer), Layer.provideMerge(SkillV2.locationLayer), diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts new file mode 100644 index 000000000..66386a212 --- /dev/null +++ b/packages/core/src/plugin/command.ts @@ -0,0 +1,29 @@ +export * as CommandPlugin from "./command" + +import { Effect } from "effect" +import { CommandV2 } from "../command" +import { Location } from "../location" +import { PluginV2 } from "../plugin" +import PROMPT_INITIALIZE from "./command/initialize.txt" +import PROMPT_REVIEW from "./command/review.txt" + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("command"), + effect: Effect.gen(function* () { + const command = yield* CommandV2.Service + const location = yield* Location.Service + const transform = yield* command.transform() + + yield* transform((editor) => { + editor.update("init", (command) => { + command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory) + command.description = "guided AGENTS.md setup" + }) + editor.update("review", (command) => { + command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) + command.description = "review changes [commit|branch|pr], defaults to uncommitted" + command.subtask = true + }) + }) + }), +}) diff --git a/packages/core/src/plugin/command/initialize.txt b/packages/core/src/plugin/command/initialize.txt new file mode 100644 index 000000000..5fc073d61 --- /dev/null +++ b/packages/core/src/plugin/command/initialize.txt @@ -0,0 +1,65 @@ +Create or update `AGENTS.md` for this repository. + +The goal is a compact instruction file that helps future OpenCode sessions avoid mistakes and ramp up quickly. Every line should answer: "Would an agent likely miss this without help?" If not, leave it out. + +User-provided focus or constraints (honor these): +$ARGUMENTS + +## How to investigate + +Read the highest-value sources first: +- `README*`, root manifests, workspace config, lockfiles +- build, test, lint, formatter, typecheck, and codegen config +- CI workflows and pre-commit / task runner config +- existing instruction files (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules/`, `.cursorrules`, `.github/copilot-instructions.md`) +- repo-local OpenCode config such as `opencode.json` + +If architecture is still unclear after reading config and docs, inspect a small number of representative code files to find the real entrypoints, package boundaries, and execution flow. Prefer reading the files that explain how the system is wired together over random leaf files. + +Prefer executable sources of truth over prose. If docs conflict with config or scripts, trust the executable source and only keep what you can verify. + +## What to extract + +Look for the highest-signal facts for an agent working in this repo: +- exact developer commands, especially non-obvious ones +- how to run a single test, a single package, or a focused verification step +- required command order when it matters, such as `lint -> typecheck -> test` +- monorepo or multi-package boundaries, ownership of major directories, and the real app/library entrypoints +- framework or toolchain quirks: generated code, migrations, codegen, build artifacts, special env loading, dev servers, infra deploy flow +- testing quirks: fixtures, integration test prerequisites, snapshot workflows, required services, flaky or expensive suites +- important constraints from existing instruction files worth preserving + +Good `AGENTS.md` content is usually hard-earned context that took reading multiple files to infer. + +## Questions + +Only ask the user questions if the repo cannot answer something important. Use the `question` tool for one short batch at most. + +Good questions: +- undocumented team conventions +- branch / PR / release expectations +- missing setup or test prerequisites that are known but not written down + +Do not ask about anything the repo already makes clear. + +## Writing rules + +Include only high-signal, repo-specific guidance such as: +- exact commands and shortcuts the agent would otherwise guess wrong +- architecture notes that are not obvious from filenames +- conventions that differ from language or framework defaults +- setup requirements, environment quirks, and operational gotchas +- references to existing instruction sources that matter + +Exclude: +- generic software advice +- long tutorials or exhaustive file trees +- obvious language conventions +- speculative claims or anything you could not verify +- content better stored in another file referenced via `opencode.json` `instructions` + +When in doubt, omit. + +Prefer short sections and bullets. If the repo is simple, keep the file simple. If the repo is large, summarize the few structural facts that actually change how an agent should work. + +If `AGENTS.md` already exists at `${path}`, improve it in place rather than rewriting blindly. Preserve verified useful guidance, delete fluff or stale claims, and reconcile it with the current codebase. diff --git a/packages/core/src/plugin/command/review.txt b/packages/core/src/plugin/command/review.txt new file mode 100644 index 000000000..071807ec8 --- /dev/null +++ b/packages/core/src/plugin/command/review.txt @@ -0,0 +1,100 @@ +You are a code reviewer. Your job is to review code changes and provide actionable feedback. + +--- + +Input: $ARGUMENTS + +--- + +## Determining What to Review + +Based on the input provided, determine which type of review to perform: + +1. **No arguments (default)**: Review all uncommitted changes + - Run: `git diff` for unstaged changes + - Run: `git diff --cached` for staged changes + - Run: `git status --short` to identify untracked (net new) files + +2. **Commit hash** (40-char SHA or short hash): Review that specific commit + - Run: `git show $ARGUMENTS` + +3. **Branch name**: Compare current branch to the specified branch + - Run: `git diff $ARGUMENTS...HEAD` + +4. **PR URL or number** (contains "github.com" or "pull" or looks like a PR number): Review the pull request + - Run: `gh pr view $ARGUMENTS` to get PR context + - Run: `gh pr diff $ARGUMENTS` to get the diff + +Use best judgement when processing input. + +--- + +## Gathering Context + +**Diffs alone are not enough.** After getting the diff, read the entire file(s) being modified to understand the full context. Code that looks wrong in isolation may be correct given surrounding logic—and vice versa. + +- Use the diff to identify which files changed +- Use `git status --short` to identify untracked files, then read their full contents +- Read the full file to understand existing patterns, control flow, and error handling +- Check for existing style guide or conventions files (CONVENTIONS.md, AGENTS.md, .editorconfig, etc.) + +--- + +## What to Look For + +**Bugs** - Your primary focus. +- Logic errors, off-by-one mistakes, incorrect conditionals +- If-else guards: missing guards, incorrect branching, unreachable code paths +- Edge cases: null/empty/undefined inputs, error conditions, race conditions +- Security issues: injection, auth bypass, data exposure +- Broken error handling that swallows failures, throws unexpectedly or returns error types that are not caught. + +**Structure** - Does the code fit the codebase? +- Does it follow existing patterns and conventions? +- Are there established abstractions it should use but doesn't? +- Excessive nesting that could be flattened with early returns or extraction + +**Performance** - Only flag if obviously problematic. +- O(n²) on unbounded data, N+1 queries, blocking I/O on hot paths + +**Behavior Changes** - If a behavioral change is introduced, raise it (especially if it's possibly unintentional). + +--- + +## Before You Flag Something + +**Be certain.** If you're going to call something a bug, you need to be confident it actually is one. + +- Only review the changes - do not review pre-existing code that wasn't modified +- Don't flag something as a bug if you're unsure - investigate first +- Don't invent hypothetical problems - if an edge case matters, explain the realistic scenario where it breaks +- If you need more context to be sure, use the tools below to get it + +**Don't be a zealot about style.** When checking code against conventions: + +- Verify the code is *actually* in violation. Don't complain about else statements if early returns are already being used correctly. +- Some "violations" are acceptable when they're the simplest option. A `let` statement is fine if the alternative is convoluted. +- Excessive nesting is a legitimate concern regardless of other style choices. + +--- + +## Tools + +Use these to inform your review: + +- **Explore agent** - Find how existing code handles similar problems. Check patterns, conventions, and prior art before claiming something doesn't fit. +- **Exa Code Context** - Verify correct usage of libraries/APIs before flagging something as wrong. +- **Web Search** - Research best practices if you're unsure about a pattern. + +If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue. + +--- + +## Output + +1. If there is a bug, be direct and clear about why it is a bug. +2. Clearly communicate severity of issues. Do not overstate severity. +3. Critiques should clearly and explicitly communicate the scenarios, environments, or inputs that are necessary for the bug to arise. The comment should immediately indicate that the issue's severity depends on these factors. +4. Your tone should be matter-of-fact and not accusatory or overly positive. It should read as a helpful AI assistant suggestion without sounding too much like a human reviewer. +5. Write so the reader can quickly understand the issue without reading too closely. +6. AVOID flattery, do not give any comments that are not helpful to the reader. diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts new file mode 100644 index 000000000..c3e226f74 --- /dev/null +++ b/packages/core/src/plugin/skill.ts @@ -0,0 +1,30 @@ +export * as SkillPlugin from "./skill" + +import { Effect } from "effect" +import { PluginV2 } from "../plugin" +import { AbsolutePath } from "../schema" +import { SkillV2 } from "../skill" + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("skill"), + effect: Effect.gen(function* () { + const skill = yield* SkillV2.Service + const transform = yield* skill.transform() + const content = yield* Effect.promise(() => Bun.file(new URL("./skill/customize-opencode.md", import.meta.url)).text()) + + yield* transform((editor) => { + editor.source( + new SkillV2.EmbeddedSource({ + type: "embedded", + skill: new SkillV2.Info({ + name: "customize-opencode", + description: + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", + location: AbsolutePath.make("/builtin/customize-opencode.md"), + content, + }), + }), + ) + }) + }), +}) diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md similarity index 99% rename from packages/opencode/src/skill/prompt/customize-opencode.md rename to packages/core/src/plugin/skill/customize-opencode.md index a3bc44a1f..5b51f8f2a 100644 --- a/packages/opencode/src/skill/prompt/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -1,6 +1,6 @@ diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 7b122ad41..044cf7b16 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -22,9 +22,6 @@ export const ID = Schema.String.pipe( ) export type ID = typeof ID.Type -export const ModelID = Schema.String.pipe(Schema.brand("ModelID")) -export type ModelID = typeof ModelID.Type - export const AISDK = Schema.Struct({ type: Schema.Literal("aisdk"), package: Schema.String, @@ -41,20 +38,6 @@ export const Native = Schema.Struct({ export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type")) export type Api = typeof Api.Type -export const PublicAISDK = Schema.Struct({ - type: Schema.Literal("aisdk"), - package: Schema.String, - url: Schema.String.pipe(Schema.optional), -}) - -export const PublicNative = Schema.Struct({ - type: Schema.Literal("native"), - url: Schema.String.pipe(Schema.optional), -}) - -export const PublicApi = Schema.Union([PublicAISDK, PublicNative]).pipe(Schema.toTaggedUnion("type")) -export type PublicApi = typeof PublicApi.Type - export const Request = Schema.Struct({ headers: Schema.Record(Schema.String, Schema.String), body: Schema.Record(Schema.String, Schema.Any), @@ -100,49 +83,3 @@ export class Info extends Schema.Class("ProviderV2.Info")({ }) } } - -export class PublicInfo extends Schema.Class("ProviderV2.PublicInfo")({ - id: ID, - name: Schema.String, - enabled: Schema.Union([ - Schema.Literal(false), - Schema.Struct({ - via: Schema.Literal("env"), - name: Schema.String, - }), - Schema.Struct({ - via: Schema.Literal("account"), - service: Schema.String, - }), - Schema.Struct({ - via: Schema.Literal("custom"), - }), - ]), - env: Schema.String.pipe(Schema.Array), - api: PublicApi, -}) {} - -export function sanitizePublicUrl(value: string | undefined): string | undefined { - if (!value) return undefined - try { - const url = new URL(value) - return url.protocol === "http:" || url.protocol === "https:" ? url.origin : undefined - } catch { - return undefined - } -} - -export function toPublic(info: Info): PublicInfo { - const enabled = info.enabled === false || info.enabled.via !== "custom" ? info.enabled : { via: "custom" as const } - const api = - info.api.type === "aisdk" - ? { type: info.api.type, package: info.api.package, url: sanitizePublicUrl(info.api.url) } - : { type: info.api.type, url: sanitizePublicUrl(info.api.url) } - return new PublicInfo({ - id: info.id, - name: info.name, - enabled, - env: [...info.env], - api, - }) -} diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 0dcb2e5e0..38496ea07 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -243,7 +243,7 @@ export const layer = Layer.effect( agent: input.agent, model: input.model ? { - id: ProviderV2.ModelID.make(input.model.id), + id: ModelV2.ID.make(input.model.id), providerID: input.model.providerID, variant: input.model.variant, } diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index bd384f288..0f1978653 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -21,17 +21,23 @@ export class UrlSource extends Schema.Class("SkillV2.UrlSource")({ url: Schema.String, }) {} -export const Source = Schema.Union([DirectorySource, UrlSource]).pipe( +export class EmbeddedSource extends Schema.Class("SkillV2.EmbeddedSource")({ + type: Schema.Literal("embedded"), + skill: Schema.suspend(() => Info), +}) {} + +export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe( Schema.toTaggedUnion("type"), withStatics(() => ({ - equals: (a: DirectorySource | UrlSource, b: DirectorySource | UrlSource) => { + equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => { if (a.type !== b.type) return false if (a.type === "directory" && b.type === "directory") return a.path === b.path if (a.type === "url" && b.type === "url") return a.url === b.url + if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name return false }, - key: (source: DirectorySource | UrlSource) => - source.type === "directory" ? `directory:${source.path}` : `url:${source.url}`, + key: (source: DirectorySource | UrlSource | EmbeddedSource) => + source.type === "directory" ? `directory:${source.path}` : source.type === "url" ? `url:${source.url}` : `embedded:${source.skill.name}`, })), ) export type Source = typeof Source.Type @@ -89,6 +95,7 @@ export const layer = Layer.effect( const load = Effect.fn("SkillV2.load")(function* (source: Source) { const skills: Info[] = [] + if (source.type === "embedded") return [source.skill] const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url) for (const directory of directories) { const files = yield* fs diff --git a/packages/core/src/v1/config/command.ts b/packages/core/src/v1/config/command.ts index 37bbdc44f..281d53091 100644 --- a/packages/core/src/v1/config/command.ts +++ b/packages/core/src/v1/config/command.ts @@ -7,6 +7,7 @@ export const Info = Schema.Struct({ description: Schema.optional(Schema.String), agent: Schema.optional(Schema.String), model: Schema.optional(Schema.String), + variant: Schema.optional(Schema.String), subtask: Schema.optional(Schema.Boolean), }) export type Info = Schema.Schema.Type diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 9b123ecd1..5dea17a4a 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -61,6 +61,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) { buffer: info.compaction.reserved, }, skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])], + commands: info.command, instructions: info.instructions, references: info.reference, plugins: info.plugin?.map((plugin) => diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index 3a732e54d..61393c13b 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -5,6 +5,7 @@ import { EventV2 } from "../event" import { PermissionV1 } from "./permission" import { ProjectV2 } from "../project" import { ProviderV2 } from "../provider" +import { ModelV2 } from "../model" import { optionalOmitUndefined, withStatics } from "../schema" import { Identifier } from "../util/identifier" import { NonNegativeInt } from "../schema" @@ -200,7 +201,7 @@ export const SubtaskPart = Schema.Struct({ model: Schema.optional( Schema.Struct({ providerID: ProviderV2.ID, - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, }), ), command: Schema.optional(Schema.String), @@ -344,7 +345,7 @@ export const User = Schema.Struct({ agent: Schema.String, model: Schema.Struct({ providerID: ProviderV2.ID, - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, variant: Schema.optional(Schema.String), }), system: Schema.optional(Schema.String), @@ -440,7 +441,7 @@ export const SubtaskPartInput = Schema.Struct({ model: Schema.optional( Schema.Struct({ providerID: ProviderV2.ID, - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, }), ), command: Schema.optional(Schema.String), @@ -456,7 +457,7 @@ export const Assistant = Schema.Struct({ }), error: Schema.optional(AssistantErrorSchema), parentID: MessageID, - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, providerID: ProviderV2.ID, mode: Schema.String, agent: Schema.String, @@ -532,7 +533,7 @@ const SessionRevert = Schema.Struct({ }) const SessionModel = Schema.Struct({ - id: ProviderV2.ModelID, + id: ModelV2.ID, providerID: ProviderV2.ID, variant: optionalOmitUndefined(Schema.String), }) diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 2f247ed0f..14811d67c 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -6,6 +6,7 @@ import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { Policy } from "@opencode-ai/core/policy" +import { Project } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" @@ -187,7 +188,12 @@ describe("CatalogV2", () => { yield* events.publish( PluginV2.Event.Added, { id: PluginV2.ID.make("test-transform") }, - { location: { directory: AbsolutePath.make("other") } }, + { + location: new Location.Info({ + directory: AbsolutePath.make("other"), + project: { id: Project.ID.global, directory: AbsolutePath.make("other") }, + }), + }, ) yield* Effect.yieldNow diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts new file mode 100644 index 000000000..f2175743e --- /dev/null +++ b/packages/core/test/command.test.ts @@ -0,0 +1,56 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CommandV2 } from "@opencode-ai/core/command" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "./lib/effect" + +const it = testEffect(CommandV2.locationLayer) + +describe("CommandV2", () => { + it.effect("applies command transforms and preserves later overrides", () => + Effect.gen(function* () { + const command = yield* CommandV2.Service + const transform = yield* command.transform() + yield* transform((editor) => { + editor.update("review", (command) => { + command.template = "First" + command.description = "Review code" + }) + editor.update("review", (command) => { + command.template = "Second" + command.model = { + id: ModelV2.ID.make("claude"), + providerID: ProviderV2.ID.make("anthropic"), + variant: ModelV2.VariantID.make("high"), + } + }) + }) + + expect(yield* command.get("review")).toEqual( + new CommandV2.Info({ + name: "review", + template: "Second", + description: "Review code", + model: { + id: ModelV2.ID.make("claude"), + providerID: ProviderV2.ID.make("anthropic"), + variant: ModelV2.VariantID.make("high"), + }, + }), + ) + expect(yield* command.list()).toEqual([ + new CommandV2.Info({ + name: "review", + template: "Second", + description: "Review code", + model: { + id: ModelV2.ID.make("claude"), + providerID: ProviderV2.ID.make("anthropic"), + variant: ModelV2.VariantID.make("high"), + }, + }), + ]) + }), + ) +}) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts new file mode 100644 index 000000000..da3bb749b --- /dev/null +++ b/packages/core/test/config/command.test.ts @@ -0,0 +1,81 @@ +import fs from "fs/promises" +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema } from "effect" +import { CommandV2 } from "@opencode-ai/core/command" +import { Config } from "@opencode-ai/core/config" +import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { tmpdir } from "../fixture/tmpdir" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer)) +const decode = Schema.decodeUnknownSync(Config.Info) + +describe("ConfigCommandPlugin.Plugin", () => { + it.live("loads inline and file-based commands in config order", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(path.join(tmp.path, "commands", "nested"), { recursive: true }) + await fs.writeFile( + path.join(tmp.path, "commands", "review.md"), + `--- +description: File review +agent: reviewer +model: anthropic/claude +variant: high +subtask: true +--- +Review files`, + ) + await fs.writeFile(path.join(tmp.path, "commands", "nested", "docs.md"), "Write docs") + await fs.writeFile(path.join(tmp.path, "commands", "empty.md"), "") + }) + + const command = yield* CommandV2.Service + yield* ConfigCommandPlugin.Plugin.effect.pipe( + Effect.provideService(CommandV2.Service, command), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ commands: { review: { template: "Inline review" } } }), + }), + new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }), + ]), + }), + ), + ) + + expect(yield* command.list()).toEqual([ + new CommandV2.Info({ + name: "review", + template: "Review files", + description: "File review", + agent: "reviewer", + model: { + providerID: ProviderV2.ID.make("anthropic"), + id: ModelV2.ID.make("claude"), + variant: ModelV2.VariantID.make("high"), + }, + subtask: true, + }), + new CommandV2.Info({ name: "empty", template: "" }), + new CommandV2.Info({ name: "nested/docs", template: "Write docs" }), + ]) + }), + ), + ), + ) +}) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 5b218dae5..465a41547 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -100,6 +100,34 @@ describe("Config", () => { }), ) + it.effect("migrates v1 command configuration", () => + Effect.sync(() => { + expect( + ConfigMigrateV1.migrate({ + command: { + review: { + template: "Review changes", + description: "Review code", + agent: "reviewer", + model: "anthropic/claude", + variant: "high", + subtask: true, + }, + }, + }).commands, + ).toEqual({ + review: { + template: "Review changes", + description: "Review code", + agent: "reviewer", + model: "anthropic/claude", + variant: "high", + subtask: true, + }, + }) + }), + ) + it.live("returns an empty configuration when directory files do not exist", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts new file mode 100644 index 000000000..f1136ee81 --- /dev/null +++ b/packages/core/test/plugin/command.test.ts @@ -0,0 +1,44 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { CommandV2 } from "@opencode-ai/core/command" +import { Location } from "@opencode-ai/core/location" +import { CommandPlugin } from "@opencode-ai/core/plugin/command" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "../fixture/location" +import { testEffect } from "../lib/effect" + +const directory = AbsolutePath.make("/repo/packages/app") +const project = AbsolutePath.make("/repo") +const it = testEffect( + CommandV2.locationLayer.pipe( + Layer.provide( + Layer.succeed( + Location.Service, + Location.Service.of(location({ directory }, { projectDirectory: project })), + ), + ), + ), +) + +describe("CommandPlugin.Plugin", () => { + it.effect("registers built-in init and review commands", () => + Effect.gen(function* () { + const command = yield* CommandV2.Service + yield* CommandPlugin.Plugin.effect.pipe( + Effect.provideService(CommandV2.Service, command), + Effect.provideService(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))), + ) + + expect(yield* command.get("init")).toMatchObject({ + name: "init", + description: "guided AGENTS.md setup", + }) + expect((yield* command.get("init"))?.template).toContain("`/repo`") + expect(yield* command.get("review")).toMatchObject({ + name: "review", + description: "review changes [commit|branch|pr], defaults to uncommitted", + subtask: true, + }) + }), + ) +}) diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts new file mode 100644 index 000000000..63d028e4e --- /dev/null +++ b/packages/core/test/plugin/skill.test.ts @@ -0,0 +1,32 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { SkillPlugin } from "@opencode-ai/core/plugin/skill" +import { SkillV2 } from "@opencode-ai/core/skill" +import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" +import { testEffect } from "../lib/effect" + +const it = testEffect( + SkillV2.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(SkillDiscovery.defaultLayer), + Layer.provideMerge(AgentV2.locationLayer), + ), +) + +describe("SkillPlugin.Plugin", () => { + it.effect("registers the built-in customize-opencode skill", () => + Effect.gen(function* () { + const skill = yield* SkillV2.Service + yield* SkillPlugin.Plugin.effect.pipe(Effect.provideService(SkillV2.Service, skill)) + + expect(yield* skill.list()).toContainEqual( + expect.objectContaining({ + name: "customize-opencode", + description: expect.stringContaining("opencode's own configuration"), + }), + ) + }), + ) +}) diff --git a/packages/core/test/public-catalog.test.ts b/packages/core/test/public-catalog.test.ts deleted file mode 100644 index 6f4c419df..000000000 --- a/packages/core/test/public-catalog.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Catalog } from "@opencode-ai/core/catalog" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { ModelV2 } from "@opencode-ai/core/model" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { Effect, Layer, Schema } from "effect" -import { location } from "./fixture/location" -import { testEffect } from "./lib/effect" - -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("test") })), -) -const it = testEffect( - Catalog.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)), -) - -const encodeProvider = Schema.encodeSync(ProviderV2.PublicInfo) -const encodeModel = Schema.encodeSync(ModelV2.PublicInfo) - -describe("public catalog DTOs", () => { - test("provider DTO excludes credentials and internal settings", () => { - const providerID = ProviderV2.ID.make("test") - const encoded = encodeProvider( - ProviderV2.toPublic( - new ProviderV2.Info({ - ...ProviderV2.Info.empty(providerID), - enabled: { via: "account", service: "test-account" }, - env: ["TEST_API_KEY"], - api: { type: "native", url: "https://example.com", settings: { apiKey: "settings-secret" } }, - request: { - headers: { Authorization: "Bearer header-secret", "x-api-key": "header-secret" }, - body: { apiKey: "body-secret", account: "account-body-secret" }, - }, - }), - ), - ) - - expect(encoded).toEqual({ - id: "test", - name: "test", - enabled: { via: "account", service: "test-account" }, - env: ["TEST_API_KEY"], - api: { type: "native", url: "https://example.com" }, - }) - expect(JSON.stringify(encoded)).not.toMatch(/Authorization|x-api-key|apiKey|account-body-secret|settings-secret/) - }) - - test("provider DTO excludes custom enabled metadata", () => { - const providerID = ProviderV2.ID.make("custom") - const encoded = encodeProvider( - ProviderV2.toPublic( - new ProviderV2.Info({ - ...ProviderV2.Info.empty(providerID), - enabled: { via: "custom", data: { apiKey: "custom-secret" } }, - }), - ), - ) - - expect(encoded.enabled).toEqual({ via: "custom" }) - expect(JSON.stringify(encoded)).not.toContain("custom-secret") - }) - - it.effect("model DTO excludes resolved provider requests and variant requests", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const providerID = ProviderV2.ID.make("test") - const modelID = ModelV2.ID.make("model") - const transform = yield* catalog.transform() - - yield* transform((catalog) => { - catalog.provider.update(providerID, (provider) => { - provider.enabled = { via: "account", service: "test-account" } - provider.api = { type: "native", url: "https://example.com", settings: { apiKey: "settings-secret" } } - provider.request.headers.Authorization = "Bearer provider-secret" - provider.request.body.apiKey = "provider-body-secret" - provider.request.body.account = "account-body-secret" - }) - catalog.model.update(providerID, modelID, (model) => { - model.request.headers["x-api-key"] = "model-header-secret" - model.request.body.apiKey = "model-body-secret" - model.variants.push({ - id: ModelV2.VariantID.make("fast"), - headers: { Authorization: "Bearer variant-secret" }, - body: { apiKey: "variant-body-secret" }, - }) - }) - }) - - const model = yield* catalog.model.get(providerID, modelID) - expect(model.request.headers.Authorization).toBe("Bearer provider-secret") - expect(model.request.headers["x-api-key"]).toBe("model-header-secret") - expect(model.request.body.apiKey).toBe("model-body-secret") - expect(model.request.body.account).toBe("account-body-secret") - expect(model.api).toHaveProperty("settings.apiKey", "settings-secret") - - const encoded = encodeModel(ModelV2.toPublic(model)) - expect(encoded.api).toEqual({ id: "model", type: "native", url: "https://example.com" }) - expect(encoded.variants).toEqual([{ id: "fast" }]) - expect(encoded).not.toHaveProperty("request") - expect(JSON.stringify(encoded)).not.toMatch( - /Authorization|x-api-key|apiKey|account-body-secret|provider-secret|model-header-secret|variant-secret|settings-secret/, - ) - }), - ) -}) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f5e7fda8b..751831d84 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -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", diff --git a/packages/opencode/src/acp/directory.ts b/packages/opencode/src/acp/directory.ts index eb0b78859..f61023c10 100644 --- a/packages/opencode/src/acp/directory.ts +++ b/packages/opencode/src/acp/directory.ts @@ -3,6 +3,7 @@ import { Command } from "@/command" import { InstanceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" import { Context, Effect, Layer, SynchronizedRef } from "effect" import type * as ACPError from "./error" @@ -10,7 +11,7 @@ import type * as ACPError from "./error" export type ModelOption = { readonly providerID: ProviderV2.ID readonly providerName: string - readonly modelID: ProviderV2.ModelID + readonly modelID: ModelV2.ID readonly modelName: string } @@ -24,7 +25,7 @@ export type ModelVariants = NonNullable export type DefaultModel = { readonly providerID: ProviderV2.ID - readonly modelID: ProviderV2.ModelID + readonly modelID: ModelV2.ID } export type Snapshot = { diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index b8c700ea0..3771459b1 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -42,6 +42,7 @@ import { ACPSession } from "./session" import { UsageService } from "./usage" import { ACPProfile } from "./profile" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" import type { Command } from "@/command" @@ -650,7 +651,7 @@ function makeUsageService(sdk: OpencodeClient) { const size = yield* contextLimit({ directory: params.directory, providerID: ProviderV2.ID.make(message.providerID), - modelID: ProviderV2.ModelID.make(message.modelID), + modelID: ModelV2.ID.make(message.modelID), }) if (!size) return @@ -812,7 +813,7 @@ function selectDefaultModel(snapshot: Directory.Snapshot) { if (snapshot.defaultModel) return snapshot.defaultModel const model = snapshot.modelOptions[0] if (model) return { providerID: model.providerID, modelID: model.modelID } - return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ProviderV2.ModelID } + return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ModelV2.ID } } function detectSlashCommand(parts: ReturnType) { @@ -872,7 +873,7 @@ function configOptions(snapshot: Directory.Snapshot, session: ConfigState) { function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) { const selected = parseModelSelection(modelId, Object.values(snapshot.providers)) const provider = snapshot.providers[ProviderV2.ID.make(selected.model.providerID)] - const model = provider?.models[ProviderV2.ModelID.make(selected.model.modelID)] + const model = provider?.models[ModelV2.ID.make(selected.model.modelID)] if (!model) { return Effect.fail( new ACPError.InvalidModelError({ @@ -1000,7 +1001,7 @@ function restoreFromMessages(messages: readonly MessageInfo[]) { ) if (user?.model?.providerID && user.model.modelID) { return { - model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ProviderV2.ModelID }, + model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ModelV2.ID }, variant: user.model.variant, modeId: user.agent, } @@ -1009,7 +1010,7 @@ function restoreFromMessages(messages: readonly MessageInfo[]) { const assistant = messages.findLast((message) => message.providerID && message.modelID) if (assistant?.providerID && assistant.modelID) { return { - model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ProviderV2.ModelID }, + model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ModelV2.ID }, variant: assistant.variant, modeId: assistant.mode ?? assistant.agent, } diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 7b7dc9d4a..e0b3af99f 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,12 +1,13 @@ import type { McpServer } from "@agentclientprotocol/sdk" import type { Message, Part } from "@opencode-ai/sdk/v2" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { Context, Effect, Layer, Ref } from "effect" import * as ACPError from "./error" export type SelectedModel = { providerID: ProviderV2.ID - modelID: ProviderV2.ModelID + modelID: ModelV2.ID } export type KnownMessagePartMetadata = { diff --git a/packages/opencode/src/acp/usage.ts b/packages/opencode/src/acp/usage.ts index a6db606b5..a7af8cc92 100644 --- a/packages/opencode/src/acp/usage.ts +++ b/packages/opencode/src/acp/usage.ts @@ -4,6 +4,7 @@ import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@ope import { InstanceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" import { Context, Effect, Layer, SynchronizedRef } from "effect" @@ -50,7 +51,7 @@ export interface Interface { readonly contextLimit: (input: { readonly directory: string readonly providerID: ProviderV2.ID - readonly modelID: ProviderV2.ModelID + readonly modelID: ModelV2.ID }) => Effect.Effect readonly sendUpdate: (input: { readonly connection: UsageConnection @@ -112,7 +113,7 @@ export function totalSessionCost(messages: readonly SessionMessage[]): number { export function findContextLimit( providers: Record, providerID: ProviderV2.ID, - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, ): number | undefined { return providers[providerID]?.models[modelID]?.limit.context } @@ -144,7 +145,7 @@ export const layer = Layer.effect( const cachedLimit = Effect.fnUntraced(function* (input: { readonly directory: string readonly providerID: ProviderV2.ID - readonly modelID: ProviderV2.ModelID + readonly modelID: ModelV2.ID }) { return yield* SynchronizedRef.modifyEffect( limits, @@ -171,7 +172,7 @@ export const layer = Layer.effect( const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: { readonly directory: string readonly providerID: ProviderV2.ID - readonly modelID: ProviderV2.ModelID + readonly modelID: ModelV2.ID }) { return yield* yield* cachedLimit(input) }) @@ -198,7 +199,7 @@ export const layer = Layer.effect( const size = yield* contextLimit({ directory: input.directory, providerID: ProviderV2.ID.make(message.providerID), - modelID: ProviderV2.ModelID.make(message.modelID), + modelID: ModelV2.ID.make(message.modelID), }) if (!size) return diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 03d711e40..a04f5315b 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -25,6 +25,7 @@ import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" import { type DeepMutable } from "@opencode-ai/core/schema" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" export const Info = Schema.Struct({ name: Schema.String, @@ -38,7 +39,7 @@ export const Info = Schema.Struct({ permission: PermissionV1.Ruleset, model: Schema.optional( Schema.Struct({ - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, providerID: ProviderV2.ID, }), ), @@ -62,7 +63,7 @@ export interface Interface { readonly defaultAgent: () => Effect.Effect readonly generate: (input: { description: string - model?: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID } + model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID } }) => Effect.Effect< { identifier: string @@ -350,7 +351,7 @@ export const layer = Layer.effect( }), generate: Effect.fn("Agent.generate")(function* (input: { description: string - model?: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID } + model?: { providerID: ProviderV2.ID; modelID: ModelV2.ID } }) { const cfg = yield* config.get() const model = input.model ?? (yield* provider.defaultModel()) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index e34933df8..0b0690d9d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -348,7 +348,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( message: { async sync(sessionID: string) { const response = await sdk.client.v2.session.messages({ sessionID }) - setStore("messages", sessionID, reconcile(response.data?.items ?? [])) + setStore("messages", sessionID, reconcile(response.data?.data ?? [])) }, fromSession(sessionID: string) { const messages = store.messages[sessionID] diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 673bf1f15..6a31b42b2 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -3,6 +3,8 @@ import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { GlobalBus } from "@/bus/global" import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" import "@opencode-ai/core/account" import "@opencode-ai/core/catalog" @@ -24,10 +26,11 @@ export const layer = Layer.effect( const workspaceID = yield* WorkspaceRef return yield* events.publish(definition, data, { ...options, - location: { + location: new Location.Info({ directory: AbsolutePath.make(ctx.directory), ...(workspaceID ? { workspaceID } : {}), - }, + project: { id: Project.ID.make(ctx.project.id), directory: AbsolutePath.make(ctx.worktree) }, + }), }) }) @@ -41,6 +44,25 @@ export const layer = Layer.effect( workspace: workspaceID, payload: { id: event.id, type: event.type, properties: event.data }, }) + const sync = EventV2.registry.get(event.type)?.sync + if (sync === undefined || event.seq === undefined || event.version === undefined) return + const aggregateID = (event.data as Record)[sync.aggregate] + if (typeof aggregateID !== "string") return + GlobalBus.emit("event", { + directory: event.location?.directory ?? ctx?.directory, + project: ctx?.project.id, + workspace: workspaceID, + payload: { + type: "sync", + syncEvent: { + id: event.id, + type: EventV2.versionedType(event.type, event.version), + seq: event.seq, + aggregateID, + data: event.data, + }, + }, + }) }), ) yield* Effect.addFinalizer(() => unsubscribe) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 7dd454b7b..db8811138 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -27,6 +27,7 @@ import { isRecord } from "@/util/record" import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { ProviderTransform } from "./transform" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { ModelStatus } from "./model-status" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderError } from "./error" @@ -664,7 +665,7 @@ function custom(dep: CustomDep): Record { for (const m of result.models) { if (!input.models[m.id]) { models[m.id] = { - id: ProviderV2.ModelID.make(m.id), + id: ModelV2.ID.make(m.id), providerID: ProviderV2.ID.make("gitlab"), name: `Agent Platform (${m.name})`, family: "", @@ -920,7 +921,7 @@ const ProviderLimit = Schema.Struct({ }) export const Model = Schema.Struct({ - id: ProviderV2.ModelID, + id: ModelV2.ID, providerID: ProviderV2.ID, api: ProviderApiInfo, name: Schema.String, @@ -978,7 +979,7 @@ export function defaultModelIDs()("ProviderModelNotFoundError", { providerID: ProviderV2.ID, - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, suggestions: Schema.optional(Schema.Array(Schema.String)), cause: Schema.optional(Schema.Defect), }) { @@ -1018,7 +1019,7 @@ export interface Interface { readonly getProvider: (providerID: ProviderV2.ID) => Effect.Effect readonly getModel: ( providerID: ProviderV2.ID, - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, ) => Effect.Effect readonly getLanguage: (model: Model) => Effect.Effect readonly closest: ( @@ -1027,7 +1028,7 @@ export interface Interface { ) => Effect.Effect<{ providerID: ProviderV2.ID; modelID: string } | undefined> readonly getSmallModel: (providerID: ProviderV2.ID) => Effect.Effect readonly defaultModel: () => Effect.Effect< - { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }, + { providerID: ProviderV2.ID; modelID: ModelV2.ID }, DefaultModelError > } @@ -1080,7 +1081,7 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] { function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const base: Model = { - id: ProviderV2.ModelID.make(model.id), + id: ModelV2.ID.make(model.id), providerID: ProviderV2.ID.make(provider.id), name: model.name, family: model.family, @@ -1138,7 +1139,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { const base = fromModelsDevModel(provider, model) models[id] = { ...base, - id: ProviderV2.ModelID.make(id), + id: ModelV2.ID.make(id), name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`, cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost, options: opts.provider?.body @@ -1163,7 +1164,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { } } -function modelSuggestions(provider: Info | undefined, modelID: ProviderV2.ModelID, enableExperimentalModels: boolean) { +function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) { const available = provider ? Object.keys(provider.models).filter((id) => { const model = provider.models[id] @@ -1279,7 +1280,7 @@ export const layer = Layer.effect( id, { ...model, - id: ProviderV2.ModelID.make(id), + id: ModelV2.ID.make(id), providerID, }, ]), @@ -1314,7 +1315,7 @@ export const layer = Layer.effect( return existingModel?.name ?? modelID }) const parsedModel: Model = { - id: ProviderV2.ModelID.make(modelID), + id: ModelV2.ID.make(modelID), api: { id: apiID, npm: apiNpm, @@ -1703,7 +1704,7 @@ export const layer = Layer.effect( InstanceState.use(state, (s) => s.providers[providerID]), ) - const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID) { + const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderV2.ID, modelID: ModelV2.ID) { const s = yield* InstanceState.get(state) const provider = s.providers[providerID] if (!provider) { @@ -1792,7 +1793,7 @@ export const layer = Layer.effect( if (experimental.model) { return { ...experimental.model, - id: ProviderV2.ModelID.make(experimental.model.id), + id: ModelV2.ID.make(experimental.model.id), providerID: ProviderV2.ID.make(experimental.model.providerID), } } @@ -1846,16 +1847,16 @@ export const layer = Layer.effect( const s = yield* InstanceState.get(state) const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe( - Effect.map((x): { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }[] => { + Effect.map((x): { providerID: ProviderV2.ID; modelID: ModelV2.ID }[] => { if (!isRecord(x) || !Array.isArray(x.recent)) return [] return x.recent.flatMap((item) => { if (!isRecord(item)) return [] if (typeof item.providerID !== "string") return [] if (typeof item.modelID !== "string") return [] - return [{ providerID: ProviderV2.ID.make(item.providerID), modelID: ProviderV2.ModelID.make(item.modelID) }] + return [{ providerID: ProviderV2.ID.make(item.providerID), modelID: ModelV2.ID.make(item.modelID) }] }) }), - Effect.catch(() => Effect.succeed([] as { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }[])), + Effect.catch(() => Effect.succeed([] as { providerID: ProviderV2.ID; modelID: ModelV2.ID }[])), ) for (const entry of recent) { const provider = s.providers[entry.providerID] @@ -1904,7 +1905,7 @@ export function parseModel(model: string) { const [providerID, ...rest] = model.split("/") return { providerID: ProviderV2.ID.make(providerID), - modelID: ProviderV2.ModelID.make(rest.join("/")), + modelID: ModelV2.ID.make(rest.join("/")), } } diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 57b8b37d9..b80e57222 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -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]) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index c40a3bf00..83491400a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -16,6 +16,7 @@ import { import { described } from "./metadata" import { QueryBoolean } from "./query" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const ConsoleStateResponse = Schema.Struct({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), @@ -51,7 +52,7 @@ const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) export const ToolListQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, provider: ProviderV2.ID, - model: ProviderV2.ModelID, + model: ModelV2.ID, }) const WorktreeList = Schema.Array(Schema.String) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 4a24282a0..cd8499052 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -20,11 +20,14 @@ const SyncEventSchemas = EventV2.registry return [ Schema.Struct({ type: Schema.Literal("sync"), - name: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)), id: Schema.String, - seq: Schema.Finite, - aggregateID: Schema.Literal(definition.sync.aggregate), - data: definition.data, + syncEvent: Schema.Struct({ + type: Schema.Literal(EventV2.versionedType(definition.type, definition.sync.version)), + id: Schema.String, + seq: Schema.Finite, + aggregateID: Schema.String, + data: definition.data, + }), }).annotate({ identifier: `SyncEvent.${definition.type}` }), ] }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 5765126ef..959a303dc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -24,6 +24,7 @@ import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../ import { described } from "./metadata" import { QueryBoolean } from "./query" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const root = "/session" export const ListQuery = Schema.Struct({ @@ -57,13 +58,13 @@ export const UpdatePayload = Schema.Struct({ }) export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])) export const InitPayload = Schema.Struct({ - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, providerID: ProviderV2.ID, messageID: MessageID, }) export const SummarizePayload = Schema.Struct({ providerID: ProviderV2.ID, - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, auto: Schema.optional(Schema.Boolean), }) export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts deleted file mode 100644 index 9f0ba9274..000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts +++ /dev/null @@ -1,27 +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 { QuestionGroup, SessionQuestionGroup } from "./v2/question" - -export const V2Api = HttpApi.make("v2") - .add(SessionGroup) - .add(MessageGroup) - .add(ModelGroup) - .add(ProviderGroup) - .add(PermissionGroup) - .add(SessionPermissionGroup) - .add(PermissionSavedGroup) - .add(FileSystemGroup) - .add(QuestionGroup) - .add(SessionQuestionGroup) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts deleted file mode 100644 index 67fd4d8c0..000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { FileSystem } from "@opencode-ai/core/filesystem" -import { Effect } from "effect" -import { HttpApiBuilder } from "effect/unstable/httpapi" -import { InstanceHttpApi } from "../../api" - -export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) => - Effect.gen(function* () { - return handlers - .handle("read", (ctx) => FileSystem.Service.use((fs) => fs.read(ctx.query))) - .handle("list", (ctx) => FileSystem.Service.use((fs) => fs.list(ctx.query))) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index db6554590..43ee1e174 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -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()( }, ) {} -export class V2Authorization extends HttpApiMiddleware.Service()( - "@opencode/ExperimentalHttpApiV2Authorization", - { - error: UnauthorizedError, - }, -) {} - export class PtyConnectAuthorization extends HttpApiMiddleware.Service()( "@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" }) - }), - ), - ) - }), - ) - }), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index dfbb1a88b..1ce65cb8f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -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 { - 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, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index c714fd414..f0d7e0dfe 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -21,6 +21,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionEvent } from "@opencode-ai/core/session/event" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "session.compaction" }) @@ -201,7 +202,7 @@ export interface Interface { readonly create: (input: { sessionID: SessionID agent: string - model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID } + model: { providerID: ProviderV2.ID; modelID: ModelV2.ID } auto: boolean overflow?: boolean }) => Effect.Effect @@ -585,7 +586,7 @@ export const layer = Layer.effect( const create = Effect.fn("SessionCompaction.create")(function* (input: { sessionID: SessionID agent: string - model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID } + model: { providerID: ProviderV2.ID; modelID: ModelV2.ID } auto: boolean overflow?: boolean }) { diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index e5332992f..b641b7dd8 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -5,6 +5,7 @@ import { NonNegativeInt } from "@opencode-ai/core/schema" import { MessageError } from "./message-error" import { AuthError, OutputLengthError } from "./message-error" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" export { AuthError, OutputLengthError } from "./message-error" export const ToolCall = Schema.Struct({ @@ -120,7 +121,7 @@ export const Info = Schema.Struct({ assistant: Schema.optional( Schema.Struct({ system: Schema.Array(Schema.String), - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, providerID: ProviderV2.ID, path: Schema.Struct({ cwd: Schema.String, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1f9dfb047..1ac1efb1f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -241,7 +241,7 @@ export const layer = Layer.effect( session: Session.Info history: SessionV1.WithParts[] providerID: ProviderV2.ID - modelID: ProviderV2.ModelID + modelID: ModelV2.ID }) { if (input.session.parentID) return if (!Session.isDefaultTitle(input.session.title)) return @@ -653,7 +653,7 @@ export const layer = Layer.effect( const getModel = Effect.fn("SessionPrompt.getModel")(function* ( providerID: ProviderV2.ID, - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, sessionID: SessionID, ) { const exit = yield* provider.getModel(providerID, modelID).pipe(Effect.exit) @@ -681,7 +681,7 @@ export const layer = Layer.effect( if (current?.model) { return { providerID: ProviderV2.ID.make(current.model.providerID), - modelID: ProviderV2.ModelID.make(current.model.id), + modelID: ModelV2.ID.make(current.model.id), ...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}), } } @@ -1679,7 +1679,7 @@ export const defaultLayer = Layer.suspend(() => ) const ModelRef = Schema.Struct({ providerID: ProviderV2.ID, - modelID: ProviderV2.ModelID, + modelID: ModelV2.ID, }) export const PromptInput = Schema.Struct({ diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 4b577bc38..7e4e4fd4d 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -40,9 +40,10 @@ import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" -import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" +import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const log = Log.create({ service: "session" }) const runtime = makeRuntime(Database.Service, Database.defaultLayer) @@ -82,7 +83,7 @@ export function fromRow(row: SessionRow): Info { agent: row.agent ?? undefined, model: row.model ? { - id: ProviderV2.ModelID.make(row.model.id), + id: ModelV2.ID.make(row.model.id), providerID: ProviderV2.ID.make(row.model.providerID), variant: row.model.variant, } @@ -112,13 +113,6 @@ export function fromRow(row: SessionRow): Info { } } -function eventLocation(info: Pick) { - return { - directory: AbsolutePath.make(info.directory), - workspaceID: info.workspaceID, - } -} - export function toRow(info: Info) { return { id: info.id, @@ -209,7 +203,7 @@ const Revert = Schema.Struct({ }) const Model = Schema.Struct({ - id: ProviderV2.ModelID, + id: ModelV2.ID, providerID: ProviderV2.ID, variant: optionalOmitUndefined(Schema.String), }) @@ -544,20 +538,6 @@ export const layer: Layer.Layer< const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service - const locationForSession = Effect.fnUntraced(function* (sessionID: SessionID) { - const row = yield* db - .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get() - .pipe(Effect.orDie) - if (!row) return - return { - directory: AbsolutePath.make(row.directory), - workspaceID: row.workspaceID ?? undefined, - } - }) - const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID title?: string @@ -597,7 +577,6 @@ export const layer: Layer.Layer< yield* events.publish( SessionV1.Event.Created, { sessionID: result.id, info: result }, - { location: eventLocation(result) }, ) return result @@ -688,7 +667,6 @@ export const layer: Layer.Layer< yield* events.publish( SessionV1.Event.Deleted, { sessionID, info: session }, - { location: eventLocation(session) }, ) yield* events.remove(sessionID) } catch (e) { @@ -698,14 +676,12 @@ export const layer: Layer.Layer< const updateMessage = (msg: T): Effect.Effect => Effect.gen(function* () { - const location = yield* locationForSession(msg.sessionID) - yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg }, { location }) + yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg }) return msg }).pipe(Effect.withSpan("Session.updateMessage")) const updatePart = (part: T): Effect.Effect => Effect.gen(function* () { - const location = yield* locationForSession(part.sessionID) yield* events.publish( SessionV1.Event.PartUpdated, { @@ -713,7 +689,6 @@ export const layer: Layer.Layer< part: structuredClone(part), time: Date.now(), }, - { location }, ) return part }).pipe(Effect.withSpan("Session.updatePart")) @@ -819,7 +794,7 @@ export const layer: Layer.Layer< revert: info.revert === null ? undefined : (info.revert ?? current.revert), permission: info.permission === null ? undefined : (info.permission ?? current.permission), } as Info - yield* events.publish(SessionV1.Event.Updated, { sessionID, info: next }, { location: eventLocation(next) }) + yield* events.publish(SessionV1.Event.Updated, { sessionID, info: next }) }) const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) { @@ -917,14 +892,12 @@ export const layer: Layer.Layer< sessionID: SessionID messageID: MessageID }) { - const location = yield* locationForSession(input.sessionID) yield* events.publish( SessionV1.Event.MessageRemoved, { sessionID: input.sessionID, messageID: input.messageID, }, - { location }, ) return input.messageID }) @@ -934,7 +907,6 @@ export const layer: Layer.Layer< messageID: MessageID partID: PartID }) { - const location = yield* locationForSession(input.sessionID) yield* events.publish( SessionV1.Event.PartRemoved, { @@ -942,7 +914,6 @@ export const layer: Layer.Layer< messageID: input.messageID, partID: input.partID, }, - { location }, ) return input.partID }) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index fe8814816..34749795f 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -20,6 +20,7 @@ import { PartID } from "./schema" import { Log } from "@opencode-ai/core/util/log" import { EffectBridge } from "@/effect/bridge" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const log = Log.create({ service: "session.tools" }) @@ -75,7 +76,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) for (const item of yield* registry.tools({ - modelID: ProviderV2.ModelID.make(input.model.api.id), + modelID: ModelV2.ID.make(input.model.api.id), providerID: input.model.providerID, agent: input.agent, })) { diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 665b62898..37787843e 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -16,6 +16,7 @@ import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" import { SessionShareTable } from "@opencode-ai/core/share/sql" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { EventV2 } from "@opencode-ai/core/event" const log = Log.create({ service: "share-next" }) @@ -284,7 +285,7 @@ export const layer = Layer.effect( .map((item) => [`${item.providerID}/${item.modelID}`, item] as const), ).values(), ), - (item) => provider.getModel(ProviderV2.ID.make(item.providerID), ProviderV2.ModelID.make(item.modelID)), + (item) => provider.getModel(ProviderV2.ID.make(item.providerID), ModelV2.ID.make(item.modelID)), { concurrency: 8 }, ) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 34e5edba8..7a101658a 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -15,7 +15,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { Glob } from "@opencode-ai/core/util/glob" import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" -import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } import { isRecord } from "@/util/record" const log = Log.create({ service: "skill" }) @@ -33,6 +32,9 @@ const SKILL_PATTERN = "**/SKILL.md" const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." +const CUSTOMIZE_OPENCODE_SKILL_BODY = await Bun.file( + new URL("../../../core/src/plugin/skill/customize-opencode.md", import.meta.url), +).text() export const Info = Schema.Struct({ name: Schema.String, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index b639277d8..68b324523 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -51,6 +51,7 @@ import { Reference } from "@/reference/reference" import { BackgroundJob } from "@/background/job" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const log = Log.create({ service: "tool.registry" }) @@ -74,7 +75,7 @@ export interface Interface { readonly named: () => Effect.Effect<{ task: TaskDef; read: ReadDef }> readonly tools: (model: { providerID: ProviderV2.ID - modelID: ProviderV2.ModelID + modelID: ModelV2.ID agent: Agent.Info }) => Effect.Effect } diff --git a/packages/opencode/test/acp/directory.test.ts b/packages/opencode/test/acp/directory.test.ts index 5cc48d78f..e274db85c 100644 --- a/packages/opencode/test/acp/directory.test.ts +++ b/packages/opencode/test/acp/directory.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { Directory } from "@/acp/directory" import { Command } from "@/command" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" import { Effect, Layer } from "effect" import { it } from "../lib/effect" @@ -14,7 +15,7 @@ const command = (name: string): Command.Info => ({ }) const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({ - id: ProviderV2.ModelID.make(id), + id: ModelV2.ID.make(id), providerID, api: { id, @@ -50,7 +51,7 @@ const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.Model const snapshot = (directory: string) => { const providerID = ProviderV2.ID.make(`provider-${directory}`) - const modelID = ProviderV2.ModelID.make(`model-${directory}`) + const modelID = ModelV2.ID.make(`model-${directory}`) const providers = { [providerID]: { id: providerID, @@ -63,7 +64,7 @@ const snapshot = (directory: string) => { low: { reasoningEffort: "low" }, high: { reasoningEffort: "high" }, }), - [ProviderV2.ModelID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`), + [ModelV2.ID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`), }, }, } satisfies Record @@ -148,7 +149,7 @@ describe("ACP directory snapshot", () => { low: { reasoningEffort: "low" }, high: { reasoningEffort: "high" }, }) - expect(directory.variants(alpha, { ...model, modelID: ProviderV2.ModelID.make("missing") })).toBeUndefined() + expect(directory.variants(alpha, { ...model, modelID: ModelV2.ID.make("missing") })).toBeUndefined() }).pipe(Effect.provide(fakeLayer([]))), ) diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index e5f1bc64c..2a189293e 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -12,6 +12,7 @@ import type { } from "@agentclientprotocol/sdk" import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { Effect } from "effect" import * as ACPService from "@/acp/service" import * as ACPError from "@/acp/error" @@ -19,9 +20,9 @@ import { UsageService } from "@/acp/usage" import type { Provider } from "@/provider/provider" const providerID = ProviderV2.ID.make("test") -const modelID = ProviderV2.ModelID.make("test-model") -const configuredModelID = ProviderV2.ModelID.make("configured-model") -const secondModelID = ProviderV2.ModelID.make("second-model") +const modelID = ModelV2.ID.make("test-model") +const configuredModelID = ModelV2.ID.make("configured-model") +const secondModelID = ModelV2.ID.make("second-model") const provider: Provider.Info = { id: providerID, diff --git a/packages/opencode/test/acp/session.test.ts b/packages/opencode/test/acp/session.test.ts index a7801218e..c3d41ef08 100644 --- a/packages/opencode/test/acp/session.test.ts +++ b/packages/opencode/test/acp/session.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import type { McpServer } from "@agentclientprotocol/sdk" import { Effect } from "effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import * as ACPError from "@/acp/error" import * as ACPSession from "@/acp/session" import { testEffect } from "../lib/effect" @@ -10,7 +11,7 @@ const sessionTest = testEffect(ACPSession.defaultLayer) const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({ providerID: ProviderV2.ID.make(providerID), - modelID: ProviderV2.ModelID.make(modelID), + modelID: ModelV2.ID.make(modelID), }) const mcpServer: McpServer = { diff --git a/packages/opencode/test/acp/usage.test.ts b/packages/opencode/test/acp/usage.test.ts index 0366f2321..d2ff139c5 100644 --- a/packages/opencode/test/acp/usage.test.ts +++ b/packages/opencode/test/acp/usage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import type { SessionNotification } from "@agentclientprotocol/sdk" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { UsageService } from "@/acp/usage" import { Provider } from "@/provider/provider" import { Effect, Layer } from "effect" @@ -41,7 +42,7 @@ const assistantWithoutProvider = (): UsageService.SessionMessage => ({ }, }) -const model = (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID, context: number): Provider.Model => ({ +const model = (providerID: ProviderV2.ID, modelID: ModelV2.ID, context: number): Provider.Model => ({ id: modelID, providerID, api: { @@ -77,7 +78,7 @@ const model = (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID, context: const providers = (context = 128_000): Record => { const providerID = ProviderV2.ID.make("anthropic") - const modelID = ProviderV2.ModelID.make("claude-sonnet") + const modelID = ModelV2.ID.make("claude-sonnet") return { [providerID]: { id: providerID, @@ -179,12 +180,12 @@ describe("acp usage", () => { const first = yield* usage.contextLimit({ directory: "/workspace", providerID: ProviderV2.ID.make("anthropic"), - modelID: ProviderV2.ModelID.make("claude-sonnet"), + modelID: ModelV2.ID.make("claude-sonnet"), }) const second = yield* usage.contextLimit({ directory: "/workspace", providerID: ProviderV2.ID.make("anthropic"), - modelID: ProviderV2.ModelID.make("claude-sonnet"), + modelID: ModelV2.ID.make("claude-sonnet"), }) expect(first).toBe(200_000) diff --git a/packages/opencode/test/fake/provider.ts b/packages/opencode/test/fake/provider.ts index e90bde29e..1dbfa6fa7 100644 --- a/packages/opencode/test/fake/provider.ts +++ b/packages/opencode/test/fake/provider.ts @@ -1,10 +1,11 @@ import { Effect, Layer } from "effect" import { Provider } from "@/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" export namespace ProviderTest { export function model(override: Partial = {}): Provider.Model { - const id = override.id ?? ProviderV2.ModelID.make("gpt-5.2") + const id = override.id ?? ModelV2.ID.make("gpt-5.2") const providerID = override.providerID ?? ProviderV2.ID.make("openai") return { id, diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 453969420..7bd9e3352 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -18,6 +18,7 @@ import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), @@ -75,7 +76,7 @@ const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransfo { model: { providerID: ProviderV2.ID.anthropic, - modelID: ProviderV2.ModelID.make("claude-sonnet-4-6"), + modelID: ModelV2.ID.make("claude-sonnet-4-6"), }, }, out, diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 5533fba6d..7cbdb1e1c 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -10,6 +10,7 @@ import { Provider } from "@/provider/provider" import { disposeAllInstances } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer)) @@ -113,7 +114,7 @@ it.instance( () => Effect.gen(function* () { yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token") - const model = yield* Provider.use.getModel(ProviderV2.ID.amazonBedrock, ProviderV2.ModelID.make("openai.gpt-5.5")) + const model = yield* Provider.use.getModel(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")) const language = yield* Provider.use.getLanguage(model) expect((language as { provider: string }).provider).toBe("bedrock-mantle.responses") expect((language as { modelId: string }).modelId).toBe("openai.gpt-5.5") @@ -143,7 +144,7 @@ it.instance( yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token") const model = yield* Provider.use.getModel( ProviderV2.ID.amazonBedrock, - ProviderV2.ModelID.make("openai.gpt-oss-safeguard-120b"), + ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), ) const language = yield* Provider.use.getLanguage(model) expect((language as { provider: string }).provider).toBe("bedrock-mantle.chat") diff --git a/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts index cf18e842f..f062868c4 100644 --- a/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts +++ b/packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts @@ -14,6 +14,7 @@ import { createUnified } from "ai-gateway-provider/providers/unified" import { ProviderTransform } from "@/provider/transform" import type * as Provider from "@/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" type Captured = { url: string; outerBody: unknown } type ProviderOptions = Record> @@ -56,7 +57,7 @@ afterEach(() => { }) const cfModel = (apiId: string, releaseDate = "2026-03-05"): Provider.Model => ({ - id: ProviderV2.ModelID.make(`cloudflare-ai-gateway/${apiId}`), + id: ModelV2.ID.make(`cloudflare-ai-gateway/${apiId}`), providerID: ProviderV2.ID.make("cloudflare-ai-gateway"), name: apiId, api: { id: apiId, url: "https://gateway.ai.cloudflare.com/v1/compat", npm: "ai-gateway-provider" }, diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index a3caf4fd8..b9d8d4530 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -4,6 +4,7 @@ import { streamText } from "ai" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { testProviderConfig } from "../lib/test-provider" @@ -31,7 +32,7 @@ it.live("headerTimeout does not abort delayed SSE body after headers arrive", () () => Effect.gen(function* () { const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model")) + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model")) const result = streamText({ model: yield* provider.getLanguage(model), messages: [{ role: "user", content: "hello" }], @@ -55,7 +56,7 @@ it.live("chunkTimeout raises a response stream error when SSE body stalls", () = () => Effect.gen(function* () { const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model")) + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model")) const result = streamText({ model: yield* provider.getLanguage(model), onError() {}, @@ -89,7 +90,7 @@ it.live("headerTimeout aborts when response headers do not arrive", () => () => Effect.gen(function* () { const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model")) + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model")) const result = streamText({ model: yield* provider.getLanguage(model), onError() {}, @@ -121,7 +122,7 @@ it.live("headerTimeout is opt-in for non-OpenAI providers", () => () => Effect.gen(function* () { const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderV2.ID.make("test"), ProviderV2.ModelID.make("test-model")) + const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model")) const result = streamText({ model: yield* provider.getLanguage(model), messages: [{ role: "user", content: "hello" }], diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index ecccd97aa..aac08f262 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -19,6 +19,7 @@ import { Filesystem } from "@/util/filesystem" import { InstanceLayer } from "@/project/instance-layer" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const originalEnv = new Map() @@ -293,7 +294,7 @@ it.instance("getModel returns model for valid provider/model", () => Effect.gen(function* () { yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") const provider = yield* Provider.Service - const model = yield* provider.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonnet-4-20250514")) + const model = yield* provider.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() expect(String(model.providerID)).toBe("anthropic") expect(String(model.id)).toBe("claude-sonnet-4-20250514") @@ -306,7 +307,7 @@ it.instance("getModel throws ModelNotFoundError for invalid model", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const exit = yield* Provider.use - .getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("nonexistent-model")) + .getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("nonexistent-model")) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") }), @@ -315,7 +316,7 @@ it.instance("getModel throws ModelNotFoundError for invalid model", () => it.instance("getModel throws ModelNotFoundError for invalid provider", () => Effect.gen(function* () { const exit = yield* Provider.use - .getModel(ProviderV2.ID.make("nonexistent-provider"), ProviderV2.ModelID.make("some-model")) + .getModel(ProviderV2.ID.make("nonexistent-provider"), ModelV2.ID.make("some-model")) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") }), @@ -464,7 +465,7 @@ it.instance( const providers = yield* list expect(providers[ProviderV2.ID.anthropic].models["my-sonnet"]).toBeDefined() - const model = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("my-sonnet")) + const model = yield* Provider.use.getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("my-sonnet")) expect(model).toBeDefined() expect(String(model.id)).toBe("my-sonnet") expect(model.name).toBe("My Sonnet Alias") @@ -981,11 +982,11 @@ it.instance("getModel returns consistent results", () => yield* set("ANTHROPIC_API_KEY", "test-api-key") const model1 = yield* Provider.use.getModel( ProviderV2.ID.anthropic, - ProviderV2.ModelID.make("claude-sonnet-4-20250514"), + ModelV2.ID.make("claude-sonnet-4-20250514"), ) const model2 = yield* Provider.use.getModel( ProviderV2.ID.anthropic, - ProviderV2.ModelID.make("claude-sonnet-4-20250514"), + ModelV2.ID.make("claude-sonnet-4-20250514"), ) expect(model1.providerID).toEqual(model2.providerID) expect(model1.id).toEqual(model2.id) @@ -1017,7 +1018,7 @@ it.instance("ModelNotFoundError includes suggestions for typos", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const error = yield* Provider.use - .getModel(ProviderV2.ID.anthropic, ProviderV2.ModelID.make("claude-sonet-4")) + .getModel(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonet-4")) .pipe(Effect.flip) expect(error.suggestions).toBeDefined() expect((error.suggestions ?? []).length).toBeGreaterThan(0) @@ -1028,7 +1029,7 @@ it.instance("ModelNotFoundError for provider includes suggestions", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") const error = yield* Provider.use - .getModel(ProviderV2.ID.make("antropic"), ProviderV2.ModelID.make("claude-sonnet-4")) + .getModel(ProviderV2.ID.make("antropic"), ModelV2.ID.make("claude-sonnet-4")) .pipe(Effect.flip) expect(error.suggestions).toBeDefined() expect(error.suggestions).toContain("anthropic") @@ -1039,7 +1040,7 @@ it.instance("ModelNotFoundError suggests catalog models for unloaded providers", Effect.gen(function* () { yield* remove("OPENCODE_API_KEY") const error = yield* Provider.use - .getModel(ProviderV2.ID.opencode, ProviderV2.ModelID.make("claude-haiku-fake-model")) + .getModel(ProviderV2.ID.opencode, ModelV2.ID.make("claude-haiku-fake-model")) .pipe(Effect.flip) if (!Provider.ModelNotFoundError.isInstance(error)) throw error expect(error.suggestions ?? []).toContain("claude-haiku-4-5") @@ -1577,7 +1578,7 @@ it.instance("Google Vertex: uses REP endpoint for Claude continental multi-regio const provider = yield* Provider.Service const model = yield* provider.getModel( ProviderV2.ID.make("google-vertex"), - ProviderV2.ModelID.make("claude-sonnet-4-6@default"), + ModelV2.ID.make("claude-sonnet-4-6@default"), ) const language = yield* provider.getLanguage(model) expect(languageBaseURL(language)).toBe( @@ -1593,7 +1594,7 @@ it.instance("Google Vertex Anthropic: uses REP endpoint for continental multi-re const provider = yield* Provider.Service const model = yield* provider.getModel( ProviderV2.ID.make("google-vertex-anthropic"), - ProviderV2.ModelID.make("claude-sonnet-4-6@default"), + ModelV2.ID.make("claude-sonnet-4-6@default"), ) const language = yield* provider.getLanguage(model) expect(languageBaseURL(language)).toBe( @@ -1609,7 +1610,7 @@ it.instance("Google Vertex: keeps regional Claude endpoints unchanged", () => const provider = yield* Provider.Service const model = yield* provider.getModel( ProviderV2.ID.make("google-vertex"), - ProviderV2.ModelID.make("claude-sonnet-4-6@default"), + ModelV2.ID.make("claude-sonnet-4-6@default"), ) const language = yield* provider.getLanguage(model) expect(languageBaseURL(language)).toBe( @@ -1700,13 +1701,13 @@ it.effect("plugin config providers persist after instance dispose", () => const first = yield* loadAndList expect(first[ProviderV2.ID.make("demo")]).toBeDefined() - expect(first[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")]).toBeDefined() + expect(first[ProviderV2.ID.make("demo")].models[ModelV2.ID.make("chat")]).toBeDefined() yield* Effect.promise(() => disposeAllInstances()) const second = yield* loadAndList expect(second[ProviderV2.ID.make("demo")]).toBeDefined() - expect(second[ProviderV2.ID.make("demo")].models[ProviderV2.ModelID.make("chat")]).toBeDefined() + expect(second[ProviderV2.ID.make("demo")].models[ModelV2.ID.make("chat")]).toBeDefined() }).pipe(provideMultiInstance), ) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 46404dec8..414b66412 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { ProviderTransform } from "@/provider/transform" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" describe("ProviderTransform.options - setCacheKey", () => { const sessionID = "test-session-123" @@ -1123,7 +1124,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { const result = ProviderTransform.message( msgs, { - id: ProviderV2.ModelID.make("deepseek/deepseek-chat"), + id: ModelV2.ID.make("deepseek/deepseek-chat"), providerID: ProviderV2.ID.make("deepseek"), api: { id: "deepseek-chat", @@ -1185,7 +1186,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { const result = ProviderTransform.message( msgs, { - id: ProviderV2.ModelID.make("openai/gpt-4"), + id: ModelV2.ID.make("openai/gpt-4"), providerID: ProviderV2.ID.make("openai"), api: { id: "gpt-4", diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 88356504e..a8059aca3 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -43,6 +43,22 @@ function cursor(input: Record) { return Buffer.from(JSON.stringify(input)).toString("base64url") } +function data(validate: (value: any) => void) { + return (body: any) => { + object(body) + validate(body.data) + } +} + +function locationData(validate: (value: any) => void) { + return (body: any) => { + object(body) + object(body.location) + object(body.location.project) + validate(body.data) + } +} + const scenarios: Scenario[] = [ http.protected .get("/global/health", "global.health") @@ -609,20 +625,48 @@ const scenarios: Scenario[] = [ check(auth.test === undefined, "auth remove should delete provider from isolated auth file") }), ), - http.protected.get("/api/model", "v2.model.list").json(200, array), - http.protected.get("/api/provider", "v2.provider.list").json(200, array), + 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)), + http.protected.get("/api/skill", "v2.skill.list").json(200, locationData(array)), + http.protected + .get("/api/event", "v2.event.subscribe") + .stream() + .status( + 200, + (ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "v2 event should be an SSE stream") + check(result.text.includes("server.connected"), "v2 event should emit initial connection event") + check(!!ctx.directory && result.text.includes(ctx.directory), "v2 event should include the resolved location") + }), + "status", + ), http.protected .get("/api/fs/read", "v2.fs.read") .seeded((ctx) => ctx.file("hello.txt", "hello\n")) .at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() })) - .json(200, object), - http.protected.get("/api/fs/list", "v2.fs.list").json(200, array), + .json(200, locationData(object)), + http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)), http.protected .get("/api/provider/{providerID}", "v2.provider.get") .at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() })) .json(404, object, "status"), - http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, array), - http.protected.get("/api/question/request", "v2.question.request.list").json(200, array), + http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, (body) => { + object(body) + object(body.location) + array(body.data) + }), + http.protected.get("/api/question/request", "v2.question.request.list").json(200, (body) => { + object(body) + object(body.location) + array(body.data) + }), http.protected .get("/api/session/{sessionID}/permission/request", "v2.session.permission.list") .seeded((ctx) => ctx.session({ title: "Permission list owner" })) @@ -630,7 +674,7 @@ const scenarios: Scenario[] = [ path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }), headers: ctx.headers(), })) - .json(200, array), + .json(200, data(array)), http.protected .post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply") .seeded((ctx) => ctx.session({ title: "Permission owner" })) @@ -666,7 +710,10 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(404, object, "status"), - http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array), + http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, (body) => { + object(body) + array(body.data) + }), http.protected .delete("/api/permission/saved/{id}", "v2.permission.saved.remove") .at((ctx) => ({ path: route("/api/permission/saved/{id}", { id: "psv_httpapi_missing" }), headers: ctx.headers() })) @@ -678,7 +725,7 @@ const scenarios: Scenario[] = [ 200, (body) => { object(body) - array(body.items) + array(body.data) object(body.cursor) }, "none", @@ -701,7 +748,7 @@ const scenarios: Scenario[] = [ 200, (body) => { object(body) - array(body.items) + array(body.data) object(body.cursor) }, "none", @@ -723,7 +770,7 @@ const scenarios: Scenario[] = [ 200, (body) => { object(body) - array(body.items) + array(body.data) object(body.cursor) }, "none", diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index dade95438..b7ad6d620 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -12,6 +12,7 @@ import { original } from "./environment" import { runtime } from "./runtime" import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" export function runScenario(options: Options) { return (scenario: Scenario) => { @@ -153,7 +154,7 @@ function withContext( agent: "build", model: { providerID: ProviderV2.ID.opencode, - modelID: ProviderV2.ModelID.make("test"), + modelID: ModelV2.ID.make("test"), }, } const part: SessionV1.TextPart = { diff --git a/packages/opencode/test/server/httpapi-public-catalog-redaction.test.ts b/packages/opencode/test/server/httpapi-public-catalog-redaction.test.ts deleted file mode 100644 index 90a10bcc8..000000000 --- a/packages/opencode/test/server/httpapi-public-catalog-redaction.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { ModelV2 } from "@opencode-ai/core/model" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { Schema } from "effect" -import { OpenApi } from "effect/unstable/httpapi" -import { PublicApi } from "../../src/server/routes/instance/httpapi/public" - -type OpenApiSchema = { - readonly $ref?: string - readonly items?: OpenApiSchema - readonly properties?: Record -} - -type OpenApiSpec = { - readonly components?: { readonly schemas?: Record } - readonly paths: Record< - string, - { - readonly get?: { - readonly responses?: Record }> - } - } - > -} - -function responseSchema(spec: OpenApiSpec, path: string) { - return spec.paths[path]?.get?.responses?.["200"]?.content?.["application/json"]?.schema -} - -function componentName(ref: string | undefined) { - return ref?.replace("#/components/schemas/", "") -} - -describe("PublicApi v2 catalog redaction", () => { - test("routes use redacted provider and model DTO schemas", () => { - const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec - const provider = responseSchema(spec, "/api/provider/{providerID}") - const providers = responseSchema(spec, "/api/provider") - const models = responseSchema(spec, "/api/model") - - expect(componentName(provider?.$ref)).toBe("ProviderV2PublicInfo") - expect(componentName(providers?.items?.$ref)).toBe("ProviderV2PublicInfo") - expect(componentName(models?.items?.$ref)).toBe("ModelV2PublicInfo") - - const providerProperties = spec.components?.schemas?.ProviderV2PublicInfo?.properties - const modelProperties = spec.components?.schemas?.ModelV2PublicInfo?.properties - expect(providerProperties).not.toHaveProperty("request") - expect(modelProperties).not.toHaveProperty("request") - expect(JSON.stringify(providerProperties)).not.toMatch(/settings|headers|body|data/) - expect(JSON.stringify(modelProperties)).not.toMatch(/settings|headers|body/) - }) - - test("DTOs sanitize provider and model API URLs", () => { - const providerID = ProviderV2.ID.make("test") - const providers = [ - new ProviderV2.Info({ - ...ProviderV2.Info.empty(providerID), - api: { - type: "native", - url: "https://provider-user:provider-password@example.com:8443/provider/v1?api_key=provider-secret#fragment", - settings: {}, - }, - }), - new ProviderV2.Info({ - ...ProviderV2.Info.empty(providerID), - api: { - type: "aisdk", - package: "@ai-sdk/openai", - url: "https://provider-aisdk-user:provider-aisdk-password@example.com:8444/provider/aisdk?api_key=provider-aisdk-secret#fragment", - }, - }), - ].map((provider) => Schema.encodeSync(ProviderV2.PublicInfo)(ProviderV2.toPublic(provider))) - const models = [ - new ModelV2.Info({ - ...ModelV2.Info.empty(providerID, ModelV2.ID.make("native")), - api: { - id: ModelV2.ID.make("native"), - type: "native", - url: "https://native-user:native-password@example.com:9443/native/v1?api_key=native-secret#fragment", - settings: {}, - }, - }), - new ModelV2.Info({ - ...ModelV2.Info.empty(providerID, ModelV2.ID.make("aisdk")), - api: { - id: ModelV2.ID.make("aisdk"), - type: "aisdk", - package: "@ai-sdk/openai", - url: "https://aisdk-user:aisdk-password@example.com:10443/aisdk/v1?api_key=aisdk-secret#fragment", - }, - }), - ].map((model) => Schema.encodeSync(ModelV2.PublicInfo)(ModelV2.toPublic(model))) - - expect(providers.map((provider) => provider.api)).toEqual([ - { type: "native", url: "https://example.com:8443" }, - { type: "aisdk", package: "@ai-sdk/openai", url: "https://example.com:8444" }, - ]) - expect(models.map((model) => model.api)).toEqual([ - { id: "native", type: "native", url: "https://example.com:9443" }, - { id: "aisdk", type: "aisdk", package: "@ai-sdk/openai", url: "https://example.com:10443" }, - ]) - expect(JSON.stringify({ providers, models })).not.toMatch(/user|password|api_key|secret|fragment/) - }) - - test("DTOs omit malformed API URLs", () => { - const providerID = ProviderV2.ID.make("test") - const provider = Schema.encodeSync(ProviderV2.PublicInfo)( - ProviderV2.toPublic( - new ProviderV2.Info({ - ...ProviderV2.Info.empty(providerID), - api: { type: "native", url: "not a url?api_key=provider-secret", settings: {} }, - }), - ), - ) - const modelID = ModelV2.ID.make("aisdk") - const model = Schema.encodeSync(ModelV2.PublicInfo)( - ModelV2.toPublic( - new ModelV2.Info({ - ...ModelV2.Info.empty(providerID, modelID), - api: { id: modelID, type: "aisdk", package: "@ai-sdk/openai", url: "model-secret" }, - }), - ), - ) - - expect(provider.api).toEqual({ type: "native" }) - expect(model.api).toEqual({ id: "aisdk", type: "aisdk", package: "@ai-sdk/openai" }) - expect(JSON.stringify({ provider, model })).not.toMatch(/secret|api_key/) - }) -}) diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index 6e1c5eae3..e835debe0 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -3,7 +3,14 @@ import { OpenApi } from "effect/unstable/httpapi" import { PublicApi } from "../../src/server/routes/instance/httpapi/public" type Method = "get" | "post" | "put" | "delete" | "patch" -type OpenApiSchema = { readonly $ref?: string; readonly anyOf?: ReadonlyArray } +type OpenApiSchema = { + readonly $ref?: string + readonly anyOf?: ReadonlyArray + readonly type?: string + readonly enum?: readonly unknown[] + readonly properties?: Record + readonly required?: readonly string[] +} type OpenApiResponse = { readonly description?: string readonly content?: Record @@ -20,7 +27,10 @@ type OpenApiOperation = { readonly security?: unknown } type OpenApiPathItem = Partial> -type OpenApiSpec = { readonly paths: Record } +type OpenApiSpec = { + readonly paths: Record + readonly components: { readonly schemas: Record } +} const methods = ["get", "post", "put", "delete", "patch"] as const @@ -56,6 +66,23 @@ function isBuiltInEndpointError(name: string) { } describe("PublicApi OpenAPI v2 errors", () => { + test("documents nested legacy global sync events", () => { + const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec + const schema = spec.components.schemas.SyncEventSessionCreated + + expect(schema?.required).toEqual(["type", "id", "syncEvent"]) + expect(schema?.properties?.type?.enum).toEqual(["sync"]) + expect(schema?.properties?.syncEvent).toMatchObject({ + required: ["type", "id", "seq", "aggregateID", "data"], + properties: { + type: { enum: ["session.created.1"] }, + id: { type: "string" }, + seq: { type: "number" }, + aggregateID: { type: "string" }, + }, + }) + }) + test("preserves /api auth responses", () => { const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index 064bcc97e..cdaf554f0 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -24,7 +24,7 @@ import { SessionPaths, } from "../../src/server/routes/instance/httpapi/groups/session" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" -import { 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" diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts index f217bf844..c650b3772 100644 --- a/packages/opencode/test/server/httpapi-schema-error-body.test.ts +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -13,6 +13,7 @@ import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) @@ -32,7 +33,7 @@ const seedCorruptStepFinishPart = Effect.gen(function* () { role: "user", sessionID: info.id, agent: "build", - model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") }, + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, time: { created: Date.now() }, }) const partID = PartID.ascending() diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 4e8ba6c67..f5ad8e59f 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -25,6 +25,7 @@ import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixt import { awaitWithTimeout, testEffect } from "../lib/effect" import { testProviderConfig } from "../lib/test-provider" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { Database } from "@opencode-ai/core/database/database" import { httpApiLayer } from "./httpapi-layer" @@ -310,7 +311,7 @@ function seedMessage(directory: string, sessionID: string) { role: "user", time: { created: Date.now() }, agent: "test", - model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") }, + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, tools: {}, } satisfies SessionV1.User) const part = yield* svc.updatePart({ @@ -392,7 +393,7 @@ describe("HttpApi SDK", () => { const url = new URL(request!.url) expect(file.response.status).toBe(200) - expect(file.data).toMatchObject({ content: "hello" }) + expect(file.data).toMatchObject({ data: { content: "hello" } }) expect(url.searchParams.get("directory")).toBe(directory) expect(url.searchParams.get("workspace")).toBe(workspaceID) expect(url.searchParams.get("location[directory]")).toBe(directory) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 0394d0730..713511fac 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -88,7 +88,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) { role: "user", sessionID, agent: "build", - model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") }, + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, time: { created: Date.now() }, }) const part = yield* svc.updatePart({ @@ -391,8 +391,9 @@ describe("session HttpApi", () => { yield* insertLegacyAssistantMessage(parent.id) expect( - (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })) - .items, + (yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { + headers, + })).data, ).toMatchObject([{ type: "assistant" }]) }), { git: true, config: { formatter: false, lsp: false } }, @@ -456,7 +457,7 @@ describe("session HttpApi", () => { })}`, { headers }, ) - const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next + const sessionCursor = (yield* json<{ data: Session.Info[]; cursor: { next?: string } }>(sessionPage)).cursor.next expect(sessionCursor).toBeTruthy() expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({ order: "asc", @@ -483,10 +484,10 @@ describe("session HttpApi", () => { }) const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers }) - const messageBody = yield* json<{ items: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage) + const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage) const messageCursor = messageBody.cursor.next expect(messageCursor).toBeTruthy() - expect(messageBody.items.map((message) => message.id)).toEqual([secondMessage.id]) + expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id]) expect(JSON.parse(Buffer.from(messageCursor!, "base64url").toString("utf8"))).toEqual({ id: secondMessage.id, order: "desc", @@ -497,7 +498,7 @@ describe("session HttpApi", () => { headers, }) expect( - (yield* json<{ items: SessionMessage.Message[] }>(nextMessagePage)).items.map((message) => message.id), + (yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id), ).toEqual([firstMessage.id]) const legacyMessageCursor = Buffer.from( @@ -507,7 +508,7 @@ describe("session HttpApi", () => { headers, }) expect( - (yield* json<{ items: SessionMessage.Message[] }>(legacyMessagePage)).items.map((message) => message.id), + (yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id), ).toEqual([firstMessage.id]) const messageCursorWithOrder = yield* request( @@ -587,17 +588,17 @@ describe("session HttpApi", () => { const first = yield* recordPrompt() const retried = yield* recordPrompt() type PromptBody = { id: string; type: string; text: string } - const firstBody = yield* json(first) - const retriedBody = yield* json(retried) + const firstBody = yield* json<{ data: PromptBody }>(first) + const retriedBody = yield* json<{ data: PromptBody }>(retried) expect(first.status).toBe(200) expect(retried.status).toBe(200) expect(retriedBody).toEqual(firstBody) - expect(firstBody).toMatchObject({ type: "user", text: "hello" }) + expect(firstBody).toMatchObject({ data: { type: "user", text: "hello" } }) - const messages = yield* requestJson<{ items: PromptBody[] }>(`/api/session/${session.id}/message`, { + const messages = yield* requestJson<{ data: PromptBody[] }>(`/api/session/${session.id}/message`, { headers, }) - expect(messages.items).toHaveLength(0) + expect(messages.data).toHaveLength(0) const admitted = yield* Database.Service.use(({ db }) => db .select() diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts new file mode 100644 index 000000000..481e05b1c --- /dev/null +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Context, Schema } from "effect" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import * as Log from "@opencode-ai/core/util/log" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const context = Context.empty() as Context.Context + +function request(route: string, directory: string, init: RequestInit = {}) { + const headers = new Headers(init.headers) + headers.set("x-opencode-directory", directory) + return HttpApiApp.webHandler().handler( + new Request(`http://localhost${route}`, { + ...init, + headers, + }), + context, + ) +} + +const Event = Schema.Struct({ + id: Schema.String, + type: Schema.String, + location: Schema.Struct({ + directory: Schema.String, + project: Schema.Struct({ id: Schema.String, directory: Schema.String }), + }), + data: Schema.Unknown, +}) + +async function readEvent(reader: ReadableStreamDefaultReader) { + const value = await reader.read() + if (value.done) throw new Error("event stream closed") + return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, ""))) +} + +async function readEventType(reader: ReadableStreamDefaultReader, type: string) { + for (let index = 0; index < 20; index++) { + const event = await readEvent(reader) + if (event.type === type) return event + } + throw new Error(`timed out waiting for ${type}`) +} + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("v2 location HttpApi", () => { + test("returns command and skill snapshots with resolved locations", async () => { + await using tmp = await tmpdir({ git: true }) + + for (const route of ["/api/command", "/api/skill"]) { + const response = await request(route, tmp.path) + expect(response.status).toBe(200) + const body = (await response.json()) as { location: { directory: string; project: { id: string } }; data: unknown } + expect(body.data).toBeArray() + expect(body.location.directory).toBe(tmp.path) + expect(body.location.project.id).toBeTruthy() + } + }) + + test("streams native EventV2 payloads with resolved locations", async () => { + await using tmp = await tmpdir({ git: true }) + const response = await request("/api/event", tmp.path) + const reader = response.body!.getReader() + expect((await readEvent(reader)).type).toBe("server.connected") + + const created = await request("/session", tmp.path, { method: "POST" }) + expect(created.status).toBe(200) + expect(await readEventType(reader, "session.created")).toMatchObject({ + type: "session.created", + location: { directory: tmp.path, project: { directory: tmp.path } }, + data: { sessionID: expect.any(String) }, + }) + await reader.cancel() + }) +}) diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts index e79f655fb..b23726965 100644 --- a/packages/opencode/test/server/negative-tokens-regression.test.ts +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -18,6 +18,7 @@ import { resetDatabase } from "../fixture/db" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) @@ -31,7 +32,7 @@ function seedNegativeTokenSession() { role: "user", sessionID: info.id, agent: "build", - model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") }, + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, time: { created: Date.now() }, }) const partID = PartID.ascending() diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index 92970b755..b83b63dd6 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -18,6 +18,7 @@ import { Storage } from "@/storage/storage" import { SessionV1 } from "@opencode-ai/core/v1/session" import { MessageID } from "@/session/schema" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -79,7 +80,7 @@ describe("session diff with missing patch (#26574)", () => { role: "user", time: { created: Date.now() }, agent: "build", - model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("model") }, + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") }, summary: { diffs: [{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }], }, diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 8ea8aefbe..23b38d4a8 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -10,6 +10,7 @@ import * as Log from "@opencode-ai/core/util/log" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" void Log.init({ print: false }) @@ -18,7 +19,7 @@ const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer)) const model = { providerID: ProviderV2.ID.make("test"), - modelID: ProviderV2.ModelID.make("test"), + modelID: ModelV2.ID.make("test"), } afterEach(async () => { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 0d36fe8d6..3e861c216 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -33,6 +33,7 @@ import { TestConfig } from "../fixture/config" import { RuntimeFlags } from "@/effect/runtime-flags" import { LLMEvent, Usage } from "@opencode-ai/llm" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" void Log.init({ print: false }) @@ -47,7 +48,7 @@ const summary = Layer.succeed( const ref = { providerID: ProviderV2.ID.make("test"), - modelID: ProviderV2.ModelID.make("test-model"), + modelID: ModelV2.ID.make("test-model"), } const usage = (input: ConstructorParameters[0]) => new Usage(input) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index e480fc32a..53ccf06e1 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -16,6 +16,7 @@ import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdirS import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer)) @@ -77,7 +78,7 @@ function loaded(filepath: string): SessionV1.WithParts[] { agent: "build", model: { providerID: ProviderV2.ID.make("anthropic"), - modelID: ProviderV2.ModelID.make("claude-sonnet-4-20250514"), + modelID: ModelV2.ID.make("claude-sonnet-4-20250514"), }, }, parts: [ diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 857409ad9..eee31cb16 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -25,6 +25,7 @@ import { MessageID, SessionID } from "../../src/session/schema" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings") @@ -368,7 +369,7 @@ const driveToolLoop = (scenario: RecordedScenario) => const stableID = scenario.stableID ?? scenario.providerID const sessionID = SessionID.make(`session-recorded-${stableID}-loop`) - const modelID = ProviderV2.ModelID.make(model.id) + const modelID = ModelV2.ID.make(model.id) const agent = { name: "test", mode: "primary", diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 09766f0bc..702bb67e3 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -10,9 +10,10 @@ import type { Provider } from "@/provider/provider" import { OAUTH_DUMMY_KEY } from "@/auth" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const baseModel: Provider.Model = { - id: ProviderV2.ModelID.make("gpt-5-mini"), + id: ModelV2.ID.make("gpt-5-mini"), providerID: ProviderV2.ID.make("openai"), api: { id: "gpt-5-mini", diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index e8ca95513..6644599f4 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -26,6 +26,7 @@ import { Permission } from "@/permission" import { LLMAISDK } from "@/session/llm/ai-sdk" import { Session as SessionNs } from "@/session/session" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" type ConfigModel = NonNullable[string]["models"]>[string] @@ -768,7 +769,7 @@ describe("session.llm.stream", () => { const resolved = yield* Provider.use.getModel( ProviderV2.ID.make(vivgridFixture.providerID), - ProviderV2.ModelID.make(fixture.model.id), + ModelV2.ID.make(fixture.model.id), ) const sessionID = SessionID.make("session-test-1") const agent = { @@ -842,7 +843,7 @@ describe("session.llm.stream", () => { const resolved = yield* Provider.use.getModel( ProviderV2.ID.make(alibabaQwenFixture.providerID), - ProviderV2.ModelID.make(fixture.model.id), + ModelV2.ID.make(fixture.model.id), ) const sessionID = SessionID.make("session-test-service-abort") const agent = { @@ -910,7 +911,7 @@ describe("session.llm.stream", () => { const resolved = yield* Provider.use.getModel( ProviderV2.ID.make(alibabaQwenFixture.providerID), - ProviderV2.ModelID.make(fixture.model.id), + ModelV2.ID.make(fixture.model.id), ) const sessionID = SessionID.make("session-test-tools") const agent = { @@ -1013,7 +1014,7 @@ describe("session.llm.stream", () => { ] const request = waitRequest("/responses", createEventResponse(responseChunks, true)) - const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) const sessionID = SessionID.make("session-test-2") const agent = { name: "test", @@ -1118,7 +1119,7 @@ describe("session.llm.stream", () => { }), ) - const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) const sessionID = SessionID.make("session-test-native-flag-off") const agent = { name: "test", @@ -1188,7 +1189,7 @@ describe("session.llm.stream", () => { ] const request = waitRequest("/responses", createEventResponse(chunks, true)) - const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) const sessionID = SessionID.make("session-test-native") const agent = { name: "test", @@ -1272,7 +1273,7 @@ describe("session.llm.stream", () => { }), ) - const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) const sessionID = SessionID.make("session-test-native-injected-tool") const agent = { name: "test", @@ -1360,7 +1361,7 @@ describe("session.llm.stream", () => { const request = waitRequest("/responses", createEventResponse(chunks, true)) let executed: unknown - const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) const sessionID = SessionID.make("session-test-native-tool") const agent = { name: "test", @@ -1486,7 +1487,7 @@ describe("session.llm.stream", () => { ), ).toString("base64")}` - const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ProviderV2.ModelID.make(model.id)) + const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id)) const sessionID = SessionID.make("session-test-data-url") const agent = { name: "test", @@ -1575,7 +1576,7 @@ describe("session.llm.stream", () => { const resolved = yield* Provider.use.getModel( ProviderV2.ID.make(minimaxFixture.providerID), - ProviderV2.ModelID.make(model.id), + ModelV2.ID.make(model.id), ) const sessionID = SessionID.make("session-test-3") const agent = { @@ -1593,7 +1594,7 @@ describe("session.llm.stream", () => { role: "user", time: { created: Date.now() }, agent: agent.name, - model: { providerID: ProviderV2.ID.make("minimax"), modelID: ProviderV2.ModelID.make("MiniMax-M2.5") }, + model: { providerID: ProviderV2.ID.make("minimax"), modelID: ModelV2.ID.make("MiniMax-M2.5") }, } satisfies SessionV1.User yield* drain({ @@ -1672,7 +1673,7 @@ describe("session.llm.stream", () => { const resolved = yield* Provider.use.getModel( ProviderV2.ID.make("anthropic"), - ProviderV2.ModelID.make(model.id), + ModelV2.ID.make(model.id), ) const sessionID = SessionID.make("session-test-anthropic-tools") const agent = { @@ -1874,7 +1875,7 @@ describe("session.llm.stream", () => { const resolved = yield* Provider.use.getModel( ProviderV2.ID.make(geminiFixture.providerID), - ProviderV2.ModelID.make(model.id), + ModelV2.ID.make(model.id), ) const sessionID = SessionID.make("session-test-4") const agent = { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index f0eec133d..1de84c9dd 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -8,11 +8,12 @@ import type { Provider } from "@/provider/provider" import { SessionID, MessageID, PartID } from "../../src/session/schema" import { Question } from "../../src/question" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const sessionID = SessionID.make("session") const providerID = ProviderV2.ID.make("test") const model: Provider.Model = { - id: ProviderV2.ModelID.make("test-model"), + id: ModelV2.ID.make("test-model"), providerID, api: { id: "test-model", @@ -67,7 +68,7 @@ function userInfo(id: string): SessionV1.User { role: "user", time: { created: 0 }, agent: "user", - model: { providerID, modelID: ProviderV2.ModelID.make("test") }, + model: { providerID, modelID: ModelV2.ID.make("test") }, tools: {}, mode: "", } as unknown as SessionV1.User @@ -413,7 +414,7 @@ describe("session.message-v2.toModelMessage", () => { test("preserves jpeg tool-result media for anthropic models", async () => { const anthropicModel: Provider.Model = { ...model, - id: ProviderV2.ModelID.make("anthropic/claude-opus-4-7"), + id: ModelV2.ID.make("anthropic/claude-opus-4-7"), providerID: ProviderV2.ID.make("anthropic"), api: { id: "claude-opus-4-7-20250805", @@ -496,7 +497,7 @@ describe("session.message-v2.toModelMessage", () => { test("moves bedrock pdf tool-result media into a separate user message", async () => { const bedrockModel: Provider.Model = { ...model, - id: ProviderV2.ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"), + id: ModelV2.ID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"), providerID: ProviderV2.ID.make("amazon-bedrock"), api: { id: "anthropic.claude-sonnet-4-6", @@ -1044,7 +1045,7 @@ describe("session.message-v2.toModelMessage", () => { const assistantID = "m-assistant" const openrouterModel: Provider.Model = { ...model, - id: ProviderV2.ModelID.make("deepseek/deepseek-v4-pro"), + id: ModelV2.ID.make("deepseek/deepseek-v4-pro"), providerID: ProviderV2.ID.make("openrouter"), api: { id: "deepseek/deepseek-v4-pro", diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 4d9405b04..ac8d852e6 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -10,6 +10,7 @@ import { NotFoundError } from "@/storage/storage" import * as Log from "@opencode-ai/core/util/log" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" void Log.init({ print: false }) @@ -98,7 +99,7 @@ const addAssistant = Effect.fn("Test.addAssistant")(function* ( role: "assistant", time: { created: Date.now() }, parentID, - modelID: ProviderV2.ModelID.make("test"), + modelID: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test"), mode: "", agent: "default", diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 87e6a039d..c14777b46 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -30,6 +30,7 @@ import { testEffect } from "../lib/effect" import { raw, reply, TestLLMServer } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" import { SessionEvent } from "@opencode-ai/core/session/event" import { LLMEvent } from "@opencode-ai/llm" @@ -46,7 +47,7 @@ const summary = Layer.succeed( const ref = { providerID: ProviderV2.ID.make("test"), - modelID: ProviderV2.ModelID.make("test-model"), + modelID: ModelV2.ID.make("test-model"), } const cfg = { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index af6ac62c0..9ef84173e 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -57,6 +57,7 @@ import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" void Log.init({ print: false }) @@ -71,7 +72,7 @@ const summary = Layer.succeed( const ref = { providerID: ProviderV2.ID.make("test"), - modelID: ProviderV2.ModelID.make("test-model"), + modelID: ModelV2.ID.make("test-model"), } function withSh(fx: () => Effect.Effect) { @@ -759,7 +760,7 @@ it.instance("failed subtask preserves metadata on error tool state", () => expect(tool.state.metadata?.sessionId).toBeDefined() expect(tool.state.metadata?.model).toEqual({ providerID: ProviderV2.ID.make("test"), - modelID: ProviderV2.ModelID.make("missing-model"), + modelID: ModelV2.ID.make("missing-model"), }) }), ) @@ -2213,7 +2214,7 @@ noLLMServer.instance( const other = yield* prompt.prompt({ sessionID: session.id, agent: "build", - model: { providerID: ProviderV2.ID.make("opencode"), modelID: ProviderV2.ModelID.make("kimi-k2.5-free") }, + model: { providerID: ProviderV2.ID.make("opencode"), modelID: ModelV2.ID.make("kimi-k2.5-free") }, noReply: true, parts: [{ type: "text", text: "hello" }], }) @@ -2229,7 +2230,7 @@ noLLMServer.instance( if (match.info.role !== "user") throw new Error("expected user message") expect(match.info.model).toEqual({ providerID: ProviderV2.ID.make("test"), - modelID: ProviderV2.ModelID.make("test-model"), + modelID: ModelV2.ID.make("test-model"), variant: "xhigh", }) expect(match.info.model.variant).toBe("xhigh") diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index 0f4a666ab..e9e0133a1 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -14,6 +14,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" void Log.init({ print: false }) @@ -33,7 +34,7 @@ const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "de role: "user" as const, sessionID, agent, - model: { providerID: ProviderV2.ID.make("openai"), modelID: ProviderV2.ModelID.make("gpt-4") }, + model: { providerID: ProviderV2.ID.make("openai"), modelID: ModelV2.ID.make("gpt-4") }, time: { created: Date.now() }, }) }) @@ -49,7 +50,7 @@ const assistant = Effect.fn("test.assistant")(function* (sessionID: SessionID, p path: { cwd: dir, root: dir }, cost: 0, tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: ProviderV2.ModelID.make("gpt-4"), + modelID: ModelV2.ID.make("gpt-4"), providerID: ProviderV2.ID.make("openai"), parentID, time: { created: Date.now() }, @@ -117,7 +118,7 @@ describe("revert + compact workflow", () => { agent: "default", model: { providerID: ProviderV2.ID.make("openai"), - modelID: ProviderV2.ModelID.make("gpt-4"), + modelID: ModelV2.ID.make("gpt-4"), }, time: { created: Date.now(), @@ -149,7 +150,7 @@ describe("revert + compact workflow", () => { reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: ProviderV2.ModelID.make("gpt-4"), + modelID: ModelV2.ID.make("gpt-4"), providerID: ProviderV2.ID.make("openai"), parentID: userMsg1.id, time: { @@ -174,7 +175,7 @@ describe("revert + compact workflow", () => { agent: "default", model: { providerID: ProviderV2.ID.make("openai"), - modelID: ProviderV2.ModelID.make("gpt-4"), + modelID: ModelV2.ID.make("gpt-4"), }, time: { created: Date.now(), @@ -206,7 +207,7 @@ describe("revert + compact workflow", () => { reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: ProviderV2.ModelID.make("gpt-4"), + modelID: ModelV2.ID.make("gpt-4"), providerID: ProviderV2.ID.make("openai"), parentID: userMsg2.id, time: { @@ -279,7 +280,7 @@ describe("revert + compact workflow", () => { agent: "default", model: { providerID: ProviderV2.ID.make("openai"), - modelID: ProviderV2.ModelID.make("gpt-4"), + modelID: ModelV2.ID.make("gpt-4"), }, time: { created: Date.now(), @@ -311,7 +312,7 @@ describe("revert + compact workflow", () => { reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: ProviderV2.ModelID.make("gpt-4"), + modelID: ModelV2.ID.make("gpt-4"), providerID: ProviderV2.ID.make("openai"), parentID: userMsg.id, time: { diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index ac29d35f2..b8337b963 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Exit, Layer } from "effect" import { Session as SessionNs } from "@/session/session" @@ -14,6 +15,7 @@ import { Storage } from "@/storage/storage" import { RuntimeFlags } from "@/effect/runtime-flags" import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" +import { GlobalBus } from "@/bus/global" void Log.init({ print: false }) @@ -101,6 +103,31 @@ describe("session.created event", () => { yield* session.remove(info.id) }), ) + + it.instance("emits legacy global sync payload", () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const received = yield* Deferred.make<{ syncEvent: EventV2.SerializedEvent }>() + const listener = (event: { payload: { type?: string; syncEvent?: EventV2.SerializedEvent } }) => { + if (event.payload.type === "sync" && event.payload.syncEvent) + Deferred.doneUnsafe(received, Effect.succeed({ syncEvent: event.payload.syncEvent })) + } + GlobalBus.on("event", listener) + yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", listener))) + + const info = yield* session.create({}) + const event = yield* awaitDeferred(received, "timed out waiting for legacy global sync event") + + expect(event.syncEvent).toMatchObject({ + type: EventV2.versionedType(SessionNs.Event.Created.type, 1), + seq: 0, + aggregateID: info.id, + data: { sessionID: info.id }, + }) + + yield* session.remove(info.id) + }), + ) }) describe("step-finish token propagation via event", () => { diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index feb070a09..168243abb 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -18,7 +18,7 @@ import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" import { provideTmpdirInstance } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" -import { testEffect } from "../lib/effect" +import { pollWithTimeout, testEffect } from "../lib/effect" const env = Layer.mergeAll( Session.defaultLayer, @@ -301,7 +301,11 @@ describe("ShareNext", () => { }, ], }) - yield* Effect.sleep(1_250) + yield* pollWithTimeout( + Effect.sync(() => (seen.length === 1 ? true : undefined)), + "timed out waiting for share sync", + "5 seconds", + ) expect(seen).toHaveLength(1) expect(seen[0].url).toBe("https://legacy-share.example.com/api/share/shr_abc/sync") diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 42b8c69b3..d536ef2b0 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -36,6 +36,7 @@ import { ToolJsonSchema } from "@/tool/json-schema" import { MessageID, SessionID } from "@/session/schema" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" const node = CrossSpawnSpawner.defaultLayer const configLayer = TestConfig.layer({ @@ -124,7 +125,7 @@ describe("tool.registry", () => { if (!build) throw new Error("build agent not found") const task = (yield* registry.tools({ providerID: ProviderV2.ID.opencode, - modelID: ProviderV2.ModelID.make("test"), + modelID: ModelV2.ID.make("test"), agent: build, })).find((tool) => tool.id === "task") @@ -302,7 +303,7 @@ describe("tool.registry", () => { const agents = yield* Agent.Service const promptTools = yield* registry.tools({ providerID: ProviderV2.ID.opencode, - modelID: ProviderV2.ModelID.make("test"), + modelID: ModelV2.ID.make("test"), agent: yield* agents.defaultInfo(), }) const promptTool = promptTools.find((tool) => tool.id === "sql") diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 653bec4db..66ffd8658 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -21,6 +21,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { disposeAllInstances } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" afterEach(async () => { await disposeAllInstances() @@ -28,7 +29,7 @@ afterEach(async () => { const ref = { providerID: ProviderV2.ID.make("test"), - modelID: ProviderV2.ModelID.make("test-model"), + modelID: ModelV2.ID.make("test-model"), } const layer = (flags: Partial = {}) => diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index a0492f681..3d9d6d539 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -255,10 +255,18 @@ import type { TuiShowToastResponses, TuiSubmitPromptErrors, TuiSubmitPromptResponses, + V2AgentListErrors, + V2AgentListResponses, + V2CommandListErrors, + V2CommandListResponses, + V2EventSubscribeErrors, + V2EventSubscribeResponses, V2FsListErrors, V2FsListResponses, V2FsReadErrors, V2FsReadResponses, + V2HealthGetErrors, + V2HealthGetResponses, V2ModelListErrors, V2ModelListResponses, V2PermissionRequestListErrors, @@ -293,6 +301,8 @@ import type { V2SessionQuestionReplyResponses, V2SessionWaitErrors, V2SessionWaitResponses, + V2SkillListErrors, + V2SkillListResponses, VcsApplyErrors, VcsApplyResponses, VcsDiffErrors, @@ -4463,692 +4473,6 @@ export class Sync extends HeyApiClient { } } -export class Permission2 extends HeyApiClient { - /** - * List session permission requests - * - * Retrieve pending permission requests owned by a session. - */ - public list( - parameters: { - sessionID: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) - return (options?.client ?? this.client).get< - V2SessionPermissionListResponses, - V2SessionPermissionListErrors, - ThrowOnError - >({ - url: "/api/session/{sessionID}/permission/request", - ...options, - ...params, - }) - } - - /** - * Reply to pending permission request - * - * Respond to a pending permission request owned by a session. - */ - public reply( - parameters: { - sessionID: string - requestID: string - reply?: PermissionV2Reply - message?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "requestID" }, - { in: "body", key: "reply" }, - { in: "body", key: "message" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - V2SessionPermissionReplyResponses, - V2SessionPermissionReplyErrors, - ThrowOnError - >({ - url: "/api/session/{sessionID}/permission/request/{requestID}/reply", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - -export class Question2 extends HeyApiClient { - /** - * Reply to pending question request - * - * Answer a pending question request owned by a session. - */ - public reply( - parameters: { - sessionID: string - requestID: string - questionV2Reply: QuestionV2Reply - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "requestID" }, - { key: "questionV2Reply", map: "body" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - V2SessionQuestionReplyResponses, - V2SessionQuestionReplyErrors, - ThrowOnError - >({ - url: "/api/session/{sessionID}/question/request/{requestID}/reply", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Reject pending question request - * - * Reject a pending question request owned by a session. - */ - public reject( - parameters: { - sessionID: string - requestID: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "requestID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - V2SessionQuestionRejectResponses, - V2SessionQuestionRejectErrors, - ThrowOnError - >({ - url: "/api/session/{sessionID}/question/request/{requestID}/reject", - ...options, - ...params, - }) - } -} - -export class Session3 extends HeyApiClient { - /** - * List v2 sessions - * - * Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list. - */ - public list( - parameters?: { - workspace?: string - limit?: number - order?: "asc" | "desc" - search?: string - directory?: string - project?: string - subpath?: string - cursor?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "workspace" }, - { in: "query", key: "limit" }, - { in: "query", key: "order" }, - { in: "query", key: "search" }, - { in: "query", key: "directory" }, - { in: "query", key: "project" }, - { in: "query", key: "subpath" }, - { in: "query", key: "cursor" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/api/session", - ...options, - ...params, - }) - } - - /** - * Send v2 message - * - * Durably admit one v2 session input and schedule agent-loop execution unless resume is false. - */ - public prompt( - parameters: { - sessionID: string - directory?: string - workspace?: string - id?: string - prompt?: Prompt - delivery?: "steer" | "queue" - resume?: boolean - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "id" }, - { in: "body", key: "prompt" }, - { in: "body", key: "delivery" }, - { in: "body", key: "resume" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/api/session/{sessionID}/prompt", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Compact v2 session - * - * Compact a v2 session conversation. - */ - public compact( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/api/session/{sessionID}/compact", - ...options, - ...params, - }) - } - - /** - * Wait for v2 session - * - * Wait for a v2 session agent loop to become idle. - */ - public wait( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/api/session/{sessionID}/wait", - ...options, - ...params, - }) - } - - /** - * Get v2 session context - * - * Retrieve the active context messages for a v2 session (all messages after the last compaction). - */ - public context( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/api/session/{sessionID}/context", - ...options, - ...params, - }) - } - - /** - * Get v2 session messages - * - * Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. - */ - public messages( - parameters: { - sessionID: string - directory?: string - workspace?: string - limit?: number - order?: "asc" | "desc" - cursor?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "limit" }, - { in: "query", key: "order" }, - { in: "query", key: "cursor" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/api/session/{sessionID}/message", - ...options, - ...params, - }) - } - - private _permission?: Permission2 - get permission(): Permission2 { - return (this._permission ??= new Permission2({ client: this.client })) - } - - private _question?: Question2 - get question(): Question2 { - return (this._question ??= new Question2({ client: this.client })) - } -} - -export class Model extends HeyApiClient { - /** - * List v2 models - * - * Retrieve available v2 models ordered by release date. - */ - public list( - parameters?: { - location?: { - directory?: string - workspace?: string - } - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) - return (options?.client ?? this.client).get({ - url: "/api/model", - ...options, - ...params, - }) - } -} - -export class Provider2 extends HeyApiClient { - /** - * List v2 providers - * - * Retrieve active v2 AI providers so clients can show provider availability and configuration. - */ - public list( - parameters?: { - location?: { - directory?: string - workspace?: string - } - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) - return (options?.client ?? this.client).get({ - url: "/api/provider", - ...options, - ...params, - }) - } - - /** - * Get v2 provider - * - * Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings. - */ - public get( - parameters: { - providerID: string - location?: { - directory?: string - workspace?: string - } - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "location" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/api/provider/{providerID}", - ...options, - ...params, - }) - } -} - -export class Request extends HeyApiClient { - /** - * List pending permission requests - * - * Retrieve pending permission requests for a location. - */ - public list( - parameters?: { - location?: { - directory?: string - workspace?: string - } - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) - return (options?.client ?? this.client).get< - V2PermissionRequestListResponses, - V2PermissionRequestListErrors, - ThrowOnError - >({ - url: "/api/permission/request", - ...options, - ...params, - }) - } -} - -export class Saved extends HeyApiClient { - /** - * List saved permissions - * - * Retrieve saved permissions, optionally filtered by project. - */ - public list( - parameters?: { - projectID?: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "projectID" }] }]) - return (options?.client ?? this.client).get< - V2PermissionSavedListResponses, - V2PermissionSavedListErrors, - ThrowOnError - >({ - url: "/api/permission/saved", - ...options, - ...params, - }) - } - - /** - * Remove saved permission - * - * Remove a saved permission by ID. - */ - public remove( - parameters: { - id: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "id" }] }]) - return (options?.client ?? this.client).delete< - V2PermissionSavedRemoveResponses, - V2PermissionSavedRemoveErrors, - ThrowOnError - >({ - url: "/api/permission/saved/{id}", - ...options, - ...params, - }) - } -} - -export class Permission3 extends HeyApiClient { - private _request?: Request - get request(): Request { - return (this._request ??= new Request({ client: this.client })) - } - - private _saved?: Saved - get saved(): Saved { - return (this._saved ??= new Saved({ client: this.client })) - } -} - -export class Fs extends HeyApiClient { - /** - * Read file - * - * Read one file relative to the requested location. - */ - public read( - parameters: { - location?: { - directory?: string - workspace?: string - } - path: string - reference?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "location" }, - { in: "query", key: "path" }, - { in: "query", key: "reference" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/api/fs/read", - ...options, - ...params, - }) - } - - /** - * List directory - * - * List direct children of one directory relative to the requested location. - */ - public list( - parameters?: { - location?: { - directory?: string - workspace?: string - } - path?: string - reference?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "location" }, - { in: "query", key: "path" }, - { in: "query", key: "reference" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/api/fs/list", - ...options, - ...params, - }) - } -} - -export class Request2 extends HeyApiClient { - /** - * List pending question requests - * - * Retrieve pending question requests for a location. - */ - public list( - parameters?: { - location?: { - directory?: string - workspace?: string - } - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) - return (options?.client ?? this.client).get< - V2QuestionRequestListResponses, - V2QuestionRequestListErrors, - ThrowOnError - >({ - url: "/api/question/request", - ...options, - ...params, - }) - } -} - -export class Question3 extends HeyApiClient { - private _request?: Request2 - get request(): Request2 { - return (this._request ??= new Request2({ client: this.client })) - } -} - -export class V2 extends HeyApiClient { - private _session?: Session3 - get session(): Session3 { - return (this._session ??= new Session3({ client: this.client })) - } - - private _model?: Model - get model(): Model { - return (this._model ??= new Model({ client: this.client })) - } - - private _provider?: Provider2 - get provider(): Provider2 { - return (this._provider ??= new Provider2({ client: this.client })) - } - - private _permission?: Permission3 - get permission(): Permission3 { - return (this._permission ??= new Permission3({ client: this.client })) - } - - private _fs?: Fs - get fs(): Fs { - return (this._fs ??= new Fs({ client: this.client })) - } - - private _question?: Question3 - get question(): Question3 { - return (this._question ??= new Question3({ client: this.client })) - } -} - export class Control extends HeyApiClient { /** * Get next TUI request @@ -5596,6 +4920,780 @@ export class Tui extends HeyApiClient { } } +export class Health extends HeyApiClient { + /** + * Check v2 server health + * + * Check whether the v2 API server is ready to accept requests. + */ + public get(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/api/health", + ...options, + }) + } +} + +export class Agent extends HeyApiClient { + /** + * List v2 agents + * + * Retrieve currently registered v2 agents. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/agent", + ...options, + ...params, + }) + } +} + +export class Permission2 extends HeyApiClient { + /** + * List session permission requests + * + * Retrieve pending permission requests owned by a session. + */ + public list( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).get< + V2SessionPermissionListResponses, + V2SessionPermissionListErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/permission/request", + ...options, + ...params, + }) + } + + /** + * Reply to pending permission request + * + * Respond to a pending permission request owned by a session. + */ + public reply( + parameters: { + sessionID: string + requestID: string + reply?: PermissionV2Reply + message?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, + { in: "body", key: "reply" }, + { in: "body", key: "message" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionPermissionReplyResponses, + V2SessionPermissionReplyErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/permission/request/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Question2 extends HeyApiClient { + /** + * Reply to pending question request + * + * Answer a pending question request owned by a session. + */ + public reply( + parameters: { + sessionID: string + requestID: string + questionV2Reply: QuestionV2Reply + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, + { key: "questionV2Reply", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionQuestionReplyResponses, + V2SessionQuestionReplyErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/question/request/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reject pending question request + * + * Reject a pending question request owned by a session. + */ + public reject( + parameters: { + sessionID: string + requestID: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionQuestionRejectResponses, + V2SessionQuestionRejectErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/question/request/{requestID}/reject", + ...options, + ...params, + }) + } +} + +export class Session3 extends HeyApiClient { + /** + * List v2 sessions + * + * Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list. + */ + public list( + parameters?: { + workspace?: string + limit?: number + order?: "asc" | "desc" + search?: string + directory?: string + project?: string + subpath?: string + cursor?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "search" }, + { in: "query", key: "directory" }, + { in: "query", key: "project" }, + { in: "query", key: "subpath" }, + { in: "query", key: "cursor" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session", + ...options, + ...params, + }) + } + + /** + * Send v2 message + * + * Durably admit one v2 session input and schedule agent-loop execution unless resume is false. + */ + public prompt( + parameters: { + sessionID: string + id?: string + prompt?: Prompt + delivery?: "steer" | "queue" + resume?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + { in: "body", key: "prompt" }, + { in: "body", key: "delivery" }, + { in: "body", key: "resume" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/prompt", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Compact v2 session + * + * Compact a v2 session conversation. + */ + public compact( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/compact", + ...options, + ...params, + }) + } + + /** + * Wait for v2 session + * + * Wait for a v2 session agent loop to become idle. + */ + public wait( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/wait", + ...options, + ...params, + }) + } + + /** + * Get v2 session context + * + * Retrieve the active context messages for a v2 session (all messages after the last compaction). + */ + public context( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/context", + ...options, + ...params, + }) + } + + /** + * Get v2 session messages + * + * Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. + */ + public messages( + parameters: { + sessionID: string + limit?: number + order?: "asc" | "desc" + cursor?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "cursor" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message", + ...options, + ...params, + }) + } + + private _permission?: Permission2 + get permission(): Permission2 { + return (this._permission ??= new Permission2({ client: this.client })) + } + + private _question?: Question2 + get question(): Question2 { + return (this._question ??= new Question2({ client: this.client })) + } +} + +export class Model extends HeyApiClient { + /** + * List v2 models + * + * Retrieve available v2 models ordered by release date. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/model", + ...options, + ...params, + }) + } +} + +export class Provider2 extends HeyApiClient { + /** + * List v2 providers + * + * Retrieve active v2 AI providers so clients can show provider availability and configuration. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/provider", + ...options, + ...params, + }) + } + + /** + * Get v2 provider + * + * Retrieve a single v2 AI provider so clients can inspect its availability and endpoint settings. + */ + public get( + parameters: { + providerID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/provider/{providerID}", + ...options, + ...params, + }) + } +} + +export class Request extends HeyApiClient { + /** + * List pending permission requests + * + * Retrieve pending permission requests for a location. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get< + V2PermissionRequestListResponses, + V2PermissionRequestListErrors, + ThrowOnError + >({ + url: "/api/permission/request", + ...options, + ...params, + }) + } +} + +export class Saved extends HeyApiClient { + /** + * List saved permissions + * + * Retrieve saved permissions, optionally filtered by project. + */ + public list( + parameters?: { + projectID?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "projectID" }] }]) + return (options?.client ?? this.client).get< + V2PermissionSavedListResponses, + V2PermissionSavedListErrors, + ThrowOnError + >({ + url: "/api/permission/saved", + ...options, + ...params, + }) + } + + /** + * Remove saved permission + * + * Remove a saved permission by ID. + */ + public remove( + parameters: { + id: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "id" }] }]) + return (options?.client ?? this.client).delete< + V2PermissionSavedRemoveResponses, + V2PermissionSavedRemoveErrors, + ThrowOnError + >({ + url: "/api/permission/saved/{id}", + ...options, + ...params, + }) + } +} + +export class Permission3 extends HeyApiClient { + private _request?: Request + get request(): Request { + return (this._request ??= new Request({ client: this.client })) + } + + private _saved?: Saved + get saved(): Saved { + return (this._saved ??= new Saved({ client: this.client })) + } +} + +export class Fs extends HeyApiClient { + /** + * Read file + * + * Read one file relative to the requested location. + */ + public read( + parameters: { + location?: { + directory?: string + workspace?: string + } + path: string + reference?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "query", key: "path" }, + { in: "query", key: "reference" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/fs/read", + ...options, + ...params, + }) + } + + /** + * List directory + * + * List direct children of one directory relative to the requested location. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + path?: string + reference?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "query", key: "path" }, + { in: "query", key: "reference" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/fs/list", + ...options, + ...params, + }) + } +} + +export class Command2 extends HeyApiClient { + /** + * List v2 commands + * + * Retrieve currently registered v2 commands. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/command", + ...options, + ...params, + }) + } +} + +export class Skill extends HeyApiClient { + /** + * List v2 skills + * + * Retrieve currently registered v2 skills. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/skill", + ...options, + ...params, + }) + } +} + +export class Event2 extends HeyApiClient { + /** + * Subscribe to v2 events + * + * Subscribe to native EventV2 payloads for a location. + */ + public subscribe( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).sse.get({ + url: "/api/event", + ...options, + ...params, + }) + } +} + +export class Request2 extends HeyApiClient { + /** + * List pending question requests + * + * Retrieve pending question requests for a location. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get< + V2QuestionRequestListResponses, + V2QuestionRequestListErrors, + ThrowOnError + >({ + url: "/api/question/request", + ...options, + ...params, + }) + } +} + +export class Question3 extends HeyApiClient { + private _request?: Request2 + get request(): Request2 { + return (this._request ??= new Request2({ client: this.client })) + } +} + +export class V2 extends HeyApiClient { + private _health?: Health + get health(): Health { + return (this._health ??= new Health({ client: this.client })) + } + + private _agent?: Agent + get agent(): Agent { + return (this._agent ??= new Agent({ client: this.client })) + } + + private _session?: Session3 + get session(): Session3 { + return (this._session ??= new Session3({ client: this.client })) + } + + private _model?: Model + get model(): Model { + return (this._model ??= new Model({ client: this.client })) + } + + private _provider?: Provider2 + get provider(): Provider2 { + return (this._provider ??= new Provider2({ client: this.client })) + } + + private _permission?: Permission3 + get permission(): Permission3 { + return (this._permission ??= new Permission3({ client: this.client })) + } + + private _fs?: Fs + get fs(): Fs { + return (this._fs ??= new Fs({ client: this.client })) + } + + private _command?: Command2 + get command(): Command2 { + return (this._command ??= new Command2({ client: this.client })) + } + + private _skill?: Skill + get skill(): Skill { + return (this._skill ??= new Skill({ client: this.client })) + } + + private _event?: Event2 + get event(): Event2 { + return (this._event ??= new Event2({ client: this.client })) + } + + private _question?: Question3 + get question(): Question3 { + return (this._question ??= new Question3({ client: this.client })) + } +} + export class OpencodeClient extends HeyApiClient { public static readonly __registry = new HeyApiRegistry() @@ -5729,13 +5827,13 @@ export class OpencodeClient extends HeyApiClient { return (this._sync ??= new Sync({ client: this.client })) } - private _v2?: V2 - get v2(): V2 { - return (this._v2 ??= new V2({ client: this.client })) - } - private _tui?: Tui get tui(): Tui { return (this._tui ??= new Tui({ client: this.client })) } + + private _v2?: V2 + get v2(): V2 { + return (this._v2 ??= new V2({ client: this.client })) + } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 29f2da070..158d93cf7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1850,6 +1850,7 @@ export type Config = { description?: string agent?: string model?: string + variant?: string subtask?: boolean } } @@ -2559,62 +2560,6 @@ export type SessionBusyError = { message: string } -export type V2SessionsResponse = { - items: Array - cursor: { - previous?: string - next?: string - } -} - -export type InvalidCursorError = { - _tag: "InvalidCursorError" - message: string -} - -export type UnauthorizedError = { - _tag: "UnauthorizedError" - message: string -} - -export type ConflictError = { - _tag: "ConflictError" - message: string - resource?: string -} - -export type SessionNotFoundError = { - _tag: "SessionNotFoundError" - sessionID: string - message: string -} - -export type ServiceUnavailableError = { - _tag: "ServiceUnavailableError" - message: string - service?: string -} - -export type UnknownError1 = { - _tag: "UnknownError" - message: string - ref?: string -} - -export type V2SessionMessagesResponse = { - items: Array - cursor: { - previous?: string - next?: string - } -} - -export type ProviderNotFoundError = { - _tag: "ProviderNotFoundError" - providerID: string - message: string -} - export type EventTuiPromptAppend = { type: "tui.prompt.append" properties: { @@ -2691,6 +2636,62 @@ export type WorkspaceWarpError = { } } +export type UnauthorizedError = { + _tag: "UnauthorizedError" + message: string +} + +export type V2SessionsResponse = { + data: Array + cursor: { + previous?: string + next?: string + } +} + +export type InvalidCursorError = { + _tag: "InvalidCursorError" + message: string +} + +export type ConflictError = { + _tag: "ConflictError" + message: string + resource?: string +} + +export type SessionNotFoundError = { + _tag: "SessionNotFoundError" + sessionID: string + message: string +} + +export type ServiceUnavailableError = { + _tag: "ServiceUnavailableError" + message: string + service?: string +} + +export type UnknownError1 = { + _tag: "UnknownError" + message: string + ref?: string +} + +export type V2SessionMessagesResponse = { + data: Array + cursor: { + previous?: string + next?: string + } +} + +export type ProviderNotFoundError = { + _tag: "ProviderNotFoundError" + providerID: string + message: string +} + export type EffectHttpApiErrorForbidden = { _tag: "Forbidden" } @@ -2970,273 +2971,330 @@ export type EventServerInstanceDisposed = { export type SyncEventSessionCreated = { type: "sync" - name: "session.created.1" id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session + syncEvent: { + type: "session.created.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + info: Session + } } } export type SyncEventSessionUpdated = { type: "sync" - name: "session.updated.1" id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session + syncEvent: { + type: "session.updated.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + info: Session + } } } export type SyncEventSessionDeleted = { type: "sync" - name: "session.deleted.1" id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session + syncEvent: { + type: "session.deleted.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + info: Session + } } } export type SyncEventMessageUpdated = { type: "sync" - name: "message.updated.1" id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Message + syncEvent: { + type: "message.updated.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + info: Message + } } } export type SyncEventMessageRemoved = { type: "sync" - name: "message.removed.1" id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string + syncEvent: { + type: "message.removed.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + messageID: string + } } } export type SyncEventMessagePartUpdated = { type: "sync" - name: "message.part.updated.1" id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - part: Part - time: number + syncEvent: { + type: "message.part.updated.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + part: Part + time: number + } } } export type SyncEventMessagePartRemoved = { type: "sync" - name: "message.part.removed.1" id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - partID: string + syncEvent: { + type: "message.part.removed.1" + id: string + seq: number + aggregateID: string + data: { + sessionID: string + messageID: string + partID: string + } } } export type SyncEventSessionNextAgentSwitched = { type: "sync" - name: "session.next.agent.switched.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - agent: string + syncEvent: { + type: "session.next.agent.switched.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + agent: string + } } } export type SyncEventSessionNextModelSwitched = { type: "sync" - name: "session.next.model.switched.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - model: { - id: string - providerID: string - variant?: string + syncEvent: { + type: "session.next.model.switched.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + model: { + id: string + providerID: string + variant?: string + } } } } export type SyncEventSessionNextPrompted = { type: "sync" - name: "session.next.prompted.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - prompt: Prompt - delivery: "steer" | "queue" + syncEvent: { + type: "session.next.prompted.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + prompt: Prompt + delivery: "steer" | "queue" + } } } export type SyncEventSessionNextSynthetic = { type: "sync" - name: "session.next.synthetic.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - text: string + syncEvent: { + type: "session.next.synthetic.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + text: string + } } } export type SyncEventSessionNextShellStarted = { type: "sync" - name: "session.next.shell.started.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - callID: string - command: string + syncEvent: { + type: "session.next.shell.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + callID: string + command: string + } } } export type SyncEventSessionNextShellEnded = { type: "sync" - name: "session.next.shell.ended.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - callID: string - output: string + syncEvent: { + type: "session.next.shell.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + callID: string + output: string + } } } export type SyncEventSessionNextStepStarted = { type: "sync" - name: "session.next.step.started.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - agent: string - model: { - id: string - providerID: string - variant?: string + syncEvent: { + type: "session.next.step.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant?: string + } + snapshot?: string } - snapshot?: string } } export type SyncEventSessionNextStepEnded = { type: "sync" - name: "session.next.step.ended.2" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - finish: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number + syncEvent: { + type: "session.next.step.ended.2" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } } + snapshot?: string } - snapshot?: string } } export type SyncEventSessionNextStepFailed = { type: "sync" - name: "session.next.step.failed.2" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - error: SessionErrorUnknown + syncEvent: { + type: "session.next.step.failed.2" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + error: SessionErrorUnknown + } } } export type SyncEventSessionNextTextStarted = { type: "sync" - name: "session.next.text.started.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - textID: string + syncEvent: { + type: "session.next.text.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + textID: string + } } } export type SyncEventSessionNextTextEnded = { type: "sync" - name: "session.next.text.ended.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - textID: string - text: string + syncEvent: { + type: "session.next.text.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + textID: string + text: string + } } } export type SyncEventSessionNextReasoningStarted = { type: "sync" - name: "session.next.reasoning.started.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - reasoningID: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown + syncEvent: { + type: "session.next.reasoning.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + reasoningID: string + providerMetadata?: { + [key: string]: { + [key: string]: unknown + } } } } @@ -3244,18 +3302,21 @@ export type SyncEventSessionNextReasoningStarted = { export type SyncEventSessionNextReasoningEnded = { type: "sync" - name: "session.next.reasoning.ended.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - reasoningID: string - text: string - providerMetadata?: { - [key: string]: { - [key: string]: unknown + syncEvent: { + type: "session.next.reasoning.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + reasoningID: string + text: string + providerMetadata?: { + [key: string]: { + [key: string]: unknown + } } } } @@ -3263,54 +3324,63 @@ export type SyncEventSessionNextReasoningEnded = { export type SyncEventSessionNextToolInputStarted = { type: "sync" - name: "session.next.tool.input.started.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - name: string + syncEvent: { + type: "session.next.tool.input.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + name: string + } } } export type SyncEventSessionNextToolInputEnded = { type: "sync" - name: "session.next.tool.input.ended.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - text: string + syncEvent: { + type: "session.next.tool.input.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + text: string + } } } export type SyncEventSessionNextToolCalled = { type: "sync" - name: "session.next.tool.called.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - tool: string - input: { - [key: string]: unknown - } - provider: { - executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown + syncEvent: { + type: "session.next.tool.called.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: { + [key: string]: unknown + } } } } @@ -3319,43 +3389,49 @@ export type SyncEventSessionNextToolCalled = { export type SyncEventSessionNextToolProgress = { type: "sync" - name: "session.next.tool.progress.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - structured: { - [key: string]: unknown + syncEvent: { + type: "session.next.tool.progress.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array } - content: Array } } export type SyncEventSessionNextToolSuccess = { type: "sync" - name: "session.next.tool.success.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array - result?: unknown - provider: { - executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown + syncEvent: { + type: "session.next.tool.success.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + result?: unknown + provider: { + executed: boolean + metadata?: { + [key: string]: { + [key: string]: unknown + } } } } @@ -3364,22 +3440,25 @@ export type SyncEventSessionNextToolSuccess = { export type SyncEventSessionNextToolFailed = { type: "sync" - name: "session.next.tool.failed.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - error: SessionErrorUnknown - result?: unknown - provider: { - executed: boolean - metadata?: { - [key: string]: { - [key: string]: unknown + syncEvent: { + type: "session.next.tool.failed.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + assistantMessageID: string + callID: string + error: SessionErrorUnknown + result?: unknown + provider: { + executed: boolean + metadata?: { + [key: string]: { + [key: string]: unknown + } } } } @@ -3388,55 +3467,67 @@ export type SyncEventSessionNextToolFailed = { export type SyncEventSessionNextRetried = { type: "sync" - name: "session.next.retried.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - attempt: number - error: SessionNextRetryError + syncEvent: { + type: "session.next.retried.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } } } export type SyncEventSessionNextCompactionStarted = { type: "sync" - name: "session.next.compaction.started.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - reason: "auto" | "manual" + syncEvent: { + type: "session.next.compaction.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } } } export type SyncEventSessionNextCompactionDelta = { type: "sync" - name: "session.next.compaction.delta.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - text: string + syncEvent: { + type: "session.next.compaction.delta.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + text: string + } } } export type SyncEventSessionNextCompactionEnded = { type: "sync" - name: "session.next.compaction.ended.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - text: string - include?: string + syncEvent: { + type: "session.next.compaction.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + text: string + include?: string + } } } @@ -3454,6 +3545,49 @@ export type ProjectCopyCopy = { directory: string } +export type LocationInfo = { + directory: string + workspaceID?: string + project: { + id: string + directory: string + } +} + +export type PermissionV2Effect = "allow" | "deny" | "ask" + +export type PermissionV2Rule = { + action: string + resource: string + effect: PermissionV2Effect +} + +export type PermissionV2Ruleset = Array + +export type AgentV2Info = { + id: string + model?: { + id: string + providerID: string + variant?: string + } + request: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + } + system?: string + description?: string + mode: "subagent" | "primary" | "all" + hidden: boolean + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + permissions: PermissionV2Ruleset +} + export type LocationRef = { directory: string workspaceID?: string @@ -3707,56 +3841,7 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction -export type ModelV2PublicInfo = { - id: string - providerID: string - family?: string - name: string - api: - | { - id: string - type: "aisdk" - package: string - url?: string - } - | { - id: string - type: "native" - url?: string - } - capabilities: { - tools: boolean - input: Array - output: Array - } - variants: Array<{ - id: string - }> - time: { - released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - cost: Array<{ - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } - }> - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { - context: number - input?: number - output: number - } -} - -export type ProviderV2PublicInfo = { +export type ProviderV2Info = { id: string name: string enabled: @@ -3771,6 +3856,9 @@ export type ProviderV2PublicInfo = { } | { via: "custom" + data: { + [key: string]: unknown + } } env: Array api: @@ -3778,11 +3866,25 @@ export type ProviderV2PublicInfo = { type: "aisdk" package: string url?: string + settings?: { + [key: string]: unknown + } } | { type: "native" url?: string + settings: { + [key: string]: unknown + } } + request: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + } } export type PermissionV2Request = { @@ -3824,6 +3926,27 @@ export type FileSystemEntry = { mime: string } +export type CommandV2Info = { + name: string + template: string + description?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + subtask?: boolean +} + +export type SkillV2Info = { + name: string + description?: string + slash?: boolean + location: string + content: string +} + export type QuestionV2Request = { id: string sessionID: string @@ -8286,737 +8409,6 @@ export type SyncHistoryListResponses = { export type SyncHistoryListResponse = SyncHistoryListResponses[keyof SyncHistoryListResponses] -export type V2SessionListData = { - body?: never - path?: never - query?: { - workspace?: string - limit?: number - order?: "asc" | "desc" - search?: string - directory?: string - project?: string - subpath?: string - /** - * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. - */ - cursor?: string - } - url: "/api/session" -} - -export type V2SessionListErrors = { - /** - * InvalidCursorError | InvalidRequestError - */ - 400: InvalidCursorError | InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError -} - -export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors] - -export type V2SessionListResponses = { - /** - * V2SessionsResponse - */ - 200: V2SessionsResponse -} - -export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] - -export type V2SessionPromptData = { - body: { - id?: string - prompt: Prompt - delivery?: "steer" | "queue" - resume?: boolean - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/api/session/{sessionID}/prompt" -} - -export type V2SessionPromptErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError - /** - * SessionNotFoundError - */ - 404: SessionNotFoundError - /** - * ConflictError - */ - 409: ConflictError -} - -export type V2SessionPromptError = V2SessionPromptErrors[keyof V2SessionPromptErrors] - -export type V2SessionPromptResponses = { - /** - * Session.Message.User - */ - 200: SessionMessageUser -} - -export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] - -export type V2SessionCompactData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/api/session/{sessionID}/compact" -} - -export type V2SessionCompactErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError - /** - * SessionNotFoundError - */ - 404: SessionNotFoundError - /** - * ServiceUnavailableError - */ - 503: ServiceUnavailableError -} - -export type V2SessionCompactError = V2SessionCompactErrors[keyof V2SessionCompactErrors] - -export type V2SessionCompactResponses = { - /** - * - */ - 204: void -} - -export type V2SessionCompactResponse = V2SessionCompactResponses[keyof V2SessionCompactResponses] - -export type V2SessionWaitData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/api/session/{sessionID}/wait" -} - -export type V2SessionWaitErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError - /** - * SessionNotFoundError - */ - 404: SessionNotFoundError - /** - * ServiceUnavailableError - */ - 503: ServiceUnavailableError -} - -export type V2SessionWaitError = V2SessionWaitErrors[keyof V2SessionWaitErrors] - -export type V2SessionWaitResponses = { - /** - * - */ - 204: void -} - -export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses] - -export type V2SessionContextData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/api/session/{sessionID}/context" -} - -export type V2SessionContextErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError - /** - * SessionNotFoundError - */ - 404: SessionNotFoundError - /** - * UnknownError - */ - 500: UnknownError1 -} - -export type V2SessionContextError = V2SessionContextErrors[keyof V2SessionContextErrors] - -export type V2SessionContextResponses = { - /** - * Success - */ - 200: Array -} - -export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] - -export type V2SessionMessagesData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - limit?: number - order?: "asc" | "desc" - /** - * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order. - */ - cursor?: string - } - url: "/api/session/{sessionID}/message" -} - -export type V2SessionMessagesErrors = { - /** - * InvalidCursorError | InvalidRequestError - */ - 400: InvalidCursorError | InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError - /** - * SessionNotFoundError - */ - 404: SessionNotFoundError - /** - * UnknownError - */ - 500: UnknownError1 -} - -export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors] - -export type V2SessionMessagesResponses = { - /** - * V2SessionMessagesResponse - */ - 200: V2SessionMessagesResponse -} - -export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] - -export type V2ModelListData = { - body?: never - path?: never - query?: { - location?: { - directory?: string - workspace?: string - } - } - url: "/api/model" -} - -export type V2ModelListErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError - /** - * ServiceUnavailableError - */ - 503: ServiceUnavailableError -} - -export type V2ModelListError = V2ModelListErrors[keyof V2ModelListErrors] - -export type V2ModelListResponses = { - /** - * Success - */ - 200: Array -} - -export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses] - -export type V2ProviderListData = { - body?: never - path?: never - query?: { - location?: { - directory?: string - workspace?: string - } - } - url: "/api/provider" -} - -export type V2ProviderListErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError - /** - * ServiceUnavailableError - */ - 503: ServiceUnavailableError -} - -export type V2ProviderListError = V2ProviderListErrors[keyof V2ProviderListErrors] - -export type V2ProviderListResponses = { - /** - * Success - */ - 200: Array -} - -export type V2ProviderListResponse = V2ProviderListResponses[keyof V2ProviderListResponses] - -export type V2ProviderGetData = { - body?: never - path: { - providerID: string - } - query?: { - location?: { - directory?: string - workspace?: string - } - } - url: "/api/provider/{providerID}" -} - -export type V2ProviderGetErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError - /** - * ProviderNotFoundError - */ - 404: ProviderNotFoundError - /** - * ServiceUnavailableError - */ - 503: ServiceUnavailableError -} - -export type V2ProviderGetError = V2ProviderGetErrors[keyof V2ProviderGetErrors] - -export type V2ProviderGetResponses = { - /** - * ProviderV2.PublicInfo - */ - 200: ProviderV2PublicInfo -} - -export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses] - -export type V2PermissionRequestListData = { - body?: never - path?: never - query?: { - location?: { - directory?: string - workspace?: string - } - } - url: "/api/permission/request" -} - -export type V2PermissionRequestListErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError -} - -export type V2PermissionRequestListError = V2PermissionRequestListErrors[keyof V2PermissionRequestListErrors] - -export type V2PermissionRequestListResponses = { - /** - * Success - */ - 200: Array -} - -export type V2PermissionRequestListResponse = V2PermissionRequestListResponses[keyof V2PermissionRequestListResponses] - -export type V2SessionPermissionListData = { - body?: never - path: { - sessionID: string - } - query?: never - url: "/api/session/{sessionID}/permission/request" -} - -export type V2SessionPermissionListErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError - /** - * SessionNotFoundError - */ - 404: SessionNotFoundError -} - -export type V2SessionPermissionListError = V2SessionPermissionListErrors[keyof V2SessionPermissionListErrors] - -export type V2SessionPermissionListResponses = { - /** - * Success - */ - 200: Array -} - -export type V2SessionPermissionListResponse = V2SessionPermissionListResponses[keyof V2SessionPermissionListResponses] - -export type V2SessionPermissionReplyData = { - body: { - reply: PermissionV2Reply - message?: string - } - path: { - sessionID: string - requestID: string - } - query?: never - url: "/api/session/{sessionID}/permission/request/{requestID}/reply" -} - -export type V2SessionPermissionReplyErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError - /** - * SessionNotFoundError | PermissionNotFoundError - */ - 404: SessionNotFoundError | PermissionNotFoundError -} - -export type V2SessionPermissionReplyError = V2SessionPermissionReplyErrors[keyof V2SessionPermissionReplyErrors] - -export type V2SessionPermissionReplyResponses = { - /** - * - */ - 204: void -} - -export type V2SessionPermissionReplyResponse = - V2SessionPermissionReplyResponses[keyof V2SessionPermissionReplyResponses] - -export type V2PermissionSavedListData = { - body?: never - path?: never - query?: { - projectID?: string - } - url: "/api/permission/saved" -} - -export type V2PermissionSavedListErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError -} - -export type V2PermissionSavedListError = V2PermissionSavedListErrors[keyof V2PermissionSavedListErrors] - -export type V2PermissionSavedListResponses = { - /** - * Success - */ - 200: Array -} - -export type V2PermissionSavedListResponse = V2PermissionSavedListResponses[keyof V2PermissionSavedListResponses] - -export type V2PermissionSavedRemoveData = { - body?: never - path: { - id: string - } - query?: never - url: "/api/permission/saved/{id}" -} - -export type V2PermissionSavedRemoveErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError -} - -export type V2PermissionSavedRemoveError = V2PermissionSavedRemoveErrors[keyof V2PermissionSavedRemoveErrors] - -export type V2PermissionSavedRemoveResponses = { - /** - * - */ - 204: void -} - -export type V2PermissionSavedRemoveResponse = V2PermissionSavedRemoveResponses[keyof V2PermissionSavedRemoveResponses] - -export type V2FsReadData = { - body?: never - path?: never - query: { - location?: { - directory?: string - workspace?: string - } - path: string - reference?: string - } - url: "/api/fs/read" -} - -export type V2FsReadErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError -} - -export type V2FsReadError = V2FsReadErrors[keyof V2FsReadErrors] - -export type V2FsReadResponses = { - /** - * Success - */ - 200: FileSystemTextContent | FileSystemBinaryContent -} - -export type V2FsReadResponse = V2FsReadResponses[keyof V2FsReadResponses] - -export type V2FsListData = { - body?: never - path?: never - query?: { - location?: { - directory?: string - workspace?: string - } - path?: string - reference?: string - } - url: "/api/fs/list" -} - -export type V2FsListErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError -} - -export type V2FsListError = V2FsListErrors[keyof V2FsListErrors] - -export type V2FsListResponses = { - /** - * Success - */ - 200: Array -} - -export type V2FsListResponse = V2FsListResponses[keyof V2FsListResponses] - -export type V2QuestionRequestListData = { - body?: never - path?: never - query?: { - location?: { - directory?: string - workspace?: string - } - } - url: "/api/question/request" -} - -export type V2QuestionRequestListErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError -} - -export type V2QuestionRequestListError = V2QuestionRequestListErrors[keyof V2QuestionRequestListErrors] - -export type V2QuestionRequestListResponses = { - /** - * Success - */ - 200: Array -} - -export type V2QuestionRequestListResponse = V2QuestionRequestListResponses[keyof V2QuestionRequestListResponses] - -export type V2SessionQuestionReplyData = { - body: QuestionV2Reply - path: { - sessionID: string - requestID: string - } - query?: never - url: "/api/session/{sessionID}/question/request/{requestID}/reply" -} - -export type V2SessionQuestionReplyErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError - /** - * SessionNotFoundError | QuestionNotFoundError - */ - 404: SessionNotFoundError | QuestionNotFoundError -} - -export type V2SessionQuestionReplyError = V2SessionQuestionReplyErrors[keyof V2SessionQuestionReplyErrors] - -export type V2SessionQuestionReplyResponses = { - /** - * - */ - 204: void -} - -export type V2SessionQuestionReplyResponse = V2SessionQuestionReplyResponses[keyof V2SessionQuestionReplyResponses] - -export type V2SessionQuestionRejectData = { - body?: never - path: { - sessionID: string - requestID: string - } - query?: never - url: "/api/session/{sessionID}/question/request/{requestID}/reject" -} - -export type V2SessionQuestionRejectErrors = { - /** - * InvalidRequestError - */ - 400: InvalidRequestError - /** - * UnauthorizedError - */ - 401: UnauthorizedError - /** - * SessionNotFoundError | QuestionNotFoundError - */ - 404: SessionNotFoundError | QuestionNotFoundError -} - -export type V2SessionQuestionRejectError = V2SessionQuestionRejectErrors[keyof V2SessionQuestionRejectErrors] - -export type V2SessionQuestionRejectResponses = { - /** - * - */ - 204: void -} - -export type V2SessionQuestionRejectResponse = V2SessionQuestionRejectResponses[keyof V2SessionQuestionRejectResponses] - export type TuiAppendPromptData = { body?: { text: string @@ -9632,6 +9024,928 @@ export type ExperimentalWorkspaceWarpResponses = { export type ExperimentalWorkspaceWarpResponse = ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses] +export type V2HealthGetData = { + body?: never + path?: never + query?: never + url: "/api/health" +} + +export type V2HealthGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2HealthGetError = V2HealthGetErrors[keyof V2HealthGetErrors] + +export type V2HealthGetResponses = { + /** + * Success + */ + 200: { + healthy: true + } +} + +export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses] + +export type V2AgentListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/agent" +} + +export type V2AgentListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2AgentListError = V2AgentListErrors[keyof V2AgentListErrors] + +export type V2AgentListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2AgentListResponse = V2AgentListResponses[keyof V2AgentListResponses] + +export type V2SessionListData = { + body?: never + path?: never + query?: { + workspace?: string + limit?: number + order?: "asc" | "desc" + search?: string + directory?: string + project?: string + subpath?: string + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. + */ + cursor?: string + } + url: "/api/session" +} + +export type V2SessionListErrors = { + /** + * InvalidCursorError | InvalidRequestError + */ + 400: InvalidCursorError | InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors] + +export type V2SessionListResponses = { + /** + * V2SessionsResponse + */ + 200: V2SessionsResponse +} + +export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] + +export type V2SessionPromptData = { + body: { + id?: string + prompt: Prompt + delivery?: "steer" | "queue" + resume?: boolean + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/prompt" +} + +export type V2SessionPromptErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * ConflictError + */ + 409: ConflictError +} + +export type V2SessionPromptError = V2SessionPromptErrors[keyof V2SessionPromptErrors] + +export type V2SessionPromptResponses = { + /** + * Success + */ + 200: { + data: SessionMessageUser + } +} + +export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] + +export type V2SessionCompactData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/compact" +} + +export type V2SessionCompactErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2SessionCompactError = V2SessionCompactErrors[keyof V2SessionCompactErrors] + +export type V2SessionCompactResponses = { + /** + * + */ + 204: void +} + +export type V2SessionCompactResponse = V2SessionCompactResponses[keyof V2SessionCompactResponses] + +export type V2SessionWaitData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/wait" +} + +export type V2SessionWaitErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2SessionWaitError = V2SessionWaitErrors[keyof V2SessionWaitErrors] + +export type V2SessionWaitResponses = { + /** + * + */ + 204: void +} + +export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses] + +export type V2SessionContextData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/context" +} + +export type V2SessionContextErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * UnknownError + */ + 500: UnknownError1 +} + +export type V2SessionContextError = V2SessionContextErrors[keyof V2SessionContextErrors] + +export type V2SessionContextResponses = { + /** + * Success + */ + 200: { + data: Array + } +} + +export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] + +export type V2SessionMessagesData = { + body?: never + path: { + sessionID: string + } + query?: { + limit?: number + order?: "asc" | "desc" + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order. + */ + cursor?: string + } + url: "/api/session/{sessionID}/message" +} + +export type V2SessionMessagesErrors = { + /** + * InvalidCursorError | InvalidRequestError + */ + 400: InvalidCursorError | InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * UnknownError + */ + 500: UnknownError1 +} + +export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors] + +export type V2SessionMessagesResponses = { + /** + * V2SessionMessagesResponse + */ + 200: V2SessionMessagesResponse +} + +export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] + +export type V2ModelListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/model" +} + +export type V2ModelListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2ModelListError = V2ModelListErrors[keyof V2ModelListErrors] + +export type V2ModelListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses] + +export type V2ProviderListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/provider" +} + +export type V2ProviderListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2ProviderListError = V2ProviderListErrors[keyof V2ProviderListErrors] + +export type V2ProviderListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2ProviderListResponse = V2ProviderListResponses[keyof V2ProviderListResponses] + +export type V2ProviderGetData = { + body?: never + path: { + providerID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/provider/{providerID}" +} + +export type V2ProviderGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ProviderNotFoundError + */ + 404: ProviderNotFoundError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2ProviderGetError = V2ProviderGetErrors[keyof V2ProviderGetErrors] + +export type V2ProviderGetResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: ProviderV2Info + } +} + +export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses] + +export type V2PermissionRequestListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/permission/request" +} + +export type V2PermissionRequestListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PermissionRequestListError = V2PermissionRequestListErrors[keyof V2PermissionRequestListErrors] + +export type V2PermissionRequestListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2PermissionRequestListResponse = V2PermissionRequestListResponses[keyof V2PermissionRequestListResponses] + +export type V2SessionPermissionListData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/permission/request" +} + +export type V2SessionPermissionListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionPermissionListError = V2SessionPermissionListErrors[keyof V2SessionPermissionListErrors] + +export type V2SessionPermissionListResponses = { + /** + * Success + */ + 200: { + data: Array + } +} + +export type V2SessionPermissionListResponse = V2SessionPermissionListResponses[keyof V2SessionPermissionListResponses] + +export type V2SessionPermissionReplyData = { + body: { + reply: PermissionV2Reply + message?: string + } + path: { + sessionID: string + requestID: string + } + query?: never + url: "/api/session/{sessionID}/permission/request/{requestID}/reply" +} + +export type V2SessionPermissionReplyErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | PermissionNotFoundError + */ + 404: SessionNotFoundError | PermissionNotFoundError +} + +export type V2SessionPermissionReplyError = V2SessionPermissionReplyErrors[keyof V2SessionPermissionReplyErrors] + +export type V2SessionPermissionReplyResponses = { + /** + * + */ + 204: void +} + +export type V2SessionPermissionReplyResponse = + V2SessionPermissionReplyResponses[keyof V2SessionPermissionReplyResponses] + +export type V2PermissionSavedListData = { + body?: never + path?: never + query?: { + projectID?: string + } + url: "/api/permission/saved" +} + +export type V2PermissionSavedListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PermissionSavedListError = V2PermissionSavedListErrors[keyof V2PermissionSavedListErrors] + +export type V2PermissionSavedListResponses = { + /** + * Success + */ + 200: { + data: Array + } +} + +export type V2PermissionSavedListResponse = V2PermissionSavedListResponses[keyof V2PermissionSavedListResponses] + +export type V2PermissionSavedRemoveData = { + body?: never + path: { + id: string + } + query?: never + url: "/api/permission/saved/{id}" +} + +export type V2PermissionSavedRemoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PermissionSavedRemoveError = V2PermissionSavedRemoveErrors[keyof V2PermissionSavedRemoveErrors] + +export type V2PermissionSavedRemoveResponses = { + /** + * + */ + 204: void +} + +export type V2PermissionSavedRemoveResponse = V2PermissionSavedRemoveResponses[keyof V2PermissionSavedRemoveResponses] + +export type V2FsReadData = { + body?: never + path?: never + query: { + location?: { + directory?: string + workspace?: string + } + path: string + reference?: string + } + url: "/api/fs/read" +} + +export type V2FsReadErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2FsReadError = V2FsReadErrors[keyof V2FsReadErrors] + +export type V2FsReadResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: FileSystemTextContent | FileSystemBinaryContent + } +} + +export type V2FsReadResponse = V2FsReadResponses[keyof V2FsReadResponses] + +export type V2FsListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + path?: string + reference?: string + } + url: "/api/fs/list" +} + +export type V2FsListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2FsListError = V2FsListErrors[keyof V2FsListErrors] + +export type V2FsListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2FsListResponse = V2FsListResponses[keyof V2FsListResponses] + +export type V2CommandListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/command" +} + +export type V2CommandListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2CommandListError = V2CommandListErrors[keyof V2CommandListErrors] + +export type V2CommandListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2CommandListResponse = V2CommandListResponses[keyof V2CommandListResponses] + +export type V2SkillListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/skill" +} + +export type V2SkillListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SkillListError = V2SkillListErrors[keyof V2SkillListErrors] + +export type V2SkillListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2SkillListResponse = V2SkillListResponses[keyof V2SkillListResponses] + +export type V2EventSubscribeData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/event" +} + +export type V2EventSubscribeErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2EventSubscribeError = V2EventSubscribeErrors[keyof V2EventSubscribeErrors] + +export type V2EventSubscribeResponses = { + /** + * Success + */ + 200: string +} + +export type V2EventSubscribeResponse = V2EventSubscribeResponses[keyof V2EventSubscribeResponses] + +export type V2QuestionRequestListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/question/request" +} + +export type V2QuestionRequestListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2QuestionRequestListError = V2QuestionRequestListErrors[keyof V2QuestionRequestListErrors] + +export type V2QuestionRequestListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2QuestionRequestListResponse = V2QuestionRequestListResponses[keyof V2QuestionRequestListResponses] + +export type V2SessionQuestionReplyData = { + body: QuestionV2Reply + path: { + sessionID: string + requestID: string + } + query?: never + url: "/api/session/{sessionID}/question/request/{requestID}/reply" +} + +export type V2SessionQuestionReplyErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | QuestionNotFoundError + */ + 404: SessionNotFoundError | QuestionNotFoundError +} + +export type V2SessionQuestionReplyError = V2SessionQuestionReplyErrors[keyof V2SessionQuestionReplyErrors] + +export type V2SessionQuestionReplyResponses = { + /** + * + */ + 204: void +} + +export type V2SessionQuestionReplyResponse = V2SessionQuestionReplyResponses[keyof V2SessionQuestionReplyResponses] + +export type V2SessionQuestionRejectData = { + body?: never + path: { + sessionID: string + requestID: string + } + query?: never + url: "/api/session/{sessionID}/question/request/{requestID}/reject" +} + +export type V2SessionQuestionRejectErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | QuestionNotFoundError + */ + 404: SessionNotFoundError | QuestionNotFoundError +} + +export type V2SessionQuestionRejectError = V2SessionQuestionRejectErrors[keyof V2SessionQuestionRejectErrors] + +export type V2SessionQuestionRejectResponses = { + /** + * + */ + 204: void +} + +export type V2SessionQuestionRejectResponse = V2SessionQuestionRejectResponses[keyof V2SessionQuestionRejectResponses] + export type PtyConnectData = { body?: never path: { diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 000000000..0eb698857 --- /dev/null +++ b/packages/server/package.json @@ -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:" + } +} diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts new file mode 100644 index 000000000..d29d23313 --- /dev/null +++ b/packages/server/src/api.ts @@ -0,0 +1,39 @@ +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" +import { QuestionGroup, SessionQuestionGroup } from "./groups/v2/question" + +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) + .add(QuestionGroup) + .add(SessionQuestionGroup) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + .middleware(SchemaErrorMiddleware) diff --git a/packages/server/src/auth.ts b/packages/server/src/auth.ts new file mode 100644 index 000000000..6758fda3d --- /dev/null +++ b/packages/server/src/auth.ts @@ -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 + readonly username: string +} + +export class Config extends Context.Service()("@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 } +} diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts new file mode 100644 index 000000000..2b1dcaf11 --- /dev/null +++ b/packages/server/src/errors.ts @@ -0,0 +1,86 @@ +import { Schema } from "effect" + +export class InvalidRequestError extends Schema.TaggedErrorClass()( + "InvalidRequestError", + { + message: Schema.String, + kind: Schema.optional(Schema.String), + field: Schema.optional(Schema.String), + }, + { httpApiStatus: 400 }, +) {} + +export class UnauthorizedError extends Schema.TaggedErrorClass()( + "UnauthorizedError", + { message: Schema.String }, + { httpApiStatus: 401 }, +) {} + +export class ConflictError extends Schema.TaggedErrorClass()( + "ConflictError", + { + message: Schema.String, + resource: Schema.optional(Schema.String), + }, + { httpApiStatus: 409 }, +) {} + +export class ServiceUnavailableError extends Schema.TaggedErrorClass()( + "ServiceUnavailableError", + { + message: Schema.String, + service: Schema.optional(Schema.String), + }, + { httpApiStatus: 503 }, +) {} + +export class UnknownError extends Schema.TaggedErrorClass()( + "UnknownError", + { + message: Schema.String, + ref: Schema.optional(Schema.String), + }, + { httpApiStatus: 500 }, +) {} + +export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "ProviderNotFoundError", + { + providerID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class SessionNotFoundError extends Schema.TaggedErrorClass()( + "SessionNotFoundError", + { + sessionID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class InvalidCursorError extends Schema.TaggedErrorClass()( + "InvalidCursorError", + { message: Schema.String }, + { httpApiStatus: 400 }, +) {} + +export class PermissionNotFoundError extends Schema.TaggedErrorClass()( + "PermissionNotFoundError", + { + requestID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class QuestionNotFoundError extends Schema.TaggedErrorClass()( + "QuestionNotFoundError", + { + requestID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} diff --git a/packages/server/src/groups/v2/agent.ts b/packages/server/src/groups/v2/agent.ts new file mode 100644 index 000000000..1fdc33d37 --- /dev/null +++ b/packages/server/src/groups/v2/agent.ts @@ -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) diff --git a/packages/server/src/groups/v2/command.ts b/packages/server/src/groups/v2/command.ts new file mode 100644 index 000000000..98d84e156 --- /dev/null +++ b/packages/server/src/groups/v2/command.ts @@ -0,0 +1,30 @@ +import { CommandV2 } from "@opencode-ai/core/command" +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 CommandGroup = HttpApiGroup.make("v2.command") + .add( + HttpApiEndpoint.get("commands", "/api/command", { + query: LocationQuery, + success: Location.response(Schema.Array(CommandV2.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.command.list", + summary: "List v2 commands", + description: "Retrieve currently registered v2 commands.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2 commands", + description: "Experimental v2 command routes.", + }), + ) + .middleware(V2LocationMiddleware) + .middleware(V2Authorization) diff --git a/packages/server/src/groups/v2/event.ts b/packages/server/src/groups/v2/event.ts new file mode 100644 index 000000000..181ff38ff --- /dev/null +++ b/packages/server/src/groups/v2/event.ts @@ -0,0 +1,36 @@ +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { V2Authorization } from "../../middleware/authorization" +import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location" + +const Event = Schema.Struct({ + id: EventV2.ID, + type: Schema.String, + location: Location.Info.pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + version: Schema.Number.pipe(Schema.optional), + data: Schema.Unknown, +}) + +export const EventGroup = HttpApiGroup.make("v2.event") + .add( + HttpApiEndpoint.get("events", "/api/event", { + query: LocationQuery, + success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.event.subscribe", + summary: "Subscribe to v2 events", + description: "Subscribe to native EventV2 payloads for a location.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "v2 events", description: "Experimental v2 event stream route." })) + .middleware(V2LocationMiddleware) + .middleware(V2Authorization) + +export type Event = typeof Event.Type diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts b/packages/server/src/groups/v2/fs.ts similarity index 90% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts rename to packages/server/src/groups/v2/fs.ts index b50d2466d..81ea932f8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts +++ b/packages/server/src/groups/v2/fs.ts @@ -1,4 +1,5 @@ import { FileSystem } from "@opencode-ai/core/filesystem" +import { Location } from "@opencode-ai/core/location" import { RelativePath } from "@opencode-ai/core/schema" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -21,7 +22,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs") .add( HttpApiEndpoint.get("read", "/api/fs/read", { query: ReadQuery, - success: FileSystem.Content, + success: Location.response(FileSystem.Content), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( @@ -35,7 +36,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs") .add( HttpApiEndpoint.get("list", "/api/fs/list", { query: ListQuery, - success: Schema.Array(FileSystem.Entry), + success: Location.response(Schema.Array(FileSystem.Entry)), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( diff --git a/packages/server/src/groups/v2/health.ts b/packages/server/src/groups/v2/health.ts new file mode 100644 index 000000000..9ad38210d --- /dev/null +++ b/packages/server/src/groups/v2/health.ts @@ -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) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts b/packages/server/src/groups/v2/location.ts similarity index 81% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts rename to packages/server/src/groups/v2/location.ts index 024069c84..90da51e88 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts +++ b/packages/server/src/groups/v2/location.ts @@ -1,9 +1,12 @@ 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" import { FileSystem } from "@opencode-ai/core/filesystem" import { PermissionV2 } from "@opencode-ai/core/permission" import { ProjectReference } from "@opencode-ai/core/project-reference" +import { SkillV2 } from "@opencode-ai/core/skill" import { AbsolutePath } from "@opencode-ai/core/schema" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { WorkspaceV2 } from "@opencode-ai/core/workspace" @@ -36,15 +39,33 @@ export const locationQueryOpenApi = OpenApi.annotations({ }, }) +export function response(data: Effect.Effect) { + return Effect.gen(function* () { + const location = yield* Location.Service + return { + location: new Location.Info({ + directory: location.directory, + workspaceID: location.workspaceID, + project: location.project, + }), + data: yield* data, + } + }) +} + export class V2LocationMiddleware extends HttpApiMiddleware.Service< V2LocationMiddleware, { provides: | Catalog.Service + | AgentV2.Service + | CommandV2.Service + | Location.Service | PluginBoot.Service | PermissionV2.Service | ProjectReference.Service | FileSystem.Service + | SkillV2.Service | QuestionV2.Service } >()("@opencode/ExperimentalHttpApiV2Location") {} diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/server/src/groups/v2/message.ts similarity index 89% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts rename to packages/server/src/groups/v2/message.ts index be2fdb5ba..2fefdee47 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/server/src/groups/v2/message.ts @@ -1,13 +1,11 @@ -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" export const MessagesQuery = Schema.Struct({ - ...WorkspaceRoutingQueryFields, limit: Schema.optional( Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), ).annotate({ @@ -27,10 +25,10 @@ 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: Schema.Struct({ - items: Schema.Array(SessionMessage.Message), + data: Schema.Array(SessionMessage.Message), cursor: Schema.Struct({ previous: Schema.String.pipe(Schema.optional), next: Schema.String.pipe(Schema.optional), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts b/packages/server/src/groups/v2/model.ts similarity index 89% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts rename to packages/server/src/groups/v2/model.ts index 6d6315361..bc210f1c6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts +++ b/packages/server/src/groups/v2/model.ts @@ -1,4 +1,5 @@ import { ModelV2 } from "@opencode-ai/core/model" +import { Location } from "@opencode-ai/core/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { ServiceUnavailableError } from "../../errors" @@ -9,7 +10,7 @@ export const ModelGroup = HttpApiGroup.make("v2.model") .add( HttpApiEndpoint.get("models", "/api/model", { query: LocationQuery, - success: Schema.Array(ModelV2.PublicInfo), + success: Location.response(Schema.Array(ModelV2.Info)), error: ServiceUnavailableError, }) .annotateMerge(locationQueryOpenApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts b/packages/server/src/groups/v2/permission.ts similarity index 93% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts rename to packages/server/src/groups/v2/permission.ts index c1f78089a..1e887ffc0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts +++ b/packages/server/src/groups/v2/permission.ts @@ -1,4 +1,5 @@ import { PermissionV2 } from "@opencode-ai/core/permission" +import { Location } from "@opencode-ai/core/location" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { ProjectV2 } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" @@ -12,7 +13,7 @@ export const PermissionGroup = HttpApiGroup.make("v2.permission") .add( HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { query: LocationQuery, - success: Schema.Array(PermissionV2.Request), + success: Location.response(Schema.Array(PermissionV2.Request)), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( @@ -31,7 +32,7 @@ export const SessionPermissionGroup = HttpApiGroup.make("v2.session.permission") .add( HttpApiEndpoint.get("sessionPermissionRequests", "/api/session/:sessionID/permission/request", { params: { sessionID: SessionV2.ID }, - success: Schema.Array(PermissionV2.Request), + success: Schema.Struct({ data: Schema.Array(PermissionV2.Request) }), error: SessionNotFoundError, }).annotateMerge( OpenApi.annotations({ @@ -67,7 +68,7 @@ export const PermissionSavedGroup = HttpApiGroup.make("v2.permission.saved") .add( HttpApiEndpoint.get("savedPermissions", "/api/permission/saved", { query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }), - success: Schema.Array(PermissionSaved.Info), + success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }), }).annotateMerge( OpenApi.annotations({ identifier: "v2.permission.saved.list", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts b/packages/server/src/groups/v2/provider.ts similarity index 90% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts rename to packages/server/src/groups/v2/provider.ts index 0075e0db1..6498af016 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts +++ b/packages/server/src/groups/v2/provider.ts @@ -1,4 +1,5 @@ import { ProviderV2 } from "@opencode-ai/core/provider" +import { Location } from "@opencode-ai/core/location" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors" @@ -9,7 +10,7 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider") .add( HttpApiEndpoint.get("providers", "/api/provider", { query: LocationQuery, - success: Schema.Array(ProviderV2.PublicInfo), + success: Location.response(Schema.Array(ProviderV2.Info)), error: ServiceUnavailableError, }) .annotateMerge(locationQueryOpenApi) @@ -25,7 +26,7 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider") HttpApiEndpoint.get("provider", "/api/provider/:providerID", { params: { providerID: ProviderV2.ID }, query: LocationQuery, - success: ProviderV2.PublicInfo, + success: Location.response(ProviderV2.Info), error: [ProviderNotFoundError, ServiceUnavailableError], }) .annotateMerge(locationQueryOpenApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/question.ts b/packages/server/src/groups/v2/question.ts similarity index 95% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/question.ts rename to packages/server/src/groups/v2/question.ts index b09f209bf..6269a7f16 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/question.ts +++ b/packages/server/src/groups/v2/question.ts @@ -1,4 +1,5 @@ import { QuestionV2 } from "@opencode-ai/core/question" +import { Location } from "@opencode-ai/core/location" import { SessionV2 } from "@opencode-ai/core/session" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" @@ -10,7 +11,7 @@ export const QuestionGroup = HttpApiGroup.make("v2.question") .add( HttpApiEndpoint.get("questionRequests", "/api/question/request", { query: LocationQuery, - success: Schema.Array(QuestionV2.Request), + success: Location.response(Schema.Array(QuestionV2.Request)), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/server/src/groups/v2/session.ts similarity index 91% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts rename to packages/server/src/groups/v2/session.ts index 59b14eee3..a830e4bf3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/server/src/groups/v2/session.ts @@ -1,4 +1,3 @@ -import { SessionID } from "@/session/schema" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionInput } from "@opencode-ai/core/session/input" import { Prompt } from "@opencode-ai/core/session/prompt" @@ -17,7 +16,6 @@ import { UnknownError, } from "../../errors" import { V2Authorization } from "../../middleware/authorization" -import { WorkspaceRoutingQuery } from "../../middleware/workspace-routing" const SessionsQueryFields = { workspace: WorkspaceV2.ID.pipe(Schema.optional), @@ -91,7 +89,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") HttpApiEndpoint.get("sessions", "/api/session", { query: SessionsQuery, success: Schema.Struct({ - items: Schema.Array(SessionV2.Info), + data: Schema.Array(SessionV2.Info), cursor: Schema.Struct({ previous: SessionsCursor.pipe(Schema.optional), next: SessionsCursor.pipe(Schema.optional), @@ -109,15 +107,14 @@ 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({ id: SessionMessage.ID.pipe(Schema.optional), prompt: Prompt, delivery: SessionInput.Delivery.pipe(Schema.optional), resume: Schema.Boolean.pipe(Schema.optional), }), - success: SessionMessage.User, + success: Schema.Struct({ data: SessionMessage.User }), error: [ConflictError, SessionNotFoundError], }).annotateMerge( OpenApi.annotations({ @@ -129,8 +126,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( @@ -143,8 +139,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( @@ -157,9 +152,8 @@ export const SessionGroup = HttpApiGroup.make("v2.session") ) .add( HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { - params: { sessionID: SessionID }, - query: WorkspaceRoutingQuery, - success: Schema.Array(SessionMessage.Message), + params: { sessionID: SessionV2.ID }, + success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), error: [SessionNotFoundError, UnknownError], }).annotateMerge( OpenApi.annotations({ diff --git a/packages/server/src/groups/v2/skill.ts b/packages/server/src/groups/v2/skill.ts new file mode 100644 index 000000000..0163c171c --- /dev/null +++ b/packages/server/src/groups/v2/skill.ts @@ -0,0 +1,30 @@ +import { SkillV2 } from "@opencode-ai/core/skill" +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 SkillGroup = HttpApiGroup.make("v2.skill") + .add( + HttpApiEndpoint.get("skills", "/api/skill", { + query: LocationQuery, + success: Location.response(Schema.Array(SkillV2.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.skill.list", + summary: "List v2 skills", + description: "Retrieve currently registered v2 skills.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2 skills", + description: "Experimental v2 skill routes.", + }), + ) + .middleware(V2LocationMiddleware) + .middleware(V2Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/server/src/handlers.ts similarity index 63% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts rename to packages/server/src/handlers.ts index 18ff8bd0a..be1444a66 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts +++ b/packages/server/src/handlers.ts @@ -1,21 +1,26 @@ import { SessionV2 } from "@opencode-ai/core/session" -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 { Layer } from "effect" +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" +import { questionHandlers, sessionQuestionHandlers } from "./handlers/v2/question" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" import { ProjectV2 } from "@opencode-ai/core/project" import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionStore } from "@opencode-ai/core/session/store" -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 { questionHandlers, sessionQuestionHandlers } from "./v2/question" const routedSessions = SessionV2.layer.pipe( Layer.provide(SessionProjector.layer), @@ -29,6 +34,8 @@ const routedSessions = SessionV2.layer.pipe( ) export const v2Handlers = Layer.mergeAll( + healthHandlers, + agentHandlers, sessionHandlers, messageHandlers, modelHandlers, @@ -37,6 +44,9 @@ export const v2Handlers = Layer.mergeAll( sessionPermissionHandlers, savedPermissionHandlers, fileSystemHandlers, + commandHandlers, + skillHandlers, + eventHandlers, questionHandlers, sessionQuestionHandlers, ).pipe( diff --git a/packages/server/src/handlers/v2/agent.ts b/packages/server/src/handlers/v2/agent.ts new file mode 100644 index 000000000..ae759e0a1 --- /dev/null +++ b/packages/server/src/handlers/v2/agent.ts @@ -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())) + }), + ), +) diff --git a/packages/server/src/handlers/v2/command.ts b/packages/server/src/handlers/v2/command.ts new file mode 100644 index 000000000..551ad4bce --- /dev/null +++ b/packages/server/src/handlers/v2/command.ts @@ -0,0 +1,9 @@ +import { CommandV2 } from "@opencode-ai/core/command" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { V2Api } from "../../api" +import { response } from "../../groups/v2/location" + +export const commandHandlers = HttpApiBuilder.group(V2Api, "v2.command", (handlers) => + handlers.handle("commands", () => response(CommandV2.Service.use((command) => command.list()))), +) diff --git a/packages/server/src/handlers/v2/event.ts b/packages/server/src/handlers/v2/event.ts new file mode 100644 index 000000000..c13fbcbeb --- /dev/null +++ b/packages/server/src/handlers/v2/event.ts @@ -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", + }, + }, + ) + }), + ) + }), +) diff --git a/packages/server/src/handlers/v2/fs.ts b/packages/server/src/handlers/v2/fs.ts new file mode 100644 index 000000000..87c2dd8a1 --- /dev/null +++ b/packages/server/src/handlers/v2/fs.ts @@ -0,0 +1,13 @@ +import { FileSystem } from "@opencode-ai/core/filesystem" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { V2Api } from "../../api" +import { response } from "../../groups/v2/location" + +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)))) + .handle("list", (ctx) => response(FileSystem.Service.use((fs) => fs.list(ctx.query)))) + }), +) diff --git a/packages/server/src/handlers/v2/health.ts b/packages/server/src/handlers/v2/health.ts new file mode 100644 index 000000000..5d66e5f25 --- /dev/null +++ b/packages/server/src/handlers/v2/health.ts @@ -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 })), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/server/src/handlers/v2/message.ts similarity index 94% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts rename to packages/server/src/handlers/v2/message.ts index e3b28aa43..3cb26080e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/server/src/handlers/v2/message.ts @@ -2,7 +2,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionV2 } from "@opencode-ai/core/session" import { Effect, Schema } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { InstanceHttpApi } from "../../api" +import { V2Api } from "../../api" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors" const DefaultMessagesLimit = 50 @@ -24,7 +24,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 @@ -72,7 +72,7 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message const first = messages[0] const last = messages.at(-1) return { - items: messages, + data: messages, cursor: { previous: first ? cursor.encode(first, order, "previous") : undefined, next: last ? cursor.encode(last, order, "next") : undefined, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts b/packages/server/src/handlers/v2/model.ts similarity index 73% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts rename to packages/server/src/handlers/v2/model.ts index 0a39e04c5..8e7870552 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts +++ b/packages/server/src/handlers/v2/model.ts @@ -1,17 +1,17 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginBoot } from "@opencode-ai/core/plugin/boot" -import { ModelV2 } from "@opencode-ai/core/model" 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" const catalogUnavailable = new ServiceUnavailableError({ message: "Model catalog is unavailable", 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", @@ -19,7 +19,7 @@ export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", ( const catalog = yield* Catalog.Service const pluginBoot = yield* PluginBoot.Service yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) - return (yield* catalog.model.available()).map(ModelV2.toPublic) + return yield* response(catalog.model.available()) }), ) }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts b/packages/server/src/handlers/v2/permission.ts similarity index 83% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts rename to packages/server/src/handlers/v2/permission.ts index 8808042a1..5b2e4a889 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts +++ b/packages/server/src/handlers/v2/permission.ts @@ -7,25 +7,26 @@ 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" 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", Effect.fn(function* () { - return yield* (yield* PermissionV2.Service).list() + return yield* response((yield* PermissionV2.Service).list()) }), ) }), ) -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 @@ -61,7 +62,7 @@ export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, " "sessionPermissionRequests", Effect.fn(function* (ctx) { return yield* withSessionPermission(ctx.params.sessionID, (permission) => - permission.forSession(ctx.params.sessionID), + permission.forSession(ctx.params.sessionID).pipe(Effect.map((data) => ({ data }))), ) }), ) @@ -84,14 +85,14 @@ 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 .handle( "savedPermissions", Effect.fn(function* (ctx) { - return yield* saved.list({ projectID: ctx.query.projectID }) + return { data: yield* saved.list({ projectID: ctx.query.projectID }) } }), ) .handle( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts b/packages/server/src/handlers/v2/provider.ts similarity index 80% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts rename to packages/server/src/handlers/v2/provider.ts index bf3c5b52b..6d6783754 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts +++ b/packages/server/src/handlers/v2/provider.ts @@ -3,15 +3,16 @@ import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { ProviderV2 } from "@opencode-ai/core/provider" 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" const catalogUnavailable = new ServiceUnavailableError({ message: "Provider catalog is unavailable", 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( @@ -20,7 +21,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provid const catalog = yield* Catalog.Service const pluginBoot = yield* PluginBoot.Service yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) - return (yield* catalog.provider.available()).map(ProviderV2.toPublic) + return yield* response(catalog.provider.available()) }), ) .handle( @@ -29,8 +30,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provid const catalog = yield* Catalog.Service const pluginBoot = yield* PluginBoot.Service yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) - return yield* catalog.provider.get(ctx.params.providerID).pipe( - Effect.map(ProviderV2.toPublic), + return yield* response(catalog.provider.get(ctx.params.providerID)).pipe( Effect.catchTag("CatalogV2.ProviderNotFound", (error) => Effect.fail( new ProviderNotFoundError({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/question.ts b/packages/server/src/handlers/v2/question.ts similarity index 90% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/question.ts rename to packages/server/src/handlers/v2/question.ts index 30eac559f..39283d58c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/question.ts +++ b/packages/server/src/handlers/v2/question.ts @@ -6,25 +6,26 @@ 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 { QuestionNotFoundError, SessionNotFoundError } from "../../errors" +import { response } from "../../groups/v2/location" function missingRequest(id: QuestionV2.ID) { return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` }) } -export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.question", (handlers) => +export const questionHandlers = HttpApiBuilder.group(V2Api, "v2.question", (handlers) => Effect.gen(function* () { return handlers.handle( "questionRequests", Effect.fn(function* () { - return yield* (yield* QuestionV2.Service).list() + return yield* response((yield* QuestionV2.Service).list()) }), ) }), ) -export const sessionQuestionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session.question", (handlers) => +export const sessionQuestionHandlers = HttpApiBuilder.group(V2Api, "v2.session.question", (handlers) => Effect.gen(function* () { const { db } = yield* Database.Service const locations = yield* LocationServiceMap diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/server/src/handlers/v2/session.ts similarity index 69% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts rename to packages/server/src/handlers/v2/session.ts index abb7be737..6edc27d39 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/server/src/handlers/v2/session.ts @@ -1,7 +1,7 @@ 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 { ConflictError, @@ -13,7 +13,7 @@ import { 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 @@ -35,7 +35,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session const first = sessions[0] const last = sessions.at(-1) return { - items: sessions, + data: sessions, cursor: { previous: first ? SessionsCursor.make({ @@ -64,32 +64,34 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session .handle( "prompt", Effect.fn(function* (ctx) { - return yield* session - .prompt({ - sessionID: ctx.params.sessionID, - id: ctx.payload.id, - prompt: ctx.payload.prompt, - delivery: ctx.payload.delivery, - resume: ctx.payload.resume, - }) - .pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), + return { + data: yield* session + .prompt({ + sessionID: ctx.params.sessionID, + id: ctx.payload.id, + prompt: ctx.payload.prompt, + delivery: ctx.payload.delivery, + resume: ctx.payload.resume, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Session.PromptConflictError", (error) => + Effect.fail( + new ConflictError({ + message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`, + resource: error.messageID, + }), + ), ), ), - Effect.catchTag("Session.PromptConflictError", (error) => - Effect.fail( - new ConflictError({ - message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`, - resource: error.messageID, - }), - ), - ), - ) + } }), ) .handle( @@ -143,30 +145,32 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session .handle( "context", Effect.fn(function* (ctx) { - return yield* session.context(ctx.params.sessionID).pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - Effect.catchTag("Session.MessageDecodeError", (error) => { - const ref = `err_${crypto.randomUUID().slice(0, 8)}` - return Effect.logError("failed to decode v2 session message").pipe( - Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }), - Effect.andThen( - Effect.fail( - new UnknownError({ - message: "Unexpected server error. Check server logs for details.", - ref, - }), - ), + return { + data: yield* session.context(ctx.params.sessionID).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), ), - ) - }), - ) + ), + Effect.catchTag("Session.MessageDecodeError", (error) => { + const ref = `err_${crypto.randomUUID().slice(0, 8)}` + return Effect.logError("failed to decode v2 session message").pipe( + Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }), + Effect.andThen( + Effect.fail( + new UnknownError({ + message: "Unexpected server error. Check server logs for details.", + ref, + }), + ), + ), + ) + }), + ), + } }), ) }), diff --git a/packages/server/src/handlers/v2/skill.ts b/packages/server/src/handlers/v2/skill.ts new file mode 100644 index 000000000..a4e98cfda --- /dev/null +++ b/packages/server/src/handlers/v2/skill.ts @@ -0,0 +1,8 @@ +import { SkillV2 } from "@opencode-ai/core/skill" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { V2Api } from "../../api" +import { response } from "../../groups/v2/location" + +export const skillHandlers = HttpApiBuilder.group(V2Api, "v2.skill", (handlers) => + handlers.handle("skills", () => response(SkillV2.Service.use((skill) => skill.list()))), +) diff --git a/packages/server/src/middleware/authorization.ts b/packages/server/src/middleware/authorization.ts new file mode 100644 index 000000000..0411c60bf --- /dev/null +++ b/packages/server/src/middleware/authorization.ts @@ -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()( + "@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" }) + }), + ) + }), +) diff --git a/packages/server/src/middleware/schema-error.ts b/packages/server/src/middleware/schema-error.ts new file mode 100644 index 000000000..e4b21dd3a --- /dev/null +++ b/packages/server/src/middleware/schema-error.ts @@ -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()( + "@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 })) +}) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts new file mode 100644 index 000000000..52fbe6ed4 --- /dev/null +++ b/packages/server/src/routes.ts @@ -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 }) diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 000000000..00ef12546 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/script/publish.ts b/script/publish.ts index 46a265d5e..cc78c3ded 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -38,6 +38,9 @@ await prepareReleaseFiles() console.log("\n=== cli ===\n") await $`bun ./packages/opencode/script/publish.ts` +console.log("\n=== preview cli ===\n") +await $`bun ./packages/cli/script/publish.ts` + console.log("\n=== sdk ===\n") await $`bun ./packages/sdk/js/script/publish.ts`