From ec26b82b6b6951d8f4062208871368f3bcb0932d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Jun 2026 17:22:21 -0400 Subject: [PATCH 01/20] feat(core): add command registry --- packages/core/src/command.ts | 68 ++++++++++++++++++ packages/core/src/config.ts | 4 ++ packages/core/src/config/command.ts | 12 ++++ packages/core/src/config/plugin/command.ts | 82 ++++++++++++++++++++++ packages/core/src/location-layer.ts | 2 + packages/core/src/plugin/boot.ts | 7 ++ packages/core/src/v1/config/command.ts | 1 + packages/core/src/v1/config/migrate.ts | 1 + packages/core/test/command.test.ts | 56 +++++++++++++++ packages/core/test/config/command.test.ts | 81 +++++++++++++++++++++ packages/core/test/config/config.test.ts | 28 ++++++++ 11 files changed, 342 insertions(+) create mode 100644 packages/core/src/command.ts create mode 100644 packages/core/src/config/command.ts create mode 100644 packages/core/src/config/plugin/command.ts create mode 100644 packages/core/test/command.test.ts create mode 100644 packages/core/test/config/command.test.ts diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts new file mode 100644 index 0000000000..b9a5ae15d8 --- /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 81fde96209..47ba303698 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 0000000000..394079b1e9 --- /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 0000000000..d905529545 --- /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/location-layer.ts b/packages/core/src/location-layer.ts index c6f3cada8a..a55321e514 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" @@ -34,6 +35,7 @@ export class LocationServiceMap extends LayerMap.Service()(" ProjectReference.locationLayer, PluginV2.locationLayer, Catalog.locationLayer, + CommandV2.locationLayer, AgentV2.locationLayer, PluginBoot.locationLayer, PermissionV2.locationLayer, diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 554547fc8b..be62032f54 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" @@ -26,6 +28,7 @@ type Plugin = { id: PluginV2.ID effect: PluginV2.Effect< | Catalog.Service + | CommandV2.Service | Auth.Service | AgentV2.Service | Npm.Service @@ -50,6 +53,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 +72,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), @@ -93,6 +98,7 @@ export const layer = Layer.effect( 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 +116,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/v1/config/command.ts b/packages/core/src/v1/config/command.ts index 37bbdc44f3..281d530910 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 9b123ecd1d..5dea17a4ab 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/test/command.test.ts b/packages/core/test/command.test.ts new file mode 100644 index 0000000000..f2175743e4 --- /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 0000000000..da3bb749b4 --- /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 5b218dae52..465a415475 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()), From 332366a0243f83f2a15eb430d89c58fd777108b0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Jun 2026 17:31:09 -0400 Subject: [PATCH 02/20] feat(core): register built-in commands --- packages/core/src/plugin/boot.ts | 2 + packages/core/src/plugin/command.ts | 29 +++++ .../core/src/plugin/command/initialize.txt | 65 ++++++++++++ packages/core/src/plugin/command/review.txt | 100 ++++++++++++++++++ packages/core/test/plugin/command.test.ts | 44 ++++++++ .../routes/instance/httpapi/groups/v2.ts | 4 + .../instance/httpapi/groups/v2/command.ts | 29 +++++ .../instance/httpapi/groups/v2/location.ts | 20 +++- .../instance/httpapi/groups/v2/skill.ts | 29 +++++ .../routes/instance/httpapi/handlers/v2.ts | 4 + .../instance/httpapi/handlers/v2/command.ts | 8 ++ .../instance/httpapi/handlers/v2/skill.ts | 7 ++ .../test/server/httpapi-v2-location.test.ts | 40 +++++++ 13 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/plugin/command.ts create mode 100644 packages/core/src/plugin/command/initialize.txt create mode 100644 packages/core/src/plugin/command/review.txt create mode 100644 packages/core/test/plugin/command.test.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/command.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/skill.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/command.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/skill.ts create mode 100644 packages/opencode/test/server/httpapi-v2-location.test.ts diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index be62032f54..d7695f7785 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -18,6 +18,7 @@ import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { AccountPlugin } from "./account" import { AgentPlugin } from "./agent" +import { CommandPlugin } from "./command" import { ConfigProviderPlugin } from "../config/plugin/provider" import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" @@ -92,6 +93,7 @@ export const layer = Layer.effect( yield* add(EnvPlugin) yield* add(AccountPlugin) yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) for (const item of ProviderPlugins) { yield* add(item) } diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts new file mode 100644 index 0000000000..66386a2128 --- /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 0000000000..5fc073d61c --- /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 0000000000..071807ec87 --- /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/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts new file mode 100644 index 0000000000..f1136ee811 --- /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/opencode/src/server/routes/instance/httpapi/groups/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts index 6e704956b9..6fa0d23a4f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts @@ -5,6 +5,8 @@ import { ProviderGroup } from "./v2/provider" import { SessionGroup } from "./v2/session" import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission" import { FileSystemGroup } from "./v2/fs" +import { CommandGroup } from "./v2/command" +import { SkillGroup } from "./v2/skill" export const V2Api = HttpApi.make("v2") .add(SessionGroup) @@ -15,6 +17,8 @@ export const V2Api = HttpApi.make("v2") .add(SessionPermissionGroup) .add(PermissionSavedGroup) .add(FileSystemGroup) + .add(CommandGroup) + .add(SkillGroup) .annotateMerge( OpenApi.annotations({ title: "opencode experimental HttpApi", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/command.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/command.ts new file mode 100644 index 0000000000..b5edb2d4cf --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/command.ts @@ -0,0 +1,29 @@ +import { CommandV2 } from "@opencode-ai/core/command" +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: 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/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts index a760642e7f..c8389cd3f8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts @@ -1,13 +1,15 @@ import { Catalog } from "@opencode-ai/core/catalog" +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 { Effect, Layer, Schema } from "effect" -import { HttpServerRequest } from "effect/unstable/http" +import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" export const LocationQuery = Schema.Struct({ @@ -39,10 +41,12 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service< { provides: | Catalog.Service + | CommandV2.Service | PluginBoot.Service | PermissionV2.Service | ProjectReference.Service | FileSystem.Service + | SkillV2.Service } >()("@opencode/ExperimentalHttpApiV2Location") {} @@ -63,7 +67,19 @@ export const layer = Layer.effect( return V2LocationMiddleware.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - return yield* effect.pipe(Effect.provide(locations.get(ref(request)))) + return yield* Effect.gen(function* () { + const location = yield* Location.Service + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed( + HttpServerResponse.setHeaders(response, { + "x-opencode-directory": location.directory, + "x-opencode-project-id": location.project.id, + ...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}), + }), + ), + ) + return yield* effect + }).pipe(Effect.provide(locations.get(ref(request)))) }), ) }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/skill.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/skill.ts new file mode 100644 index 0000000000..b9f036a861 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/skill.ts @@ -0,0 +1,29 @@ +import { SkillV2 } from "@opencode-ai/core/skill" +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: 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/opencode/src/server/routes/instance/httpapi/handlers/v2.ts index 245d79a471..f168731f21 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -9,6 +9,8 @@ import { providerHandlers } from "./v2/provider" import { sessionHandlers } from "./v2/session" import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./v2/permission" import { fileSystemHandlers } from "./v2/fs" +import { commandHandlers } from "./v2/command" +import { skillHandlers } from "./v2/skill" export const v2Handlers = Layer.mergeAll( sessionHandlers, @@ -19,6 +21,8 @@ export const v2Handlers = Layer.mergeAll( sessionPermissionHandlers, savedPermissionHandlers, fileSystemHandlers, + commandHandlers, + skillHandlers, ).pipe( Layer.provide(v2LocationLayer), Layer.provide(LocationServiceMap.layer), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/command.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/command.ts new file mode 100644 index 0000000000..9b251e6584 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/command.ts @@ -0,0 +1,8 @@ +import { CommandV2 } from "@opencode-ai/core/command" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +export const commandHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.command", (handlers) => + handlers.handle("commands", () => CommandV2.Service.use((command) => command.list())), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/skill.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/skill.ts new file mode 100644 index 0000000000..f382ac48fc --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/skill.ts @@ -0,0 +1,7 @@ +import { SkillV2 } from "@opencode-ai/core/skill" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +export const skillHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.skill", (handlers) => + handlers.handle("skills", () => SkillV2.Service.use((skill) => skill.list())), +) 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 0000000000..eeedace7f6 --- /dev/null +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Context } 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) { + return HttpApiApp.webHandler().handler( + new Request(`http://localhost${route}`, { + headers: { + "x-opencode-directory": directory, + }, + }), + context, + ) +} + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("v2 location HttpApi", () => { + test("returns command and skill snapshots with resolved location headers", 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) + expect(await response.json()).toBeArray() + expect(response.headers.get("x-opencode-directory")).toBe(tmp.path) + expect(response.headers.get("x-opencode-project-id")).toBeTruthy() + } + }) +}) From 0bd61d2826e6827973f4806019d4fdedf44fc691 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Jun 2026 17:47:38 -0400 Subject: [PATCH 03/20] feat(core): include resolved location info --- packages/core/src/event.ts | 12 ++++--- packages/core/src/location.ts | 21 +++++++---- packages/core/test/catalog.test.ts | 8 ++++- packages/core/test/event.test.ts | 7 +++- packages/opencode/src/event-v2-bridge.ts | 7 ++-- .../instance/httpapi/groups/v2/command.ts | 3 +- .../routes/instance/httpapi/groups/v2/fs.ts | 5 +-- .../instance/httpapi/groups/v2/location.ts | 30 ++++++++-------- .../instance/httpapi/groups/v2/model.ts | 3 +- .../instance/httpapi/groups/v2/permission.ts | 3 +- .../instance/httpapi/groups/v2/provider.ts | 5 +-- .../instance/httpapi/groups/v2/skill.ts | 3 +- .../instance/httpapi/handlers/v2/command.ts | 3 +- .../routes/instance/httpapi/handlers/v2/fs.ts | 5 +-- .../instance/httpapi/handlers/v2/model.ts | 3 +- .../httpapi/handlers/v2/permission.ts | 3 +- .../instance/httpapi/handlers/v2/provider.ts | 5 +-- .../instance/httpapi/handlers/v2/skill.ts | 3 +- packages/opencode/src/session/session.ts | 36 ++----------------- .../test/server/httpapi-v2-location.test.ts | 9 ++--- 20 files changed, 92 insertions(+), 82 deletions(-) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 0be8c64ef6..b9cba1ede4 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -30,7 +30,7 @@ export type Payload = { readonly type: D["type"] readonly data: Data readonly version?: number - readonly location?: Location.Ref + readonly location?: Location.Info readonly metadata?: Record } @@ -77,7 +77,7 @@ export function define - readonly location?: Location.Ref + readonly location?: Location.Info } export interface Interface { @@ -264,7 +264,11 @@ export const layer = Layer.effect( const location = options?.location ?? (serviceLocation - ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID } + ? new Location.Info({ + directory: serviceLocation.directory, + workspaceID: serviceLocation.workspaceID, + project: serviceLocation.project, + }) : undefined) return yield* publishEvent({ id: options?.id ?? ID.create(), diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 9613885c97..d8388452be 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -10,16 +10,23 @@ export const Ref = Schema.Struct({ }).annotate({ identifier: "Location.Ref" }) export type Ref = typeof Ref.Type -export interface Interface { - readonly directory: AbsolutePath - readonly workspaceID?: string - readonly project: { - readonly id: Project.ID - readonly directory: AbsolutePath - } +export class Info extends Schema.Class("Location.Info")({ + directory: AbsolutePath, + workspaceID: Schema.String.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/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 2f247ed0f8..14811d67ce 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/event.test.ts b/packages/core/test/event.test.ts index c3e5d2d75a..017ce95443 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -5,6 +5,7 @@ import { Database } from "@opencode-ai/core/database/database" import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Project } from "@opencode-ai/core/project" import { eq } from "drizzle-orm" import { location } from "./fixture/location" import { testEffect } from "./lib/effect" @@ -80,7 +81,11 @@ describe("EventV2", () => { expect(event.type).toBe("test.message") expect(event).not.toHaveProperty("version") expect(event.data).toEqual({ text: "hello" }) - expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" }) + expect(event.location).toEqual({ + directory: AbsolutePath.make("project"), + workspaceID: "workspace", + project: { id: Project.ID.global, directory: AbsolutePath.make("project") }, + }) }), ) diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 673bf1f15b..e7532a965a 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) }, + }), }) }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/command.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/command.ts index b5edb2d4cf..98d84e1564 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/command.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/command.ts @@ -1,4 +1,5 @@ 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" @@ -8,7 +9,7 @@ export const CommandGroup = HttpApiGroup.make("v2.command") .add( HttpApiEndpoint.get("commands", "/api/command", { query: LocationQuery, - success: Schema.Array(CommandV2.Info), + success: Location.response(Schema.Array(CommandV2.Info)), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts index b50d2466d4..81ea932f8e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/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/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts index c8389cd3f8..34967d6087 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts @@ -9,7 +9,7 @@ import { SkillV2 } from "@opencode-ai/core/skill" import { AbsolutePath } from "@opencode-ai/core/schema" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect, Layer, Schema } from "effect" -import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpServerRequest } from "effect/unstable/http" import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi" export const LocationQuery = Schema.Struct({ @@ -36,6 +36,20 @@ 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, { @@ -67,19 +81,7 @@ export const layer = Layer.effect( return V2LocationMiddleware.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - return yield* Effect.gen(function* () { - const location = yield* Location.Service - yield* HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed( - HttpServerResponse.setHeaders(response, { - "x-opencode-directory": location.directory, - "x-opencode-project-id": location.project.id, - ...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}), - }), - ), - ) - return yield* effect - }).pipe(Effect.provide(locations.get(ref(request)))) + return yield* effect.pipe(Effect.provide(locations.get(ref(request)))) }), ) }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts index 2f52ff23d4..bc210f1c61 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/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.Info), + 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/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts index c1f78089a2..7a143d6828 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/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( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts index 2038ddfedd..6498af016f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/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.Info), + 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.Info, + success: Location.response(ProviderV2.Info), error: [ProviderNotFoundError, ServiceUnavailableError], }) .annotateMerge(locationQueryOpenApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/skill.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/skill.ts index b9f036a861..0163c171cf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/skill.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/skill.ts @@ -1,4 +1,5 @@ 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" @@ -8,7 +9,7 @@ export const SkillGroup = HttpApiGroup.make("v2.skill") .add( HttpApiEndpoint.get("skills", "/api/skill", { query: LocationQuery, - success: Schema.Array(SkillV2.Info), + success: Location.response(Schema.Array(SkillV2.Info)), }) .annotateMerge(locationQueryOpenApi) .annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/command.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/command.ts index 9b251e6584..d9448e0a05 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/command.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/command.ts @@ -2,7 +2,8 @@ import { CommandV2 } from "@opencode-ai/core/command" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" +import { response } from "../../groups/v2/location" export const commandHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.command", (handlers) => - handlers.handle("commands", () => CommandV2.Service.use((command) => command.list())), + handlers.handle("commands", () => response(CommandV2.Service.use((command) => command.list()))), ) 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 index 67fd4d8c08..b407b21fd8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts @@ -2,11 +2,12 @@ import { FileSystem } from "@opencode-ai/core/filesystem" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" +import { response } from "../../groups/v2/location" 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))) + .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/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts index 4a748ef9b7..7df713d331 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts @@ -4,6 +4,7 @@ import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" import { ServiceUnavailableError } from "../../errors" +import { response } from "../../groups/v2/location" const catalogUnavailable = new ServiceUnavailableError({ message: "Model catalog is unavailable", @@ -18,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() + return yield* response(catalog.model.available()) }), ) }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts index 8808042a11..e697ef314f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts @@ -9,6 +9,7 @@ import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } 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}` }) @@ -19,7 +20,7 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.perm return handlers.handle( "permissionRequests", Effect.fn(function* () { - return yield* (yield* PermissionV2.Service).list() + return yield* response((yield* PermissionV2.Service).list()) }), ) }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts index 2bc5cfbe82..37c9429517 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts @@ -4,6 +4,7 @@ import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors" +import { response } from "../../groups/v2/location" const catalogUnavailable = new ServiceUnavailableError({ message: "Provider catalog is unavailable", @@ -19,7 +20,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() + return yield* response(catalog.provider.available()) }), ) .handle( @@ -28,7 +29,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( + 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/skill.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/skill.ts index f382ac48fc..e10ae66ab2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/skill.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/skill.ts @@ -1,7 +1,8 @@ import { SkillV2 } from "@opencode-ai/core/skill" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" +import { response } from "../../groups/v2/location" export const skillHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.skill", (handlers) => - handlers.handle("skills", () => SkillV2.Service.use((skill) => skill.list())), + handlers.handle("skills", () => response(SkillV2.Service.use((skill) => skill.list()))), ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 4b577bc381..2a295d1821 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -40,7 +40,7 @@ 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" @@ -112,13 +112,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, @@ -544,20 +537,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 +576,6 @@ export const layer: Layer.Layer< yield* events.publish( SessionV1.Event.Created, { sessionID: result.id, info: result }, - { location: eventLocation(result) }, ) return result @@ -688,7 +666,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 +675,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 +688,6 @@ export const layer: Layer.Layer< part: structuredClone(part), time: Date.now(), }, - { location }, ) return part }).pipe(Effect.withSpan("Session.updatePart")) @@ -819,7 +793,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 +891,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 +906,6 @@ export const layer: Layer.Layer< messageID: MessageID partID: PartID }) { - const location = yield* locationForSession(input.sessionID) yield* events.publish( SessionV1.Event.PartRemoved, { @@ -942,7 +913,6 @@ export const layer: Layer.Layer< messageID: input.messageID, partID: input.partID, }, - { location }, ) return input.partID }) diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts index eeedace7f6..6cb98952ba 100644 --- a/packages/opencode/test/server/httpapi-v2-location.test.ts +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -26,15 +26,16 @@ afterEach(async () => { }) describe("v2 location HttpApi", () => { - test("returns command and skill snapshots with resolved location headers", async () => { + 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) - expect(await response.json()).toBeArray() - expect(response.headers.get("x-opencode-directory")).toBe(tmp.path) - expect(response.headers.get("x-opencode-project-id")).toBeTruthy() + 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() } }) }) From 8a97cb55d362eb1757cae8f421e6c772758aae6d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Jun 2026 18:02:57 -0400 Subject: [PATCH 04/20] feat(core): register built-in skill --- packages/core/src/config/plugin/skill.ts | 5 +++ packages/core/src/markdown.d.ts | 4 ++ packages/core/src/plugin/boot.ts | 2 + packages/core/src/plugin/skill.ts | 30 +++++++++++++ .../src/plugin/skill}/customize-opencode.md | 2 +- packages/core/src/skill.ts | 15 +++++-- packages/core/test/config/skill.test.ts | 3 ++ packages/core/test/plugin/skill.test.ts | 32 +++++++++++++ .../instance/httpapi/groups/v2/message.ts | 5 ++- .../instance/httpapi/groups/v2/permission.ts | 5 ++- .../instance/httpapi/groups/v2/response.ts | 9 ++++ .../instance/httpapi/groups/v2/session.ts | 9 ++-- .../instance/httpapi/handlers/v2/message.ts | 5 ++- .../httpapi/handlers/v2/permission.ts | 5 ++- .../instance/httpapi/handlers/v2/session.ts | 13 +++--- packages/opencode/src/skill/index.ts | 2 +- .../test/server/httpapi-exercise/index.ts | 45 +++++++++++++------ .../test/server/httpapi-session.test.ts | 9 ++-- 18 files changed, 159 insertions(+), 41 deletions(-) create mode 100644 packages/core/src/markdown.d.ts create mode 100644 packages/core/src/plugin/skill.ts rename packages/{opencode/src/skill/prompt => core/src/plugin/skill}/customize-opencode.md (99%) create mode 100644 packages/core/test/plugin/skill.test.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/response.ts diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index a6c1bccdec..c4c4b6c0fd 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -18,9 +18,14 @@ export const Plugin = PluginV2.define({ const skill = yield* SkillV2.Service const transform = yield* skill.transform() const entries = yield* config.entries() + 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/markdown.d.ts b/packages/core/src/markdown.d.ts new file mode 100644 index 0000000000..eb3e3b92d6 --- /dev/null +++ b/packages/core/src/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string + export default content +} diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index d7695f7785..6a589a1ef0 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -19,6 +19,7 @@ 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" @@ -94,6 +95,7 @@ export const layer = Layer.effect( yield* add(AccountPlugin) yield* add(AgentPlugin.Plugin) yield* add(CommandPlugin.Plugin) + yield* add(SkillPlugin.Plugin) for (const item of ProviderPlugins) { yield* add(item) } diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts new file mode 100644 index 0000000000..8ce22373da --- /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" +import CUSTOMIZE_OPENCODE_SKILL_BODY from "./skill/customize-opencode.md" with { type: "text" } + +export const Plugin = PluginV2.define({ + id: PluginV2.ID.make("skill"), + effect: Effect.gen(function* () { + const skill = yield* SkillV2.Service + const transform = yield* skill.transform() + + 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: CUSTOMIZE_OPENCODE_SKILL_BODY, + }), + }), + ) + }) + }), +}) 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 a3bc44a1fc..5b51f8f2ab 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/skill.ts b/packages/core/src/skill.ts index d1a8c7d6b5..577fd8a624 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/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index a4841cb957..4f999c3173 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -33,6 +33,7 @@ describe("ConfigSkillPlugin.Plugin", () => { Config.Service.of({ entries: () => Effect.succeed([ + new Config.Directory({ type: "directory", path: AbsolutePath.make("/repo/.opencode") }), new Config.Document({ type: "document", info: decode({ @@ -56,6 +57,8 @@ describe("ConfigSkillPlugin.Plugin", () => { ) expect(sources).toEqual([ + new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skill")) }), + new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join("/repo/.opencode", "skills")) }), new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }), new SkillV2.DirectorySource({ type: "directory", diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts new file mode 100644 index 0000000000..63d028e4ec --- /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/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index be2fdb5ba4..109b63f97d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -5,6 +5,7 @@ import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors" import { V2Authorization } from "../../middleware/authorization" import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" +import { data } from "./response" export const MessagesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, @@ -29,13 +30,13 @@ export const MessageGroup = HttpApiGroup.make("v2.message") HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { params: { sessionID: SessionID }, query: MessagesQuery, - success: Schema.Struct({ + success: data(Schema.Struct({ items: Schema.Array(SessionMessage.Message), cursor: Schema.Struct({ previous: Schema.String.pipe(Schema.optional), next: Schema.String.pipe(Schema.optional), }), - }).annotate({ identifier: "V2SessionMessagesResponse" }), + }).annotate({ identifier: "V2SessionMessagesResponse" })), error: [InvalidCursorError, SessionNotFoundError, UnknownError], }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts index 7a143d6828..8e07eef4aa 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts @@ -8,6 +8,7 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/un import { PermissionNotFoundError, SessionNotFoundError } from "../../errors" import { V2Authorization } from "../../middleware/authorization" import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location" +import { data } from "./response" export const PermissionGroup = HttpApiGroup.make("v2.permission") .add( @@ -32,7 +33,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: data(Schema.Array(PermissionV2.Request)), error: SessionNotFoundError, }).annotateMerge( OpenApi.annotations({ @@ -68,7 +69,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: 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/response.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/response.ts new file mode 100644 index 0000000000..f1a5a8d5fe --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/response.ts @@ -0,0 +1,9 @@ +import { Schema } from "effect" + +export function data(schema: S) { + return Schema.Struct({ data: schema }) +} + +export function make(data: A) { + return { data } +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 7ec6b9875a..fdf6b26dd2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -16,6 +16,7 @@ import { } from "../../errors" import { V2Authorization } from "../../middleware/authorization" import { WorkspaceRoutingQuery } from "../../middleware/workspace-routing" +import { data } from "./response" const SessionsQueryFields = { workspace: WorkspaceV2.ID.pipe(Schema.optional), @@ -88,13 +89,13 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("sessions", "/api/session", { query: SessionsQuery, - success: Schema.Struct({ + success: data(Schema.Struct({ items: Schema.Array(SessionV2.Info), cursor: Schema.Struct({ previous: SessionsCursor.pipe(Schema.optional), next: SessionsCursor.pipe(Schema.optional), }), - }).annotate({ identifier: "V2SessionsResponse" }), + }).annotate({ identifier: "V2SessionsResponse" })), error: [InvalidCursorError, InvalidRequestError], }).annotateMerge( OpenApi.annotations({ @@ -113,7 +114,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") prompt: Prompt, delivery: SessionV2.Delivery.pipe(Schema.optional), }), - success: SessionMessage.Message, + success: data(SessionMessage.Message), error: [SessionNotFoundError, ServiceUnavailableError], }).annotateMerge( OpenApi.annotations({ @@ -155,7 +156,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, - success: Schema.Array(SessionMessage.Message), + success: data(Schema.Array(SessionMessage.Message)), error: [SessionNotFoundError, UnknownError], }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index c9cfe33bc8..e0d9228170 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -5,6 +5,7 @@ import * as DateTime from "effect/DateTime" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors" +import { make } from "../../groups/v2/response" const DefaultMessagesLimit = 50 @@ -75,13 +76,13 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message ) const first = messages[0] const last = messages.at(-1) - return { + return make({ items: 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/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts index e697ef314f..d241bcea7f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts @@ -10,6 +10,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" import { PermissionNotFoundError, SessionNotFoundError } from "../../errors" import { response } from "../../groups/v2/location" +import { make } from "../../groups/v2/response" function missingRequest(id: PermissionV2.ID) { return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` }) @@ -62,7 +63,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(make)), ) }), ) @@ -92,7 +93,7 @@ export const savedPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2 .handle( "savedPermissions", Effect.fn(function* (ctx) { - return yield* saved.list({ projectID: ctx.query.projectID }) + return make(yield* saved.list({ projectID: ctx.query.projectID })) }), ) .handle( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index 6befb471ad..77d7265131 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -4,6 +4,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" import { SessionsCursor } from "../../groups/v2/session" import { InvalidCursorError, ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../../errors" +import { make } from "../../groups/v2/response" const DefaultSessionsLimit = 50 @@ -28,7 +29,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session }) const first = sessions[0] const last = sessions.at(-1) - return { + return make({ items: sessions, cursor: { previous: first @@ -52,13 +53,13 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session }) : undefined, }, - } + }) }), ) .handle( "prompt", Effect.fn(function* (ctx) { - return yield* session + return make(yield* session .prompt({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt, @@ -81,7 +82,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session }), ), ), - ) + )) }), ) .handle( @@ -135,7 +136,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session .handle( "context", Effect.fn(function* (ctx) { - return yield* session.context(ctx.params.sessionID).pipe( + return make(yield* session.context(ctx.params.sessionID).pipe( Effect.catchTag("Session.NotFoundError", (error) => Effect.fail( new SessionNotFoundError({ @@ -158,7 +159,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session ), ) }), - ) + )) }), ) }), diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 34e5edba83..b128a19da2 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -15,7 +15,7 @@ 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 CUSTOMIZE_OPENCODE_SKILL_BODY from "../../../core/src/plugin/skill/customize-opencode.md" with { type: "text" } import { isRecord } from "@/util/record" const log = Log.create({ service: "skill" }) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index f8e8db75f7..6d9a99ffcf 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -42,6 +42,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") @@ -608,19 +624,19 @@ 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/model", "v2.model.list").json(200, locationData(array)), + http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)), 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/permission/request", "v2.permission.request.list").json(200, locationData(array)), http.protected .get("/api/session/{sessionID}/permission/request", "v2.session.permission.list") .seeded((ctx) => ctx.session({ title: "Permission list owner" })) @@ -628,7 +644,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" })) @@ -641,7 +657,7 @@ const scenarios: Scenario[] = [ body: { reply: "once" }, })) .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, data(array)), 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() })) @@ -653,8 +669,9 @@ const scenarios: Scenario[] = [ 200, (body) => { object(body) - array(body.items) - object(body.cursor) + object(body.data) + array(body.data.items) + object(body.data.cursor) }, "none", ), @@ -676,8 +693,9 @@ const scenarios: Scenario[] = [ 200, (body) => { object(body) - array(body.items) - object(body.cursor) + object(body.data) + array(body.data.items) + object(body.data.cursor) }, "none", ), @@ -698,8 +716,9 @@ const scenarios: Scenario[] = [ 200, (body) => { object(body) - array(body.items) - object(body.cursor) + object(body.data) + array(body.data.items) + object(body.data.cursor) }, "none", ), diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 5763cf3b00..6a3eb664f1 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -388,8 +388,9 @@ describe("session HttpApi", () => { yield* insertLegacyAssistantMessage(parent.id) expect( - (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })) - .items, + (yield* requestJson<{ data: { items: SessionMessage.Message[] } }>(`/api/session/${parent.id}/message`, { + headers, + })).data.items, ).toMatchObject([{ type: "assistant" }]) }), { git: true, config: { formatter: false, lsp: false } }, @@ -453,7 +454,7 @@ describe("session HttpApi", () => { })}`, { headers }, ) - const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next + const sessionCursor = (yield* json<{ data: { cursor: { next?: string } } }>(sessionPage)).data.cursor.next expect(sessionCursor).toBeTruthy() expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({ order: "asc", @@ -480,7 +481,7 @@ describe("session HttpApi", () => { }) const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers }) - const messageCursor = (yield* json<{ cursor: { next?: string } }>(messagePage)).cursor.next + const messageCursor = (yield* json<{ data: { cursor: { next?: string } } }>(messagePage)).data.cursor.next expect(messageCursor).toBeTruthy() const messageCursorWithOrder = yield* request( From 2619e6bf093061910fee515b05cebc0687f19fdb Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Jun 2026 18:23:43 -0400 Subject: [PATCH 05/20] feat(opencode): add v2 event stream --- .../routes/instance/httpapi/groups/v2.ts | 2 + .../instance/httpapi/groups/v2/event.ts | 36 +++++++++++ .../routes/instance/httpapi/handlers/v2.ts | 2 + .../instance/httpapi/handlers/v2/event.ts | 60 +++++++++++++++++++ .../test/server/httpapi-v2-location.test.ts | 51 ++++++++++++++-- 5 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/event.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/event.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts index 6fa0d23a4f..0cd768e0d9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts @@ -7,6 +7,7 @@ import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from ". import { FileSystemGroup } from "./v2/fs" import { CommandGroup } from "./v2/command" import { SkillGroup } from "./v2/skill" +import { EventGroup } from "./v2/event" export const V2Api = HttpApi.make("v2") .add(SessionGroup) @@ -19,6 +20,7 @@ export const V2Api = HttpApi.make("v2") .add(FileSystemGroup) .add(CommandGroup) .add(SkillGroup) + .add(EventGroup) .annotateMerge( OpenApi.annotations({ title: "opencode experimental HttpApi", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/event.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/event.ts new file mode 100644 index 0000000000..181ff38ffc --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/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/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts index f168731f21..c6152f6ddd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -11,6 +11,7 @@ import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers import { fileSystemHandlers } from "./v2/fs" import { commandHandlers } from "./v2/command" import { skillHandlers } from "./v2/skill" +import { eventHandlers } from "./v2/event" export const v2Handlers = Layer.mergeAll( sessionHandlers, @@ -23,6 +24,7 @@ export const v2Handlers = Layer.mergeAll( fileSystemHandlers, commandHandlers, skillHandlers, + eventHandlers, ).pipe( Layer.provide(v2LocationLayer), Layer.provide(LocationServiceMap.layer), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/event.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/event.ts new file mode 100644 index 0000000000..bde4bbb86d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/event.ts @@ -0,0 +1,60 @@ +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Effect, Stream } from "effect" +import { HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import * as Sse from "effect/unstable/encoding/Sse" +import { InstanceHttpApi } from "../../api" + +function eventData(data: unknown): Sse.Event { + return { + _tag: "Event", + event: "message", + id: undefined, + data: JSON.stringify(data), + } +} + +export const eventHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.event", (handlers) => + handlers.handleRaw("events", () => + Effect.gen(function* () { + const events = yield* EventV2Bridge.Service + const location = yield* Location.Service + const connected = { + id: EventV2.ID.create(), + type: "server.connected", + location: new Location.Info({ + directory: location.directory, + workspaceID: location.workspaceID, + project: location.project, + }), + data: {}, + } + return HttpServerResponse.stream( + Stream.make(connected).pipe( + Stream.concat( + events.all().pipe( + Stream.filter( + (event) => + event.location?.directory === location.directory && + event.location.workspaceID === location.workspaceID, + ), + ), + ), + Stream.map(eventData), + Stream.pipeThroughChannel(Sse.encode()), + Stream.encodeText, + ), + { + contentType: "text/event-stream", + headers: { + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }, + ) + }), + ), +) diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts index 6cb98952ba..481e05b1cb 100644 --- a/packages/opencode/test/server/httpapi-v2-location.test.ts +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Context } from "effect" +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" @@ -9,17 +9,42 @@ void Log.init({ print: false }) const context = Context.empty() as Context.Context -function request(route: string, directory: string) { +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}`, { - headers: { - "x-opencode-directory": directory, - }, + ...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() @@ -38,4 +63,20 @@ describe("v2 location HttpApi", () => { 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() + }) }) From 1ed76ccace6c409c211d4ce850ee02de85d1b813 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Jun 2026 18:24:49 -0400 Subject: [PATCH 06/20] fix(core): load built-in skill asset at runtime --- packages/core/src/markdown.d.ts | 4 ---- packages/core/src/plugin/skill.ts | 4 ++-- packages/opencode/src/skill/index.ts | 4 +++- 3 files changed, 5 insertions(+), 7 deletions(-) delete mode 100644 packages/core/src/markdown.d.ts diff --git a/packages/core/src/markdown.d.ts b/packages/core/src/markdown.d.ts deleted file mode 100644 index eb3e3b92d6..0000000000 --- a/packages/core/src/markdown.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "*.md" { - const content: string - export default content -} diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 8ce22373da..c3e226f740 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -4,13 +4,13 @@ import { Effect } from "effect" import { PluginV2 } from "../plugin" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" -import CUSTOMIZE_OPENCODE_SKILL_BODY from "./skill/customize-opencode.md" with { type: "text" } 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( @@ -21,7 +21,7 @@ export const Plugin = PluginV2.define({ 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: CUSTOMIZE_OPENCODE_SKILL_BODY, + content, }), }), ) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index b128a19da2..7a101658a8 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 "../../../core/src/plugin/skill/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, From 5e3026b5ce2649ebcf4e6bc5bd2cd259e490b15a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Jun 2026 19:52:49 -0400 Subject: [PATCH 07/20] fix(opencode): restore global sync event compatibility --- packages/core/src/event.ts | 32 +- packages/core/test/event.test.ts | 13 + .../src/cli/cmd/tui/context/sync-v2.tsx | 2 +- packages/opencode/src/event-v2-bridge.ts | 15 + .../routes/instance/httpapi/groups/global.ts | 11 +- .../server/httpapi-public-openapi.test.ts | 30 +- .../opencode/test/session/session.test.ts | 27 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 93 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 846 ++++++++++++------ 9 files changed, 766 insertions(+), 303 deletions(-) diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index b9cba1ede4..2d4130493e 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -30,6 +30,7 @@ export type Payload = { readonly type: D["type"] readonly data: Data readonly version?: number + readonly sync?: SyncMetadata readonly location?: Location.Info readonly metadata?: Record } @@ -48,6 +49,11 @@ export type SerializedEvent = { readonly data: Record } +export type SyncMetadata = { + readonly seq: number + readonly aggregateID: string +} + export class InvalidSyncEventError extends Schema.TaggedErrorClass()( "EventV2.InvalidSyncEvent", { @@ -77,6 +83,12 @@ export function define Effect.gen(function* () { @@ -208,8 +220,12 @@ export const layer = Layer.effect( }), ) } + const serialized = { + seq, + aggregateID, + } for (const projector of list) { - yield* projector(event as Payload) + yield* projector({ ...event, sync: serialized }) } yield* db .insert(EventSequenceTable) @@ -233,6 +249,7 @@ export const layer = Layer.effect( ]) .run() .pipe(Effect.orDie) + return serialized }), { behavior: "immediate" }, ) @@ -247,14 +264,15 @@ export const layer = Layer.effect( for (const sync of syncHandlers) { yield* sync(event as Payload) } - yield* commitSyncEvent(event as Payload) + const sync = yield* commitSyncEvent(event as Payload) + const payload = sync ? { ...event, sync } : event for (const listener of listeners) { - yield* listener(event as Payload) + yield* listener(payload as Payload) } const pubsub = typed.get(event.type) - if (pubsub) yield* PubSub.publish(pubsub, event as Payload) - yield* PubSub.publish(all, event as Payload) - return event + if (pubsub) yield* PubSub.publish(pubsub, payload as Payload) + yield* PubSub.publish(all, payload as Payload) + return payload }) } diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 017ce95443..647875fc08 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -109,6 +109,19 @@ describe("EventV2", () => { }), ) + it.effect("publishes sync metadata", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const aggregateID = EventV2.ID.create() + const event = yield* events.publish(SyncMessage, { id: aggregateID, text: "hello" }) + + expect(event.sync).toEqual({ + seq: 0, + aggregateID, + }) + }), + ) + it.effect("stores definitions in the exported registry", () => Effect.sync(() => { expect(EventV2.registry.get(Message.type)).toBe(Message) 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 d9d23999d2..442eba84ab 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -291,7 +291,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.items ?? [])) }, 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 e7532a965a..07204f28d2 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -44,6 +44,21 @@ export const layer = Layer.effect( workspace: workspaceID, payload: { id: event.id, type: event.type, properties: event.data }, }) + if (!event.sync || event.version === undefined) 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), + ...event.sync, + data: event.data, + }, + }, + }) }), ) yield* Effect.addFinalizer(() => unsubscribe) 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 4a24282a03..cd84990524 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/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index 70977eecae..fd342cc273 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -3,7 +3,13 @@ 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 } +type OpenApiSchema = { + readonly $ref?: string + readonly type?: string + readonly enum?: readonly unknown[] + readonly properties?: Record + readonly required?: readonly string[] +} type OpenApiResponse = { readonly description?: string readonly content?: Record @@ -19,7 +25,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 @@ -49,6 +58,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/session/session.test.ts b/packages/opencode/test/session/session.test.ts index ac29d35f2a..b8337b963b 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/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index e586ba61d8..09fac6ecfb 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -255,6 +255,10 @@ import type { TuiShowToastResponses, TuiSubmitPromptErrors, TuiSubmitPromptResponses, + V2CommandListErrors, + V2CommandListResponses, + V2EventSubscribeErrors, + V2EventSubscribeResponses, V2FsListErrors, V2FsListResponses, V2FsReadErrors, @@ -287,6 +291,8 @@ import type { V2SessionPromptResponses, V2SessionWaitErrors, V2SessionWaitResponses, + V2SkillListErrors, + V2SkillListResponses, VcsApplyErrors, VcsApplyResponses, VcsDiffErrors, @@ -4990,6 +4996,78 @@ export class Fs extends HeyApiClient { } } +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 V2 extends HeyApiClient { private _session?: Session3 get session(): Session3 { @@ -5015,6 +5093,21 @@ export class V2 extends HeyApiClient { 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 })) + } } export class Control extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c738c38710..9a809fdc43 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1789,6 +1789,7 @@ export type Config = { description?: string agent?: string model?: string + variant?: string subtask?: boolean } } @@ -2856,429 +2857,516 @@ 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 + syncEvent: { + type: "session.next.prompted.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + prompt: Prompt + } } } 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.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - finish: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number + syncEvent: { + type: "session.next.step.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: 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.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - error: SessionErrorUnknown + syncEvent: { + type: "session.next.step.failed.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: 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 + syncEvent: { + type: "session.next.text.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + } } } export type SyncEventSessionNextTextDelta = { type: "sync" - name: "session.next.text.delta.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - delta: string + syncEvent: { + type: "session.next.text.delta.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + delta: string + } } } export type SyncEventSessionNextTextEnded = { type: "sync" - name: "session.next.text.ended.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - text: string + syncEvent: { + type: "session.next.text.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: 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 + syncEvent: { + type: "session.next.reasoning.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + reasoningID: string + } } } export type SyncEventSessionNextReasoningDelta = { type: "sync" - name: "session.next.reasoning.delta.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - reasoningID: string - delta: string + syncEvent: { + type: "session.next.reasoning.delta.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } } } 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 + syncEvent: { + type: "session.next.reasoning.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } } } export type SyncEventSessionNextToolInputStarted = { type: "sync" - name: "session.next.tool.input.started.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - callID: string - name: string + syncEvent: { + type: "session.next.tool.input.started.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + callID: string + name: string + } } } export type SyncEventSessionNextToolInputDelta = { type: "sync" - name: "session.next.tool.input.delta.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - callID: string - delta: string + syncEvent: { + type: "session.next.tool.input.delta.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + callID: string + delta: string + } } } export type SyncEventSessionNextToolInputEnded = { type: "sync" - name: "session.next.tool.input.ended.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - callID: string - text: string + syncEvent: { + type: "session.next.tool.input.ended.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: 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 - callID: string - tool: string - input: { - [key: string]: unknown - } - provider: { - executed: boolean - metadata?: { + syncEvent: { + type: "session.next.tool.called.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { [key: string]: unknown } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } } } } export type SyncEventSessionNextToolProgress = { type: "sync" - name: "session.next.tool.progress.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: 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 + 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 - callID: string - structured: { - [key: string]: unknown - } - content: Array - provider: { - executed: boolean - metadata?: { + syncEvent: { + type: "session.next.tool.success.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + callID: string + structured: { [key: string]: unknown } + content: Array + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } } } } export type SyncEventSessionNextToolFailed = { type: "sync" - name: "session.next.tool.failed.1" id: string - seq: number - aggregateID: "sessionID" - data: { - timestamp: number - sessionID: string - callID: string - error: SessionErrorUnknown - provider: { - executed: boolean - metadata?: { - [key: string]: unknown + syncEvent: { + type: "session.next.tool.failed.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + callID: string + error: SessionErrorUnknown + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } } } } @@ -3286,55 +3374,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 + } } } @@ -3592,6 +3692,15 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction +export type LocationInfo = { + directory: string + workspaceID?: string + project: { + id: string + directory: string + } +} + export type ProviderV2Info = { id: string name: string @@ -3677,6 +3786,27 @@ export type LocationFileSystemEntry = { 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 EventModelsDevRefreshed = { id: string type: "models-dev.refreshed" @@ -8092,9 +8222,11 @@ export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors] export type V2SessionListResponses = { /** - * V2SessionsResponse + * Success */ - 200: V2SessionsResponse + 200: { + data: V2SessionsResponse + } } export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] @@ -8137,9 +8269,11 @@ export type V2SessionPromptError = V2SessionPromptErrors[keyof V2SessionPromptEr export type V2SessionPromptResponses = { /** - * Session.Message + * Success */ - 200: SessionMessage + 200: { + data: SessionMessage + } } export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] @@ -8265,7 +8399,9 @@ export type V2SessionContextResponses = { /** * Success */ - 200: Array + 200: { + data: Array + } } export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] @@ -8311,9 +8447,11 @@ export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMess export type V2SessionMessagesResponses = { /** - * V2SessionMessagesResponse + * Success */ - 200: V2SessionMessagesResponse + 200: { + data: V2SessionMessagesResponse + } } export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] @@ -8351,7 +8489,10 @@ export type V2ModelListResponses = { /** * Success */ - 200: Array + 200: { + location: LocationInfo + data: Array + } } export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses] @@ -8389,7 +8530,10 @@ export type V2ProviderListResponses = { /** * Success */ - 200: Array + 200: { + location: LocationInfo + data: Array + } } export type V2ProviderListResponse = V2ProviderListResponses[keyof V2ProviderListResponses] @@ -8431,9 +8575,12 @@ export type V2ProviderGetError = V2ProviderGetErrors[keyof V2ProviderGetErrors] export type V2ProviderGetResponses = { /** - * ProviderV2.Info + * Success */ - 200: ProviderV2Info + 200: { + location: LocationInfo + data: ProviderV2Info + } } export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses] @@ -8467,7 +8614,10 @@ export type V2PermissionRequestListResponses = { /** * Success */ - 200: Array + 200: { + location: LocationInfo + data: Array + } } export type V2PermissionRequestListResponse = V2PermissionRequestListResponses[keyof V2PermissionRequestListResponses] @@ -8502,7 +8652,9 @@ export type V2SessionPermissionListResponses = { /** * Success */ - 200: Array + 200: { + data: Array + } } export type V2SessionPermissionListResponse = V2SessionPermissionListResponses[keyof V2SessionPermissionListResponses] @@ -8573,7 +8725,9 @@ export type V2PermissionSavedListResponses = { /** * Success */ - 200: Array + 200: { + data: Array + } } export type V2PermissionSavedListResponse = V2PermissionSavedListResponses[keyof V2PermissionSavedListResponses] @@ -8640,7 +8794,10 @@ export type V2FsReadResponses = { /** * Success */ - 200: LocationFileSystemTextContent | LocationFileSystemBinaryContent + 200: { + location: LocationInfo + data: LocationFileSystemTextContent | LocationFileSystemBinaryContent + } } export type V2FsReadResponse = V2FsReadResponses[keyof V2FsReadResponses] @@ -8676,11 +8833,122 @@ export type V2FsListResponses = { /** * Success */ - 200: Array + 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 TuiAppendPromptData = { body?: { text: string From dbd0a5b8c9218dd84b8097d88e36ef5c3099a2c7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Jun 2026 20:02:12 -0400 Subject: [PATCH 08/20] test(opencode): cover new v2 httpapi routes --- .../test/server/httpapi-exercise/index.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 6d9a99ffcf..cdaeea8e1e 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -626,6 +626,21 @@ const scenarios: Scenario[] = [ ), 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(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")) From b268064d0839bb43c391c30cc847145574f48629 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 3 Jun 2026 20:03:03 -0400 Subject: [PATCH 09/20] fix(opencode): narrow v2 event exercise directory --- packages/opencode/test/server/httpapi-exercise/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index cdaeea8e1e..13ed74accf 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -637,7 +637,7 @@ const scenarios: Scenario[] = [ 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(result.text.includes(ctx.directory), "v2 event should include the resolved location") + check(!!ctx.directory && result.text.includes(ctx.directory), "v2 event should include the resolved location") }), "status", ), From 23a87f8dc5e37566ecd769f635d27de61c97c594 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Jun 2026 00:38:42 -0400 Subject: [PATCH 10/20] feat(cli): add preview server daemon --- bun.lock | 41 +- packages/cli/package.json | 6 +- packages/cli/script/build.ts | 92 + packages/cli/script/generate.ts | 7 + packages/cli/src/api.ts | 12 - packages/cli/src/commands/commands.ts | 36 + .../cli/src/commands/handlers/debug/agents.ts | 21 + packages/cli/src/commands/handlers/migrate.ts | 5 + packages/cli/src/commands/handlers/serve.ts | 39 + .../src/commands/handlers/service/password.ts | 16 + .../src/commands/handlers/service/restart.ts | 14 + .../src/commands/handlers/service/start.ts | 12 + .../src/commands/handlers/service/status.ts | 13 + .../cli/src/commands/handlers/service/stop.ts | 11 + .../{cli-builder.ts => framework/runtime.ts} | 37 +- .../cli/src/{cli-api.ts => framework/spec.ts} | 2 +- packages/cli/src/handlers/debug/agents.ts | 30 - packages/cli/src/handlers/migrate.ts | 5 - packages/cli/src/index.ts | 22 +- packages/cli/src/services/daemon.ts | 145 ++ packages/cli/tsconfig.json | 1 + packages/opencode/package.json | 1 + .../src/server/routes/instance/httpapi/api.ts | 4 +- .../routes/instance/httpapi/groups/v2.ts | 30 - .../instance/httpapi/handlers/v2/event.ts | 60 - .../httpapi/middleware/authorization.ts | 33 +- .../server/routes/instance/httpapi/server.ts | 15 +- .../test/server/httpapi-exercise/index.ts | 5 + .../server/httpapi-query-schema-drift.test.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 1309 ++++++------- packages/sdk/js/src/v2/gen/types.gen.ts | 1728 +++++++++-------- packages/server/package.json | 24 + packages/server/src/api.ts | 36 + packages/server/src/auth.ts | 63 + packages/server/src/errors.ts | 68 + packages/server/src/groups/v2/agent.ts | 24 + .../src}/groups/v2/command.ts | 0 .../httpapi => server/src}/groups/v2/event.ts | 0 .../httpapi => server/src}/groups/v2/fs.ts | 0 packages/server/src/groups/v2/health.ts | 17 + .../src}/groups/v2/location.ts | 3 + .../src}/groups/v2/message.ts | 6 +- .../httpapi => server/src}/groups/v2/model.ts | 0 .../src}/groups/v2/permission.ts | 0 .../src}/groups/v2/provider.ts | 0 .../src}/groups/v2/response.ts | 0 .../src}/groups/v2/session.ts | 14 +- .../httpapi => server/src}/groups/v2/skill.ts | 0 .../handlers/v2.ts => server/src/handlers.ts} | 24 +- packages/server/src/handlers/v2/agent.ts | 15 + .../src}/handlers/v2/command.ts | 4 +- packages/server/src/handlers/v2/event.ts | 61 + .../httpapi => server/src}/handlers/v2/fs.ts | 4 +- packages/server/src/handlers/v2/health.ts | 7 + .../src}/handlers/v2/message.ts | 4 +- .../src}/handlers/v2/model.ts | 4 +- .../src}/handlers/v2/permission.ts | 8 +- .../src}/handlers/v2/provider.ts | 4 +- .../src}/handlers/v2/session.ts | 4 +- .../src}/handlers/v2/skill.ts | 4 +- .../server/src/middleware/authorization.ts | 60 + .../server/src/middleware/schema-error.ts | 23 + packages/server/src/routes.ts | 36 + packages/server/tsconfig.json | 7 + 64 files changed, 2554 insertions(+), 1724 deletions(-) create mode 100644 packages/cli/script/build.ts create mode 100644 packages/cli/script/generate.ts delete mode 100644 packages/cli/src/api.ts create mode 100644 packages/cli/src/commands/commands.ts create mode 100644 packages/cli/src/commands/handlers/debug/agents.ts create mode 100644 packages/cli/src/commands/handlers/migrate.ts create mode 100644 packages/cli/src/commands/handlers/serve.ts create mode 100644 packages/cli/src/commands/handlers/service/password.ts create mode 100644 packages/cli/src/commands/handlers/service/restart.ts create mode 100644 packages/cli/src/commands/handlers/service/start.ts create mode 100644 packages/cli/src/commands/handlers/service/status.ts create mode 100644 packages/cli/src/commands/handlers/service/stop.ts rename packages/cli/src/{cli-builder.ts => framework/runtime.ts} (59%) rename packages/cli/src/{cli-api.ts => framework/spec.ts} (97%) delete mode 100644 packages/cli/src/handlers/debug/agents.ts delete mode 100644 packages/cli/src/handlers/migrate.ts create mode 100644 packages/cli/src/services/daemon.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/event.ts create mode 100644 packages/server/package.json create mode 100644 packages/server/src/api.ts create mode 100644 packages/server/src/auth.ts create mode 100644 packages/server/src/errors.ts create mode 100644 packages/server/src/groups/v2/agent.ts rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/groups/v2/command.ts (100%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/groups/v2/event.ts (100%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/groups/v2/fs.ts (100%) create mode 100644 packages/server/src/groups/v2/health.ts rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/groups/v2/location.ts (96%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/groups/v2/message.ts (91%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/groups/v2/model.ts (100%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/groups/v2/permission.ts (100%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/groups/v2/provider.ts (100%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/groups/v2/response.ts (100%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/groups/v2/session.ts (93%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/groups/v2/skill.ts (100%) rename packages/{opencode/src/server/routes/instance/httpapi/handlers/v2.ts => server/src/handlers.ts} (51%) create mode 100644 packages/server/src/handlers/v2/agent.ts rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/handlers/v2/command.ts (67%) create mode 100644 packages/server/src/handlers/v2/event.ts rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/handlers/v2/fs.ts (76%) create mode 100644 packages/server/src/handlers/v2/health.ts rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/handlers/v2/message.ts (95%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/handlers/v2/model.ts (85%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/handlers/v2/permission.ts (90%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/handlers/v2/provider.ts (91%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/handlers/v2/session.ts (97%) rename packages/{opencode/src/server/routes/instance/httpapi => server/src}/handlers/v2/skill.ts (64%) create mode 100644 packages/server/src/middleware/authorization.ts create mode 100644 packages/server/src/middleware/schema-error.ts create mode 100644 packages/server/src/routes.ts create mode 100644 packages/server/tsconfig.json diff --git a/bun.lock b/bun.lock index 2bb58f67d7..5ead2ee029 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 1, + "lockfileVersion": 2, "configVersion": 1, "workspaces": { "": { @@ -87,14 +87,18 @@ "name": "@opencode-ai/cli", "version": "1.15.13", "bin": { - "opencode": "./src/index.ts", + "lildax": "./src/index.ts", }, "dependencies": { "@effect/platform-node": "catalog:", "@opencode-ai/core": "workspace:*", + "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/server": "workspace:*", + "@parcel/watcher": "2.5.1", "effect": "catalog:", }, "devDependencies": { + "@opencode-ai/script": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", @@ -512,6 +516,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/server": "workspace:*", "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", @@ -649,6 +654,20 @@ "typescript": "catalog:", }, }, + "packages/server": { + "name": "@opencode-ai/server", + "version": "1.15.13", + "dependencies": { + "@opencode-ai/core": "workspace:*", + "drizzle-orm": "catalog:", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/slack": { "name": "@opencode-ai/slack", "version": "1.15.13", @@ -839,23 +858,23 @@ }, }, "trustedDependencies": [ - "esbuild", "tree-sitter-powershell", - "protobufjs", - "electron", "web-tree-sitter", "tree-sitter-bash", + "esbuild", + "electron", + "protobufjs", ], "patchedDependencies": { - "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "virtua@0.49.1": "patches/virtua@0.49.1.patch", - "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", }, "overrides": { "@opentui/core": "catalog:", @@ -1729,6 +1748,8 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], + "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], + "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"], diff --git a/packages/cli/package.json b/packages/cli/package.json index 8221953562..bcf1418fb5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -6,7 +6,7 @@ "type": "module", "license": "MIT", "bin": { - "opencode": "./src/index.ts" + "lildax": "./src/index.ts" }, "scripts": { "build": "bun run script/build.ts", @@ -16,9 +16,13 @@ "dependencies": { "@effect/platform-node": "catalog:", "@opencode-ai/core": "workspace:*", + "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/server": "workspace:*", + "@parcel/watcher": "2.5.1", "effect": "catalog:" }, "devDependencies": { + "@opencode-ai/script": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:" diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts new file mode 100644 index 0000000000..7b16318ef3 --- /dev/null +++ b/packages/cli/script/build.ts @@ -0,0 +1,92 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import { rm } from "fs/promises" +import path from "path" +import { Script } from "@opencode-ai/script" +import { modelsData } from "./generate" +import pkg from "../package.json" + +const dir = path.resolve(import.meta.dirname, "..") +const binary = "lildax" +process.chdir(dir) + +await rm("dist", { recursive: true, force: true }) + +const singleFlag = process.argv.includes("--single") +const baselineFlag = process.argv.includes("--baseline") +const skipInstall = process.argv.includes("--skip-install") +const sourcemapsFlag = process.argv.includes("--sourcemaps") + +const allTargets: { + os: string + arch: "arm64" | "x64" + abi?: "musl" + avx2?: false +}[] = [ + { os: "linux", arch: "arm64" }, + { os: "linux", arch: "x64" }, + { os: "linux", arch: "x64", avx2: false }, + { os: "linux", arch: "arm64", abi: "musl" }, + { os: "linux", arch: "x64", abi: "musl" }, + { os: "linux", arch: "x64", abi: "musl", avx2: false }, + { os: "darwin", arch: "arm64" }, + { os: "darwin", arch: "x64" }, + { os: "darwin", arch: "x64", avx2: false }, + { os: "win32", arch: "arm64" }, + { os: "win32", arch: "x64" }, + { os: "win32", arch: "x64", avx2: false }, +] + +const targets = singleFlag + ? allTargets.filter((item) => { + if (item.os !== process.platform || item.arch !== process.arch) return false + if (item.avx2 === false) return baselineFlag + return item.abi === undefined + }) + : allTargets + +if (!skipInstall) await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}` + +for (const item of targets) { + const name = [ + binary, + item.os === "win32" ? "windows" : item.os, + item.arch, + item.avx2 === false ? "baseline" : undefined, + item.abi, + ] + .filter(Boolean) + .join("-") + console.log(`building ${name}`) + const result = await Bun.build({ + entrypoints: ["./src/index.ts"], + tsconfig: "./tsconfig.json", + external: ["node-gyp"], + format: "esm", + minify: true, + sourcemap: sourcemapsFlag ? "linked" : "none", + splitting: true, + compile: { + autoloadBunfig: false, + autoloadDotenv: false, + autoloadTsconfig: true, + autoloadPackageJson: true, + target: name.replace(binary, "bun") as Bun.Build.CompileTarget, + outfile: `./dist/${name}/bin/${binary}`, + execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"], + windows: {}, + }, + define: { + OPENCODE_VERSION: `'${Script.version}'`, + OPENCODE_CLI_NAME: `'${binary}'`, + OPENCODE_MODELS_DEV: modelsData, + OPENCODE_CHANNEL: `'${Script.channel}'`, + OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined", + }, + }) + + if (result.success) continue + for (const log of result.logs) console.error(log) + process.exit(1) +} diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts new file mode 100644 index 0000000000..d98565e298 --- /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/src/api.ts b/packages/cli/src/api.ts deleted file mode 100644 index d4a4a4fa77..0000000000 --- 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 0000000000..39594e9951 --- /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 0000000000..3a0c20cb06 --- /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 0000000000..c73c7750df --- /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 0000000000..62c64df433 --- /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 0000000000..6bf49d50d0 --- /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 0000000000..d348987d16 --- /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 0000000000..0d6fbaada9 --- /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 0000000000..d409970e8b --- /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 0000000000..8da9b04cff --- /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 e3dbaf8cb3..eee9ff795b 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 038428d66b..3bb47e5e5e 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 85eec4555d..0000000000 --- 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 0d9c1e6aca..0000000000 --- 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 0a1f21a21f..75837af534 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 0000000000..8500add61a --- /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 fe5c4d217b..00ef125468 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/opencode/package.json b/packages/opencode/package.json index 9538218a6b..ab0ca7e7d9 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/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 57b8b37d99..b80e57222e 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/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts deleted file mode 100644 index 0cd768e0d9..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { HttpApi, OpenApi } from "effect/unstable/httpapi" -import { MessageGroup } from "./v2/message" -import { ModelGroup } from "./v2/model" -import { ProviderGroup } from "./v2/provider" -import { SessionGroup } from "./v2/session" -import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission" -import { FileSystemGroup } from "./v2/fs" -import { CommandGroup } from "./v2/command" -import { SkillGroup } from "./v2/skill" -import { EventGroup } from "./v2/event" - -export const V2Api = HttpApi.make("v2") - .add(SessionGroup) - .add(MessageGroup) - .add(ModelGroup) - .add(ProviderGroup) - .add(PermissionGroup) - .add(SessionPermissionGroup) - .add(PermissionSavedGroup) - .add(FileSystemGroup) - .add(CommandGroup) - .add(SkillGroup) - .add(EventGroup) - .annotateMerge( - OpenApi.annotations({ - title: "opencode experimental HttpApi", - version: "0.0.1", - description: "Experimental HttpApi surface for selected instance routes.", - }), - ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/event.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/event.ts deleted file mode 100644 index bde4bbb86d..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/event.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { EventV2Bridge } from "@/event-v2-bridge" -import { Effect, Stream } from "effect" -import { HttpServerResponse } from "effect/unstable/http" -import { HttpApiBuilder } from "effect/unstable/httpapi" -import * as Sse from "effect/unstable/encoding/Sse" -import { InstanceHttpApi } from "../../api" - -function eventData(data: unknown): Sse.Event { - return { - _tag: "Event", - event: "message", - id: undefined, - data: JSON.stringify(data), - } -} - -export const eventHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.event", (handlers) => - handlers.handleRaw("events", () => - Effect.gen(function* () { - const events = yield* EventV2Bridge.Service - const location = yield* Location.Service - const connected = { - id: EventV2.ID.create(), - type: "server.connected", - location: new Location.Info({ - directory: location.directory, - workspaceID: location.workspaceID, - project: location.project, - }), - data: {}, - } - return HttpServerResponse.stream( - Stream.make(connected).pipe( - Stream.concat( - events.all().pipe( - Stream.filter( - (event) => - event.location?.directory === location.directory && - event.location.workspaceID === location.workspaceID, - ), - ), - ), - Stream.map(eventData), - Stream.pipeThroughChannel(Sse.encode()), - Stream.encodeText, - ), - { - contentType: "text/event-stream", - headers: { - "Cache-Control": "no-cache, no-transform", - "X-Accel-Buffering": "no", - "X-Content-Type-Options": "nosniff", - }, - }, - ) - }), - ), -) 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 db6554590f..43ee1e174a 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 dfbb1a88b7..1ce65cb8f9 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/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 13ed74accf..98cd1b1ba6 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -624,6 +624,11 @@ const scenarios: Scenario[] = [ check(auth.test === undefined, "auth remove should delete provider from isolated auth file") }), ), + http.protected.get("/api/health", "v2.health.get").json(200, (body) => { + object(body) + check(body.healthy === true, "v2 server should report healthy") + }), + http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)), http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)), http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)), http.protected.get("/api/command", "v2.command.list").json(200, locationData(array)), 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 064bcc97e2..cdaf554f09 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/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 09fac6ecfb..bf749739d2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -255,6 +255,8 @@ import type { TuiShowToastResponses, TuiSubmitPromptErrors, TuiSubmitPromptResponses, + V2AgentListErrors, + V2AgentListResponses, V2CommandListErrors, V2CommandListResponses, V2EventSubscribeErrors, @@ -263,6 +265,8 @@ import type { V2FsListResponses, V2FsReadErrors, V2FsReadResponses, + V2HealthGetErrors, + V2HealthGetResponses, V2ModelListErrors, V2ModelListResponses, V2PermissionRequestListErrors, @@ -4463,653 +4467,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 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 - * - * Create a v2 session message and queue it for the agent loop. - */ - public prompt( - parameters: { - sessionID: string - directory?: string - workspace?: string - prompt?: Prompt - delivery?: SessionDelivery - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "prompt" }, - { in: "body", key: "delivery" }, - ], - }, - ], - ) - 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 })) - } -} - -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 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 _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 })) - } -} - export class Control extends HeyApiClient { /** * Get next TUI request @@ -5557,6 +4914,654 @@ 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 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 + * + * Create a v2 session message and queue it for the agent loop. + */ + public prompt( + parameters: { + sessionID: string + prompt?: Prompt + delivery?: SessionDelivery + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "prompt" }, + { in: "body", key: "delivery" }, + ], + }, + ], + ) + 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 })) + } +} + +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 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 })) + } +} + export class OpencodeClient extends HeyApiClient { public static readonly __registry = new HeyApiRegistry() @@ -5690,13 +5695,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 9a809fdc43..dba1baeef5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2499,56 +2499,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 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: { @@ -2625,6 +2575,56 @@ export type WorkspaceWarpError = { } } +export type UnauthorizedError = { + _tag: "UnauthorizedError" + message: string +} + +export type V2SessionsResponse = { + items: Array + cursor: { + previous?: string + next?: string + } +} + +export type InvalidCursorError = { + _tag: "InvalidCursorError" + message: 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 EffectHttpApiErrorForbidden = { _tag: "Forbidden" } @@ -3452,6 +3452,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 @@ -3692,15 +3735,6 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction -export type LocationInfo = { - directory: string - workspaceID?: string - project: { - id: string - directory: string - } -} - export type ProviderV2Info = { id: string name: string @@ -8188,767 +8222,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 = { - /** - * Success - */ - 200: { - data: V2SessionsResponse - } -} - -export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] - -export type V2SessionPromptData = { - body?: { - prompt: Prompt - delivery?: SessionDelivery - } - 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 - /** - * ServiceUnavailableError - */ - 503: ServiceUnavailableError -} - -export type V2SessionPromptError = V2SessionPromptErrors[keyof V2SessionPromptErrors] - -export type V2SessionPromptResponses = { - /** - * Success - */ - 200: { - data: SessionMessage - } -} - -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: { - data: 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 = { - /** - * Success - */ - 200: { - data: 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: LocationFileSystemTextContent | LocationFileSystemBinaryContent - } -} - -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 TuiAppendPromptData = { body?: { text: string @@ -9564,6 +8837,821 @@ 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 = { + /** + * Success + */ + 200: { + data: V2SessionsResponse + } +} + +export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] + +export type V2SessionPromptData = { + body?: { + prompt: Prompt + delivery?: SessionDelivery + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/prompt" +} + +export type V2SessionPromptErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2SessionPromptError = V2SessionPromptErrors[keyof V2SessionPromptErrors] + +export type V2SessionPromptResponses = { + /** + * Success + */ + 200: { + data: SessionMessage + } +} + +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 = { + /** + * Success + */ + 200: { + data: 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: LocationFileSystemTextContent | LocationFileSystemBinaryContent + } +} + +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 PtyConnectData = { body?: never path: { diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000000..0eb698857f --- /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 0000000000..f45ef9288f --- /dev/null +++ b/packages/server/src/api.ts @@ -0,0 +1,36 @@ +import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { SchemaErrorMiddleware } from "./middleware/schema-error" +import { MessageGroup } from "./groups/v2/message" +import { ModelGroup } from "./groups/v2/model" +import { ProviderGroup } from "./groups/v2/provider" +import { SessionGroup } from "./groups/v2/session" +import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./groups/v2/permission" +import { FileSystemGroup } from "./groups/v2/fs" +import { CommandGroup } from "./groups/v2/command" +import { SkillGroup } from "./groups/v2/skill" +import { EventGroup } from "./groups/v2/event" +import { AgentGroup } from "./groups/v2/agent" +import { HealthGroup } from "./groups/v2/health" + +export const V2Api = HttpApi.make("v2") + .add(HealthGroup) + .add(AgentGroup) + .add(SessionGroup) + .add(MessageGroup) + .add(ModelGroup) + .add(ProviderGroup) + .add(PermissionGroup) + .add(SessionPermissionGroup) + .add(PermissionSavedGroup) + .add(FileSystemGroup) + .add(CommandGroup) + .add(SkillGroup) + .add(EventGroup) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + .middleware(SchemaErrorMiddleware) diff --git a/packages/server/src/auth.ts b/packages/server/src/auth.ts new file mode 100644 index 0000000000..6758fda3d2 --- /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 0000000000..b9862d19f7 --- /dev/null +++ b/packages/server/src/errors.ts @@ -0,0 +1,68 @@ +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 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 }, +) {} diff --git a/packages/server/src/groups/v2/agent.ts b/packages/server/src/groups/v2/agent.ts new file mode 100644 index 0000000000..1fdc33d377 --- /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/opencode/src/server/routes/instance/httpapi/groups/v2/command.ts b/packages/server/src/groups/v2/command.ts similarity index 100% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/command.ts rename to packages/server/src/groups/v2/command.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/event.ts b/packages/server/src/groups/v2/event.ts similarity index 100% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/event.ts rename to packages/server/src/groups/v2/event.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts b/packages/server/src/groups/v2/fs.ts similarity index 100% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts rename to packages/server/src/groups/v2/fs.ts diff --git a/packages/server/src/groups/v2/health.ts b/packages/server/src/groups/v2/health.ts new file mode 100644 index 0000000000..9ad38210db --- /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 96% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts rename to packages/server/src/groups/v2/location.ts index 34967d6087..23083899d8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts +++ b/packages/server/src/groups/v2/location.ts @@ -1,4 +1,5 @@ import { Catalog } from "@opencode-ai/core/catalog" +import { AgentV2 } from "@opencode-ai/core/agent" import { CommandV2 } from "@opencode-ai/core/command" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" @@ -55,7 +56,9 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service< { provides: | Catalog.Service + | AgentV2.Service | CommandV2.Service + | Location.Service | PluginBoot.Service | PermissionV2.Service | ProjectReference.Service diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/server/src/groups/v2/message.ts similarity index 91% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts rename to packages/server/src/groups/v2/message.ts index 109b63f97d..75ae4aadd6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/server/src/groups/v2/message.ts @@ -1,14 +1,12 @@ -import { SessionID } from "@/session/schema" +import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors" import { V2Authorization } from "../../middleware/authorization" -import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" import { data } from "./response" export const MessagesQuery = Schema.Struct({ - ...WorkspaceRoutingQueryFields, limit: Schema.optional( Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), ).annotate({ @@ -28,7 +26,7 @@ export const MessagesQuery = Schema.Struct({ export const MessageGroup = HttpApiGroup.make("v2.message") .add( HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { - params: { sessionID: SessionID }, + params: { sessionID: SessionV2.ID }, query: MessagesQuery, success: data(Schema.Struct({ items: Schema.Array(SessionMessage.Message), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts b/packages/server/src/groups/v2/model.ts similarity index 100% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts rename to packages/server/src/groups/v2/model.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts b/packages/server/src/groups/v2/permission.ts similarity index 100% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/permission.ts rename to packages/server/src/groups/v2/permission.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts b/packages/server/src/groups/v2/provider.ts similarity index 100% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts rename to packages/server/src/groups/v2/provider.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/response.ts b/packages/server/src/groups/v2/response.ts similarity index 100% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/response.ts rename to packages/server/src/groups/v2/response.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/server/src/groups/v2/session.ts similarity index 93% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts rename to packages/server/src/groups/v2/session.ts index fdf6b26dd2..94fddb18b7 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 { Prompt } from "@opencode-ai/core/session/prompt" import { SessionV2 } from "@opencode-ai/core/session" @@ -15,7 +14,6 @@ import { UnknownError, } from "../../errors" import { V2Authorization } from "../../middleware/authorization" -import { WorkspaceRoutingQuery } from "../../middleware/workspace-routing" import { data } from "./response" const SessionsQueryFields = { @@ -108,8 +106,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") ) .add( HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { - params: { sessionID: SessionID }, - query: WorkspaceRoutingQuery, + params: { sessionID: SessionV2.ID }, payload: Schema.Struct({ prompt: Prompt, delivery: SessionV2.Delivery.pipe(Schema.optional), @@ -126,8 +123,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") ) .add( HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { - params: { sessionID: SessionID }, - query: WorkspaceRoutingQuery, + params: { sessionID: SessionV2.ID }, success: HttpApiSchema.NoContent, error: [SessionNotFoundError, ServiceUnavailableError], }).annotateMerge( @@ -140,8 +136,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") ) .add( HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { - params: { sessionID: SessionID }, - query: WorkspaceRoutingQuery, + params: { sessionID: SessionV2.ID }, success: HttpApiSchema.NoContent, error: [SessionNotFoundError, ServiceUnavailableError], }).annotateMerge( @@ -154,8 +149,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") ) .add( HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { - params: { sessionID: SessionID }, - query: WorkspaceRoutingQuery, + params: { sessionID: SessionV2.ID }, success: data(Schema.Array(SessionMessage.Message)), error: [SessionNotFoundError, UnknownError], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/skill.ts b/packages/server/src/groups/v2/skill.ts similarity index 100% rename from packages/opencode/src/server/routes/instance/httpapi/groups/v2/skill.ts rename to packages/server/src/groups/v2/skill.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/server/src/handlers.ts similarity index 51% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts rename to packages/server/src/handlers.ts index c6152f6ddd..8e54d84b18 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts +++ b/packages/server/src/handlers.ts @@ -2,18 +2,22 @@ import { SessionV2 } from "@opencode-ai/core/session" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { Layer } from "effect" -import { layer as v2LocationLayer } from "../groups/v2/location" -import { messageHandlers } from "./v2/message" -import { modelHandlers } from "./v2/model" -import { providerHandlers } from "./v2/provider" -import { sessionHandlers } from "./v2/session" -import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./v2/permission" -import { fileSystemHandlers } from "./v2/fs" -import { commandHandlers } from "./v2/command" -import { skillHandlers } from "./v2/skill" -import { eventHandlers } from "./v2/event" +import { layer as v2LocationLayer } from "./groups/v2/location" +import { messageHandlers } from "./handlers/v2/message" +import { modelHandlers } from "./handlers/v2/model" +import { providerHandlers } from "./handlers/v2/provider" +import { sessionHandlers } from "./handlers/v2/session" +import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./handlers/v2/permission" +import { fileSystemHandlers } from "./handlers/v2/fs" +import { commandHandlers } from "./handlers/v2/command" +import { skillHandlers } from "./handlers/v2/skill" +import { eventHandlers } from "./handlers/v2/event" +import { agentHandlers } from "./handlers/v2/agent" +import { healthHandlers } from "./handlers/v2/health" export const v2Handlers = Layer.mergeAll( + healthHandlers, + agentHandlers, sessionHandlers, messageHandlers, modelHandlers, diff --git a/packages/server/src/handlers/v2/agent.ts b/packages/server/src/handlers/v2/agent.ts new file mode 100644 index 0000000000..ae759e0a1b --- /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/opencode/src/server/routes/instance/httpapi/handlers/v2/command.ts b/packages/server/src/handlers/v2/command.ts similarity index 67% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/command.ts rename to packages/server/src/handlers/v2/command.ts index d9448e0a05..551ad4bce2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/command.ts +++ b/packages/server/src/handlers/v2/command.ts @@ -1,9 +1,9 @@ import { CommandV2 } from "@opencode-ai/core/command" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { InstanceHttpApi } from "../../api" +import { V2Api } from "../../api" import { response } from "../../groups/v2/location" -export const commandHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.command", (handlers) => +export const commandHandlers = HttpApiBuilder.group(V2Api, "v2.command", (handlers) => handlers.handle("commands", () => response(CommandV2.Service.use((command) => command.list()))), ) diff --git a/packages/server/src/handlers/v2/event.ts b/packages/server/src/handlers/v2/event.ts new file mode 100644 index 0000000000..c13fbcbebf --- /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/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts b/packages/server/src/handlers/v2/fs.ts similarity index 76% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts rename to packages/server/src/handlers/v2/fs.ts index b407b21fd8..87c2dd8a18 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts +++ b/packages/server/src/handlers/v2/fs.ts @@ -1,10 +1,10 @@ import { FileSystem } from "@opencode-ai/core/filesystem" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { InstanceHttpApi } from "../../api" +import { V2Api } from "../../api" import { response } from "../../groups/v2/location" -export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) => +export const fileSystemHandlers = HttpApiBuilder.group(V2Api, "v2.fs", (handlers) => Effect.gen(function* () { return handlers .handle("read", (ctx) => response(FileSystem.Service.use((fs) => fs.read(ctx.query)))) diff --git a/packages/server/src/handlers/v2/health.ts b/packages/server/src/handlers/v2/health.ts new file mode 100644 index 0000000000..5d66e5f250 --- /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 95% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts rename to packages/server/src/handlers/v2/message.ts index e0d9228170..3638bca116 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/server/src/handlers/v2/message.ts @@ -3,7 +3,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { Effect, Schema } from "effect" import * as DateTime from "effect/DateTime" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { InstanceHttpApi } from "../../api" +import { V2Api } from "../../api" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors" import { make } from "../../groups/v2/response" @@ -29,7 +29,7 @@ const cursor = { }, } -export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message", (handlers) => +export const messageHandlers = HttpApiBuilder.group(V2Api, "v2.message", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts b/packages/server/src/handlers/v2/model.ts similarity index 85% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts rename to packages/server/src/handlers/v2/model.ts index 7df713d331..8e78705524 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/model.ts +++ b/packages/server/src/handlers/v2/model.ts @@ -2,7 +2,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { InstanceHttpApi } from "../../api" +import { V2Api } from "../../api" import { ServiceUnavailableError } from "../../errors" import { response } from "../../groups/v2/location" @@ -11,7 +11,7 @@ const catalogUnavailable = new ServiceUnavailableError({ service: "catalog", }) -export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) => +export const modelHandlers = HttpApiBuilder.group(V2Api, "v2.model", (handlers) => Effect.gen(function* () { return handlers.handle( "models", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts b/packages/server/src/handlers/v2/permission.ts similarity index 90% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts rename to packages/server/src/handlers/v2/permission.ts index d241bcea7f..9cd3df7dae 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/permission.ts +++ b/packages/server/src/handlers/v2/permission.ts @@ -7,7 +7,7 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import { eq } from "drizzle-orm" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" -import { InstanceHttpApi } from "../../api" +import { V2Api } from "../../api" import { PermissionNotFoundError, SessionNotFoundError } from "../../errors" import { response } from "../../groups/v2/location" import { make } from "../../groups/v2/response" @@ -16,7 +16,7 @@ function missingRequest(id: PermissionV2.ID) { return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` }) } -export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission", (handlers) => +export const permissionHandlers = HttpApiBuilder.group(V2Api, "v2.permission", (handlers) => Effect.gen(function* () { return handlers.handle( "permissionRequests", @@ -27,7 +27,7 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.perm }), ) -export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session.permission", (handlers) => +export const sessionPermissionHandlers = HttpApiBuilder.group(V2Api, "v2.session.permission", (handlers) => Effect.gen(function* () { const { db } = yield* Database.Service const locations = yield* LocationServiceMap @@ -86,7 +86,7 @@ export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, " }), ) -export const savedPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission.saved", (handlers) => +export const savedPermissionHandlers = HttpApiBuilder.group(V2Api, "v2.permission.saved", (handlers) => Effect.gen(function* () { const saved = yield* PermissionSaved.Service return handlers diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts b/packages/server/src/handlers/v2/provider.ts similarity index 91% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts rename to packages/server/src/handlers/v2/provider.ts index 37c9429517..d0b71f3b91 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/provider.ts +++ b/packages/server/src/handlers/v2/provider.ts @@ -2,7 +2,7 @@ import { Catalog } from "@opencode-ai/core/catalog" import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { InstanceHttpApi } from "../../api" +import { V2Api } from "../../api" import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors" import { response } from "../../groups/v2/location" @@ -11,7 +11,7 @@ const catalogUnavailable = new ServiceUnavailableError({ service: "catalog", }) -export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provider", (handlers) => +export const providerHandlers = HttpApiBuilder.group(V2Api, "v2.provider", (handlers) => Effect.gen(function* () { return handlers .handle( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/server/src/handlers/v2/session.ts similarity index 97% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts rename to packages/server/src/handlers/v2/session.ts index 77d7265131..cb64f798fa 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/server/src/handlers/v2/session.ts @@ -1,14 +1,14 @@ import { SessionV2 } from "@opencode-ai/core/session" import { DateTime, Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" -import { InstanceHttpApi } from "../../api" +import { V2Api } from "../../api" import { SessionsCursor } from "../../groups/v2/session" import { InvalidCursorError, ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../../errors" import { make } from "../../groups/v2/response" const DefaultSessionsLimit = 50 -export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) => +export const sessionHandlers = HttpApiBuilder.group(V2Api, "v2.session", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/skill.ts b/packages/server/src/handlers/v2/skill.ts similarity index 64% rename from packages/opencode/src/server/routes/instance/httpapi/handlers/v2/skill.ts rename to packages/server/src/handlers/v2/skill.ts index e10ae66ab2..a4e98cfda1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/skill.ts +++ b/packages/server/src/handlers/v2/skill.ts @@ -1,8 +1,8 @@ import { SkillV2 } from "@opencode-ai/core/skill" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { InstanceHttpApi } from "../../api" +import { V2Api } from "../../api" import { response } from "../../groups/v2/location" -export const skillHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.skill", (handlers) => +export const skillHandlers = HttpApiBuilder.group(V2Api, "v2.skill", (handlers) => handlers.handle("skills", () => response(SkillV2.Service.use((skill) => skill.list()))), ) diff --git a/packages/server/src/middleware/authorization.ts b/packages/server/src/middleware/authorization.ts new file mode 100644 index 0000000000..0411c60bfb --- /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 0000000000..e4b21dd3a4 --- /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 0000000000..52fbe6ed4b --- /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 0000000000..fe5c4d217b --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "noUncheckedIndexedAccess": false + } +} From 1311adfa3ffadfa50b08852b8f25dd4f47c1ecf6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Jun 2026 00:43:49 -0400 Subject: [PATCH 11/20] feat(cli): wire preview npm publishing --- .github/workflows/publish.yml | 11 ++++ packages/cli/bin/lildax.cjs | 97 ++++++++++++++++++++++++++++++++++ packages/cli/package.json | 6 ++- packages/cli/script/build.ts | 27 +++++++--- packages/cli/script/publish.ts | 48 +++++++++++++++++ script/publish.ts | 3 ++ 6 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 packages/cli/bin/lildax.cjs create mode 100644 packages/cli/script/publish.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9887cbe4dc..dc198f0d1f 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,11 @@ 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 +452,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/packages/cli/bin/lildax.cjs b/packages/cli/bin/lildax.cjs new file mode 100644 index 0000000000..24b6c36660 --- /dev/null +++ b/packages/cli/bin/lildax.cjs @@ -0,0 +1,97 @@ +#!/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 + } + } + 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 bcf1418fb5..6f6cf7f1cb 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": { - "lildax": "./src/index.ts" + "lildax": "./bin/lildax.cjs" }, + "files": [ + "bin" + ], "scripts": { "build": "bun run script/build.ts", "dev": "bun run src/index.ts", diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index 7b16318ef3..f91653c265 100644 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -1,11 +1,9 @@ #!/usr/bin/env bun -import { $ } from "bun" import { rm } from "fs/promises" import path from "path" import { Script } from "@opencode-ai/script" import { modelsData } from "./generate" -import pkg from "../package.json" const dir = path.resolve(import.meta.dirname, "..") const binary = "lildax" @@ -15,7 +13,6 @@ await rm("dist", { recursive: true, force: true }) const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") -const skipInstall = process.argv.includes("--skip-install") const sourcemapsFlag = process.argv.includes("--sourcemaps") const allTargets: { @@ -46,8 +43,6 @@ const targets = singleFlag }) : allTargets -if (!skipInstall) await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}` - for (const item of targets) { const name = [ binary, @@ -86,7 +81,23 @@ for (const item of targets) { }, }) - if (result.success) continue - for (const log of result.logs) console.error(log) - process.exit(1) + 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/publish.ts b/packages/cli/script/publish.ts new file mode 100644 index 0000000000..ef9d65cd98 --- /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 $`npm 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/script/publish.ts b/script/publish.ts index 7e91eef762..3b01a9d9d7 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -45,6 +45,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` From e43401f3c553f58744bc53b04a631a4b4231f4b6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Jun 2026 00:44:28 -0400 Subject: [PATCH 12/20] fix(cli): align preview launcher packaging --- packages/cli/bin/lildax.cjs | 19 +++++++++++++++++++ packages/cli/script/publish.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/cli/bin/lildax.cjs b/packages/cli/bin/lildax.cjs index 24b6c36660..e3491f3a43 100644 --- a/packages/cli/bin/lildax.cjs +++ b/packages/cli/bin/lildax.cjs @@ -54,6 +54,25 @@ function supportsAvx2() { 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 } diff --git a/packages/cli/script/publish.ts b/packages/cli/script/publish.ts index ef9d65cd98..5c2ca591f9 100644 --- a/packages/cli/script/publish.ts +++ b/packages/cli/script/publish.ts @@ -14,7 +14,7 @@ async function published(name: string, version: string) { 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 $`npm pack`.cwd(dir) + await $`bun pm pack`.cwd(dir) await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir) } From e0f5983141a9bab4038a9eba1b8f105701293384 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Jun 2026 00:45:23 -0400 Subject: [PATCH 13/20] fix(cli): keep preview publishing standalone --- .github/workflows/publish.yml | 10 ---------- script/publish.ts | 3 --- 2 files changed, 13 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dc198f0d1f..00e07fd82c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -90,7 +90,6 @@ 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 }} @@ -109,10 +108,6 @@ jobs: 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 }} @@ -452,11 +447,6 @@ 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/script/publish.ts b/script/publish.ts index 3b01a9d9d7..7e91eef762 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -45,9 +45,6 @@ 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` From ece816f89b6f484f57a5fb291b9b519c4271c1f0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Jun 2026 00:45:52 -0400 Subject: [PATCH 14/20] fix(cli): include preview npm packages in releases --- .github/workflows/publish.yml | 11 +++++++++++ script/publish.ts | 3 +++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 00e07fd82c..083a0a9e80 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 }} @@ -108,6 +109,11 @@ jobs: 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 }} @@ -447,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/script/publish.ts b/script/publish.ts index 7e91eef762..3b01a9d9d7 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -45,6 +45,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` From d19928884c4df52e34534c28590f3ee89d334752 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Jun 2026 01:26:04 -0400 Subject: [PATCH 15/20] fix(llm): avoid ambient websocket type dependency --- packages/llm/src/route/transport/websocket.ts | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/packages/llm/src/route/transport/websocket.ts b/packages/llm/src/route/transport/websocket.ts index 310121420c..bffbf2efc1 100644 --- a/packages/llm/src/route/transport/websocket.ts +++ b/packages/llm/src/route/transport/websocket.ts @@ -19,10 +19,30 @@ export interface Interface { readonly open: (input: WebSocketRequest) => Effect.Effect } -type WebSocketConstructorWithHeaders = new ( - url: string, - options?: { readonly headers?: Headers.Headers }, -) => globalThis.WebSocket +interface WebSocketLike { + readonly readyState: number + readonly send: (message: string) => void + readonly close: (code?: number) => void + readonly addEventListener: { + (type: "open" | "error", listener: (event: Event) => void, options?: { readonly once?: boolean }): void + (type: "close", listener: (event: CloseEvent) => void, options?: { readonly once?: boolean }): void + (type: "message", listener: (event: MessageEvent) => void, options?: { readonly once?: boolean }): void + } + readonly removeEventListener: { + (type: "open" | "error", listener: (event: Event) => void): void + (type: "close", listener: (event: CloseEvent) => void): void + (type: "message", listener: (event: MessageEvent) => void): void + } +} + +interface WebSocketConstructorWithHeaders { + readonly OPEN: number + readonly CLOSING: number + readonly CLOSED: number + new (url: string, options?: { readonly headers?: Headers.Headers }): WebSocketLike +} + +const WebSocketGlobal = globalThis as unknown as { readonly WebSocket: WebSocketConstructorWithHeaders } export class Service extends Context.Service()("@opencode/LLM/WebSocketExecutor") {} @@ -49,9 +69,9 @@ const binaryMessage = (data: unknown) => { return undefined } -const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => { - if (ws.readyState === globalThis.WebSocket.OPEN) return Effect.void - if (ws.readyState === globalThis.WebSocket.CLOSING || ws.readyState === globalThis.WebSocket.CLOSED) { +const waitOpen = (ws: WebSocketLike, input: WebSocketRequest) => { + if (ws.readyState === WebSocketGlobal.WebSocket.OPEN) return Effect.void + if (ws.readyState === WebSocketGlobal.WebSocket.CLOSING || ws.readyState === WebSocketGlobal.WebSocket.CLOSED) { return Effect.fail( transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, { url: input.url, @@ -68,7 +88,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => { } const onAbort = () => { cleanup() - if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING) + if (ws.readyState !== WebSocketGlobal.WebSocket.CLOSED && ws.readyState !== WebSocketGlobal.WebSocket.CLOSING) ws.close(1000) } const onOpen = () => { @@ -124,8 +144,7 @@ const webSocketUrl = (value: string) => export const open = (input: WebSocketRequest) => Effect.try({ - try: () => - new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }), + try: () => new WebSocketGlobal.WebSocket(input.url, { headers: input.headers }), catch: (error) => transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", { url: input.url, @@ -136,7 +155,7 @@ export const open = (input: WebSocketRequest) => export const layer: Layer.Layer = Layer.succeed(Service, Service.of({ open })) export const fromWebSocket = ( - ws: globalThis.WebSocket, + ws: WebSocketLike, input: WebSocketRequest, ): Effect.Effect => Effect.gen(function* () { @@ -195,7 +214,8 @@ export const fromWebSocket = ( close: cleanup.pipe( Effect.andThen( Effect.sync(() => { - if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return + if (ws.readyState === WebSocketGlobal.WebSocket.CLOSED || ws.readyState === WebSocketGlobal.WebSocket.CLOSING) + return ws.close(1000) }), ), From bb3cb8cd2063c98b1202dfb265cbac83328905cc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Jun 2026 01:31:31 -0400 Subject: [PATCH 16/20] fix(server): include DOM libs for typecheck --- packages/llm/src/route/transport/websocket.ts | 44 +++++-------------- packages/server/tsconfig.json | 1 + 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/packages/llm/src/route/transport/websocket.ts b/packages/llm/src/route/transport/websocket.ts index bffbf2efc1..310121420c 100644 --- a/packages/llm/src/route/transport/websocket.ts +++ b/packages/llm/src/route/transport/websocket.ts @@ -19,30 +19,10 @@ export interface Interface { readonly open: (input: WebSocketRequest) => Effect.Effect } -interface WebSocketLike { - readonly readyState: number - readonly send: (message: string) => void - readonly close: (code?: number) => void - readonly addEventListener: { - (type: "open" | "error", listener: (event: Event) => void, options?: { readonly once?: boolean }): void - (type: "close", listener: (event: CloseEvent) => void, options?: { readonly once?: boolean }): void - (type: "message", listener: (event: MessageEvent) => void, options?: { readonly once?: boolean }): void - } - readonly removeEventListener: { - (type: "open" | "error", listener: (event: Event) => void): void - (type: "close", listener: (event: CloseEvent) => void): void - (type: "message", listener: (event: MessageEvent) => void): void - } -} - -interface WebSocketConstructorWithHeaders { - readonly OPEN: number - readonly CLOSING: number - readonly CLOSED: number - new (url: string, options?: { readonly headers?: Headers.Headers }): WebSocketLike -} - -const WebSocketGlobal = globalThis as unknown as { readonly WebSocket: WebSocketConstructorWithHeaders } +type WebSocketConstructorWithHeaders = new ( + url: string, + options?: { readonly headers?: Headers.Headers }, +) => globalThis.WebSocket export class Service extends Context.Service()("@opencode/LLM/WebSocketExecutor") {} @@ -69,9 +49,9 @@ const binaryMessage = (data: unknown) => { return undefined } -const waitOpen = (ws: WebSocketLike, input: WebSocketRequest) => { - if (ws.readyState === WebSocketGlobal.WebSocket.OPEN) return Effect.void - if (ws.readyState === WebSocketGlobal.WebSocket.CLOSING || ws.readyState === WebSocketGlobal.WebSocket.CLOSED) { +const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => { + if (ws.readyState === globalThis.WebSocket.OPEN) return Effect.void + if (ws.readyState === globalThis.WebSocket.CLOSING || ws.readyState === globalThis.WebSocket.CLOSED) { return Effect.fail( transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, { url: input.url, @@ -88,7 +68,7 @@ const waitOpen = (ws: WebSocketLike, input: WebSocketRequest) => { } const onAbort = () => { cleanup() - if (ws.readyState !== WebSocketGlobal.WebSocket.CLOSED && ws.readyState !== WebSocketGlobal.WebSocket.CLOSING) + if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING) ws.close(1000) } const onOpen = () => { @@ -144,7 +124,8 @@ const webSocketUrl = (value: string) => export const open = (input: WebSocketRequest) => Effect.try({ - try: () => new WebSocketGlobal.WebSocket(input.url, { headers: input.headers }), + try: () => + new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }), catch: (error) => transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", { url: input.url, @@ -155,7 +136,7 @@ export const open = (input: WebSocketRequest) => export const layer: Layer.Layer = Layer.succeed(Service, Service.of({ open })) export const fromWebSocket = ( - ws: WebSocketLike, + ws: globalThis.WebSocket, input: WebSocketRequest, ): Effect.Effect => Effect.gen(function* () { @@ -214,8 +195,7 @@ export const fromWebSocket = ( close: cleanup.pipe( Effect.andThen( Effect.sync(() => { - if (ws.readyState === WebSocketGlobal.WebSocket.CLOSED || ws.readyState === WebSocketGlobal.WebSocket.CLOSING) - return + if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return ws.close(1000) }), ), diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index fe5c4d217b..00ef125468 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/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 } } From 8eba717e9263508e55658602af46442203f63984 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Jun 2026 02:01:56 -0400 Subject: [PATCH 17/20] fix(opencode): restore v2 event bridge compatibility --- packages/opencode/src/event-v2-bridge.ts | 26 +++++++++++++++++-- .../opencode/test/server/httpapi-sdk.test.ts | 2 +- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 673bf1f15b..6a31b42b29 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/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 0f6f8ccdb5..f5ad8e59fb 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -393,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) From 0b3d0a5b5203898f7cb50668d062b085bf39588b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Jun 2026 02:06:08 -0400 Subject: [PATCH 18/20] fix(opencode): use model ids in bedrock tests --- packages/opencode/test/provider/amazon-bedrock.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 5533fba6d5..7cbdb1e1cc 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") From 3680d33d11320704ad20abc5dd94cf80bbfd656c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Jun 2026 02:18:05 -0400 Subject: [PATCH 19/20] test(opencode): wait for share sync flush --- packages/opencode/test/share/share-next.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index feb070a09c..168243abb5 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") From a91c50db5097e2ba0a65a2c8e06ed0cdb1620d51 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 4 Jun 2026 02:31:12 -0400 Subject: [PATCH 20/20] test(opencode): assert wrapped v2 exercise shapes --- .../test/server/httpapi-exercise/index.ts | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 73c26336b0..a8059aca3e 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -657,8 +657,16 @@ const scenarios: Scenario[] = [ .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" })) @@ -702,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() })) @@ -714,9 +725,8 @@ const scenarios: Scenario[] = [ 200, (body) => { object(body) - object(body.data) - array(body.data.items) - object(body.data.cursor) + array(body.data) + object(body.cursor) }, "none", ), @@ -738,9 +748,8 @@ const scenarios: Scenario[] = [ 200, (body) => { object(body) - object(body.data) - array(body.data.items) - object(body.data.cursor) + array(body.data) + object(body.cursor) }, "none", ), @@ -761,9 +770,8 @@ const scenarios: Scenario[] = [ 200, (body) => { object(body) - object(body.data) - array(body.data.items) - object(body.data.cursor) + array(body.data) + object(body.cursor) }, "none", ),