mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c616b3252 | |||
| 8a5d19c376 | |||
| b8ca71d309 | |||
| 6849f96825 | |||
| 00c3248295 | |||
| 818b56dbd0 | |||
| 29b5b24787 | |||
| 11363170ca | |||
| ba9e4b67ed | |||
| bd1029b19f | |||
| 43b51f09d0 | |||
| c61ab51886 | |||
| d373c562f2 | |||
| 5fa5d876fc | |||
| 805af011c9 | |||
| 480aa8b23b | |||
| d62442bb5d | |||
| 8602937a37 | |||
| 77da433e0a | |||
| ad79f3e0cf | |||
| 6c2dfd2f52 | |||
| 9a8b54fe62 | |||
| dcdbdb218f | |||
| 5e49029e70 | |||
| 19abadaf27 | |||
| 6fea0178eb | |||
| ebe9dcf27e | |||
| 57efec4429 | |||
| e22144806f | |||
| 27fa297a42 | |||
| b1cd25de3d | |||
| 780bbb0f3b | |||
| 817cb076a8 | |||
| 347526e9fd | |||
| 092bc674a5 | |||
| b24a4e897e | |||
| 3afa622eab | |||
| d01cb7f013 | |||
| 28b03595bf | |||
| 32684e70e6 | |||
| b2baddcd37 | |||
| dbd48d423d | |||
| e7cc8259b5 | |||
| a9ccb0804f | |||
| ebe6087e8f | |||
| dc978cb889 | |||
| 8cbc43fbb0 | |||
| 82359c4b1b | |||
| 7f3e51453b | |||
| cbdb2d9825 | |||
| 96bde05f6e | |||
| dcb8ed8eb0 | |||
| aab82cc1a7 | |||
| cd1d1e81a6 | |||
| cff441909a | |||
| 3615d5aab1 | |||
| 11d9e82eaf | |||
| eadda11ec9 | |||
| f73a56c223 | |||
| c0acf5c43f | |||
| 4d585464f3 | |||
| f0cb17a812 | |||
| 357a74714a | |||
| ffea6c7974 | |||
| 8e9550d90d | |||
| 9b7b6cb30f | |||
| cc68afb2de | |||
| 11c33d52a5 | |||
| 2a487ea990 | |||
| 9c05d4e2fd |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -120,7 +120,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -147,7 +147,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -171,7 +171,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -195,7 +195,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -229,7 +229,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -283,7 +283,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -313,7 +313,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -329,7 +329,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "0.0.0",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -342,7 +342,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -360,7 +360,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -393,10 +393,6 @@
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
@@ -437,8 +433,6 @@
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
@@ -465,7 +459,6 @@
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
@@ -498,12 +491,11 @@
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
},
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -541,7 +533,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -556,7 +548,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -591,7 +583,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -640,7 +632,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1239,8 +1231,6 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||
|
||||
"@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
|
||||
|
||||
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-ZqTxXSIWZmrNpdIQICbesrMpoL24blahhQzAGTzupAc=",
|
||||
"aarch64-linux": "sha256-YdZAkfJXBCnqPcYS7VUbQqT9AkYnyu/ONVG5v78Lmdw=",
|
||||
"aarch64-darwin": "sha256-3Fdu1le89F53UHuaiHanHP0CB4F72fK9p1d9PC47Wsg=",
|
||||
"x86_64-darwin": "sha256-Tc4WppnKZPzYn/ICIv1vtx+NiNqAHAzd/ogTRUCQK18="
|
||||
"x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=",
|
||||
"aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=",
|
||||
"aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=",
|
||||
"x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -28,6 +28,12 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
||||
@@ -70,7 +76,8 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||
const diffs = createMemo(() => props.diffs().filter(renderDiff))
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
if (!a) return b
|
||||
@@ -81,7 +88,7 @@ export function SessionSidePanel(props: {
|
||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||
|
||||
const out = new Map<string, "add" | "del" | "mix">()
|
||||
for (const diff of props.diffs()) {
|
||||
for (const diff of diffs()) {
|
||||
const file = normalize(diff.file)
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Config } from "effect"
|
||||
import { InstallationChannel } from "../installation/version"
|
||||
|
||||
function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
@@ -11,10 +10,6 @@ function falsy(key: string) {
|
||||
return value === "false" || value === "0"
|
||||
}
|
||||
|
||||
// Channels that default to the new effect-httpapi server backend. The legacy
|
||||
// hono backend remains the default for stable (`prod`/`latest`) installs.
|
||||
const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"])
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
if (!value) return undefined
|
||||
@@ -88,14 +83,6 @@ export const Flag = {
|
||||
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
|
||||
|
||||
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
|
||||
// Defaults to true on dev/beta/local channels so internal users exercise the
|
||||
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
|
||||
// on the legacy hono backend until the rollout is complete. An explicit env
|
||||
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
|
||||
// stable users and an escape hatch for dev/beta users.
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI:
|
||||
truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") ||
|
||||
(!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.41"
|
||||
version = "1.14.44"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.0.0",
|
||||
"version": "1.14.44",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.25",
|
||||
"version": "1.14.44",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL;
|
||||
ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.41",
|
||||
"version": "1.14.44",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -9,7 +9,7 @@
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test --timeout 30000",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip",
|
||||
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
|
||||
"build": "bun run script/build.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
@@ -33,11 +33,6 @@
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
},
|
||||
"#hono": {
|
||||
"bun": "./src/server/adapter.bun.ts",
|
||||
"node": "./src/server/adapter.node.ts",
|
||||
"default": "./src/server/adapter.bun.ts"
|
||||
},
|
||||
"#httpapi-server": {
|
||||
"bun": "./src/server/httpapi-server.node.ts",
|
||||
"node": "./src/server/httpapi-server.node.ts",
|
||||
@@ -74,8 +69,7 @@
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
"zod-to-json-schema": "3.24.5"
|
||||
"why-is-node-running": "3.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
@@ -106,10 +100,6 @@
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
@@ -150,8 +140,6 @@
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
@@ -177,8 +165,7 @@
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
"zod-to-json-schema": "3.24.5"
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"overrides": {
|
||||
"drizzle-orm": "catalog:"
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Compare SDK types generated from Hono vs HttpApi specs.
|
||||
# Sorts types alphabetically so only meaningful body differences show.
|
||||
#
|
||||
# Usage: ./scripts/diff-sdk-types.sh # full diff
|
||||
# ./scripts/diff-sdk-types.sh --stat # summary only
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SDK="$(cd "$DIR/../sdk/js" && pwd)"
|
||||
|
||||
normalize() {
|
||||
python3 -c "
|
||||
import re, sys
|
||||
content = open(sys.argv[1]).read()
|
||||
blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE)
|
||||
header, body = blocks[0], blocks[1:]
|
||||
body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '')
|
||||
sys.stdout.write(header + ''.join(body))
|
||||
" "$1"
|
||||
}
|
||||
|
||||
echo "Generating Hono SDK..." >&2
|
||||
(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1)
|
||||
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts
|
||||
git -C "$SDK" checkout -- src/ 2>/dev/null
|
||||
|
||||
echo "Generating HttpApi SDK..." >&2
|
||||
(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1)
|
||||
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts
|
||||
git -C "$SDK" checkout -- src/ 2>/dev/null
|
||||
|
||||
echo "" >&2
|
||||
if [[ "${1:-}" == "--stat" ]]; then
|
||||
diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true)
|
||||
honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true)
|
||||
aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true)
|
||||
total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ')
|
||||
echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total"
|
||||
echo ""
|
||||
if [[ $honly -gt 0 ]]; then
|
||||
echo "=== Hono-only types ==="
|
||||
printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /'
|
||||
echo ""
|
||||
fi
|
||||
if [[ $aonly -gt 0 ]]; then
|
||||
echo "=== HttpApi-only types ==="
|
||||
printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /'
|
||||
fi
|
||||
else
|
||||
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true
|
||||
fi
|
||||
@@ -1,401 +0,0 @@
|
||||
# HttpApi migration
|
||||
|
||||
Plan for replacing instance Hono route implementations with Effect `HttpApi` while preserving behavior, OpenAPI, and SDK output during the transition.
|
||||
|
||||
## End State
|
||||
|
||||
- JSON route contracts and handlers live in `src/server/routes/instance/httpapi/*`.
|
||||
- Route modules own their `HttpApiGroup`, schemas, handlers, and route-level middleware.
|
||||
- `httpapi/server.ts` only composes groups, instance lookup, observability, and the web handler bridge.
|
||||
- Hono route implementations are deleted once their `HttpApi` replacements are default, tested, and represented in the SDK/OpenAPI pipeline.
|
||||
- Streaming, SSE, and websocket routes move later through Effect HTTP primitives or another explicit replacement plan; they do not need to fit `HttpApi` if `HttpApi` is the wrong abstraction.
|
||||
|
||||
## Current State
|
||||
|
||||
- `OPENCODE_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`.
|
||||
- `server/backend.ts` picks one of `effect-httpapi` or `hono`; `server.ts` builds either a pure Effect `HttpApi` web handler or the legacy Hono app accordingly. The earlier in-Hono "bridge" model has been replaced by this fork-at-startup.
|
||||
- Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation.
|
||||
- An Effect `HttpApi` OpenAPI surface exists (`OpenApi.fromApi(PublicApi)` in `cli/cmd/generate.ts --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi` in `packages/sdk/js/script/build.ts`) but is opt-in. The default SDK generation is still Hono.
|
||||
- `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4.
|
||||
- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module.
|
||||
- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`.
|
||||
- Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-opencode-directory`.
|
||||
- `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`.
|
||||
- CORS middleware is wired into both backends (`feat(httpapi): add CORS middleware to instance routes`).
|
||||
|
||||
## Migration Rules
|
||||
|
||||
- Preserve runtime behavior first. Semantic changes, new error behavior, or route shape changes need separate PRs.
|
||||
- Migrate one route group, or one coherent subset of a route group, at a time.
|
||||
- Reuse existing services. Do not re-architect service logic during HTTP boundary migration.
|
||||
- Effect Schema owns route DTOs. Keep `.zod` only as compatibility for remaining Hono/OpenAPI surfaces.
|
||||
- Regenerate the SDK after schema or OpenAPI-affecting changes and verify the diff is expected.
|
||||
- Do not delete a Hono route until the SDK/OpenAPI pipeline no longer depends on its Hono `describeRoute` entry.
|
||||
|
||||
## Route Slice Checklist
|
||||
|
||||
Use this checklist for each small HttpApi migration PR:
|
||||
|
||||
1. Read the legacy Hono route and copy behavior exactly, including default values, headers, operation IDs, response schemas, and status codes.
|
||||
2. Put the new `HttpApiGroup`, route paths, DTO schemas, and handlers in `src/server/routes/instance/httpapi/*`.
|
||||
3. Mount the new paths in `src/server/routes/instance/index.ts` only inside the `OPENCODE_EXPERIMENTAL_HTTPAPI` block.
|
||||
4. Use `InstanceState.context` / `InstanceState.directory` inside HttpApi handlers instead of `Instance.directory`, `Instance.worktree`, or `Instance.project` ALS globals.
|
||||
5. Reuse existing services directly. If a service returns plain objects, use `Schema.Struct`; use `Schema.Class` only when handlers return actual class instances.
|
||||
6. Keep legacy Hono routes and `.zod` compatibility in place for SDK/OpenAPI generation.
|
||||
7. Add tests that hit the Hono-mounted bridge via `InstanceRoutes`, not only the raw `HttpApi` web handler, when the route depends on auth or instance context.
|
||||
8. Run `bun typecheck` from `packages/opencode`, relevant `bun run test:ci ...` tests from `packages/opencode`, and `./packages/sdk/js/script/build.ts` from the repo root.
|
||||
|
||||
## Hono Deletion Checklist
|
||||
|
||||
Use this checklist before deleting any Hono route implementation. A route being `bridged` is not enough.
|
||||
|
||||
1. `HttpApi` parity is complete for the route path, method, auth behavior, query parameters, request body, response status, response headers, and error status.
|
||||
2. The route is mounted by default, not only behind `OPENCODE_EXPERIMENTAL_HTTPAPI`.
|
||||
3. If a fallback flag exists, tests cover both the default `HttpApi` path and the fallback Hono path until the fallback is removed.
|
||||
4. OpenAPI generation uses the Effect `HttpApi` route as the source for that path.
|
||||
5. Generated SDK output is unchanged from the Hono-generated contract, or the SDK diff is intentionally reviewed and accepted.
|
||||
6. The legacy Hono `describeRoute`, validator, and handler for that path are removed.
|
||||
7. Any duplicate Zod-only DTOs are deleted or kept only as `.zod` compatibility on the canonical Effect Schema.
|
||||
8. Bridge tests exist for auth, instance selection, success response, and route-specific side effects.
|
||||
9. Mutation routes prove persisted side effects and cleanup behavior in tests. If the mutation disposes/reloads the active instance, disposal happens through an explicit post-response lifecycle hook rather than inline handler teardown.
|
||||
10. Streaming, SSE, websocket, and UI bridge routes have a specific non-Hono replacement plan. Do not force them through `HttpApi` if raw Effect HTTP is a better fit.
|
||||
|
||||
Hono can be removed from the instance server only after all mounted Hono route groups meet this checklist and `server/routes/instance/index.ts` no longer depends on Hono routing for default behavior.
|
||||
|
||||
## Experimental Read Slice Guidance
|
||||
|
||||
For the experimental route group, port read-only JSON routes before mutations:
|
||||
|
||||
- Good first batch: `GET /console`, `GET /console/orgs`, `GET /tool/ids`, `GET /resource`.
|
||||
- Consider `GET /worktree` only if the handler uses `InstanceState.context` instead of `Instance.project`.
|
||||
- Defer `POST /console/switch`, worktree create/remove/reset, and `GET /session` to separate PRs because they mutate state or have broader pagination/session behavior.
|
||||
- Preserve response headers such as pagination cursors if a route is ported.
|
||||
- If SDK generation changes, explain whether it is a semantic contract change or a generator-equivalent type normalization.
|
||||
|
||||
## Schema Notes
|
||||
|
||||
- Use `Schema.Struct(...).annotate({ identifier })` for named OpenAPI refs when handlers return plain objects.
|
||||
- Use `Schema.Class` only when the handler returns real class instances or the constructor requirement is intentional.
|
||||
- Keep nested anonymous shapes as `Schema.Struct` unless a named SDK type is useful.
|
||||
- Avoid parallel hand-written Zod and Effect definitions for the same route boundary.
|
||||
|
||||
## Phases
|
||||
|
||||
### 1. Stabilize The Bridge
|
||||
|
||||
Before porting more routes, cover the bridge behavior that every route depends on.
|
||||
|
||||
- Add tests that hit the Hono-mounted `HttpApi` bridge, not just `HttpApiBuilder.layer` directly.
|
||||
- Cover auth disabled, Basic auth success, `auth_token` success, missing credentials, and bad credentials.
|
||||
- Cover `directory` and `x-opencode-directory` instance selection.
|
||||
- Verify generated SDK output remains unchanged for non-SDK work.
|
||||
- Fix or remove any implemented-but-unmounted `HttpApi` groups.
|
||||
|
||||
### 2. Complete The Inventory
|
||||
|
||||
Create a route inventory from the actual Hono registrations and classify each route.
|
||||
|
||||
Statuses:
|
||||
|
||||
- `bridged`: served through the `HttpApi` bridge when the flag is on.
|
||||
- `implemented`: `HttpApi` group exists but is not mounted through Hono.
|
||||
- `next`: good JSON candidate for near-term porting.
|
||||
- `later`: portable, but needs schema/service cleanup first.
|
||||
- `special`: SSE, websocket, streaming, or UI bridge behavior that likely needs raw Effect HTTP rather than `HttpApi`.
|
||||
|
||||
### 3. Finish JSON Route Parity
|
||||
|
||||
Port remaining JSON routes in small batches.
|
||||
|
||||
Good near-term candidates:
|
||||
|
||||
- top-level reads: `GET /path`, `GET /vcs`, `GET /vcs/diff`, `GET /command`, `GET /agent`, `GET /skill`, `GET /lsp`, `GET /formatter`
|
||||
- simple mutations: `POST /instance/dispose`
|
||||
- experimental JSON reads: console, tool, worktree list, resource list
|
||||
- deferred JSON mutations: workspace/worktree create/remove/reset, file search, MCP auth flows
|
||||
|
||||
Keep large or stateful groups for later:
|
||||
|
||||
- `session`
|
||||
- `sync`
|
||||
- process-level experimental routes
|
||||
|
||||
### 4. Move OpenAPI And SDK Generation
|
||||
|
||||
Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation.
|
||||
|
||||
Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip:
|
||||
|
||||
- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing).
|
||||
- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing).
|
||||
- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names).
|
||||
- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix.
|
||||
- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output.
|
||||
|
||||
Required before route deletion:
|
||||
|
||||
- Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path.
|
||||
- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional.
|
||||
- Flip `packages/sdk/js/script/build.ts` default to `httpapi` and regenerate.
|
||||
- Compare generated SDK output against `dev` for every route group deletion.
|
||||
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
|
||||
|
||||
V2 cleanup once SDK compatibility no longer needs the legacy Hono contract:
|
||||
|
||||
- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses.
|
||||
- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape.
|
||||
- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks.
|
||||
- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape.
|
||||
- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec.
|
||||
|
||||
### 5. Make HttpApi Default For JSON Routes
|
||||
|
||||
After JSON parity and SDK generation are covered:
|
||||
|
||||
- Flip the bridge default for ported JSON routes.
|
||||
- Keep a short-lived fallback flag for the old Hono implementation.
|
||||
- Run the same tests against both the default and fallback path during rollout.
|
||||
- Stop adding new Hono handlers for JSON routes once the default flips.
|
||||
|
||||
### 6. Delete Hono Route Implementations
|
||||
|
||||
Delete Hono routes group-by-group after each group meets the deletion criteria.
|
||||
|
||||
Deletion criteria:
|
||||
|
||||
- `HttpApi` route is mounted by default.
|
||||
- Behavior is covered by bridge-level tests.
|
||||
- OpenAPI/SDK generation comes from Effect for that path.
|
||||
- SDK diff is zero or explicitly accepted.
|
||||
- Legacy Hono route is no longer needed as a fallback.
|
||||
|
||||
After deleting a group:
|
||||
|
||||
- Remove its Hono route file or dead endpoints.
|
||||
- Remove its `.route(...)` registration from `instance/index.ts`.
|
||||
- Remove duplicate Zod-only route DTOs if Effect Schema now owns the type.
|
||||
- Regenerate SDK and verify output.
|
||||
|
||||
### 7. Replace Special Routes
|
||||
|
||||
Special routes need explicit designs before Hono can disappear completely.
|
||||
|
||||
- `event`: SSE
|
||||
- `pty`: websocket
|
||||
- `tui`: UI/control bridge behavior
|
||||
- streaming `session` endpoints
|
||||
|
||||
Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Hono implementations, not forcing every transport shape through `HttpApi`.
|
||||
|
||||
## Current Route Status
|
||||
|
||||
| Area | Status | Notes |
|
||||
| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
|
||||
| `question` | `bridged` | `GET /question`, reply, reject |
|
||||
| `permission` | `bridged` | list and reply |
|
||||
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
|
||||
| `config` | `bridged` | read, providers, update |
|
||||
| `project` | `bridged` | list, current, git init, update |
|
||||
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
|
||||
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
||||
| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore |
|
||||
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
||||
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
|
||||
| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
|
||||
| `sync` | `bridged` | start/replay/history |
|
||||
| `event` | `bridged` | SSE via raw Effect HTTP |
|
||||
| `pty` | `special` | websocket |
|
||||
| `tui` | `special` | UI bridge |
|
||||
|
||||
## Full Route Checklist
|
||||
|
||||
This checklist tracks bridge parity only. Checked routes are available through the experimental `HttpApi` bridge; Hono deletion is tracked separately by the deletion checklist above.
|
||||
|
||||
### Top-Level Instance Routes
|
||||
|
||||
- [x] `POST /instance/dispose` - dispose active instance after response.
|
||||
- [x] `GET /path` - current directory and worktree paths.
|
||||
- [x] `GET /vcs` - current VCS status.
|
||||
- [x] `GET /vcs/diff` - VCS diff summary.
|
||||
- [x] `GET /command` - command catalog.
|
||||
- [x] `GET /agent` - agent catalog.
|
||||
- [x] `GET /skill` - skill catalog.
|
||||
- [x] `GET /lsp` - LSP status.
|
||||
- [x] `GET /formatter` - formatter status.
|
||||
|
||||
### Config Routes
|
||||
|
||||
- [x] `GET /config` - read config.
|
||||
- [x] `PATCH /config` - update config and dispose active instance after response.
|
||||
- [x] `GET /config/providers` - config provider summary.
|
||||
|
||||
### Project Routes
|
||||
|
||||
- [x] `GET /project` - list projects.
|
||||
- [x] `GET /project/current` - current project.
|
||||
- [x] `POST /project/git/init` - initialize git and reload active instance after response.
|
||||
- [x] `PATCH /project/:projectID` - update project metadata.
|
||||
|
||||
### Provider Routes
|
||||
|
||||
- [x] `GET /provider` - list providers.
|
||||
- [x] `GET /provider/auth` - list provider auth methods.
|
||||
- [x] `POST /provider/:providerID/oauth/authorize` - start provider OAuth.
|
||||
- [x] `POST /provider/:providerID/oauth/callback` - finish provider OAuth.
|
||||
|
||||
### Question Routes
|
||||
|
||||
- [x] `GET /question` - list questions.
|
||||
- [x] `POST /question/:requestID/reply` - reply to question.
|
||||
- [x] `POST /question/:requestID/reject` - reject question.
|
||||
|
||||
### Permission Routes
|
||||
|
||||
- [x] `GET /permission` - list permission requests.
|
||||
- [x] `POST /permission/:requestID/reply` - reply to permission request.
|
||||
|
||||
### File Routes
|
||||
|
||||
- [x] `GET /find` - text search.
|
||||
- [x] `GET /find/file` - file search.
|
||||
- [x] `GET /find/symbol` - symbol search.
|
||||
- [x] `GET /file` - list directory entries.
|
||||
- [x] `GET /file/content` - read file content.
|
||||
- [x] `GET /file/status` - file status.
|
||||
|
||||
### MCP Routes
|
||||
|
||||
- [x] `GET /mcp` - MCP status.
|
||||
- [x] `POST /mcp` - add MCP server at runtime.
|
||||
- [x] `POST /mcp/:name/auth` - start MCP OAuth.
|
||||
- [x] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback.
|
||||
- [x] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow.
|
||||
- [x] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials.
|
||||
- [x] `POST /mcp/:name/connect` - connect MCP server.
|
||||
- [x] `POST /mcp/:name/disconnect` - disconnect MCP server.
|
||||
|
||||
### Experimental Routes
|
||||
|
||||
- [x] `GET /experimental/console` - active Console provider metadata.
|
||||
- [x] `GET /experimental/console/orgs` - switchable Console orgs.
|
||||
- [x] `POST /experimental/console/switch` - switch active Console org.
|
||||
- [x] `GET /experimental/tool/ids` - tool IDs.
|
||||
- [x] `GET /experimental/tool` - tools for provider/model.
|
||||
- [x] `GET /experimental/worktree` - list worktrees.
|
||||
- [x] `POST /experimental/worktree` - create worktree.
|
||||
- [x] `DELETE /experimental/worktree` - remove worktree.
|
||||
- [x] `POST /experimental/worktree/reset` - reset worktree.
|
||||
- [x] `GET /experimental/session` - global session list.
|
||||
- [x] `GET /experimental/resource` - MCP resources.
|
||||
|
||||
### Workspace Routes
|
||||
|
||||
- [x] `GET /experimental/workspace/adapter` - list workspace adapters.
|
||||
- [x] `POST /experimental/workspace` - create workspace.
|
||||
- [x] `GET /experimental/workspace` - list workspaces.
|
||||
- [x] `GET /experimental/workspace/status` - workspace status.
|
||||
- [x] `DELETE /experimental/workspace/:id` - remove workspace.
|
||||
- [x] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
|
||||
|
||||
### Sync Routes
|
||||
|
||||
- [x] `POST /sync/start` - start workspace sync.
|
||||
- [x] `POST /sync/replay` - replay sync events.
|
||||
- [x] `POST /sync/history` - list sync event history.
|
||||
|
||||
### Session Routes
|
||||
|
||||
- [x] `GET /session` - list sessions.
|
||||
- [x] `GET /session/status` - session status map.
|
||||
- [x] `GET /session/:sessionID` - get session.
|
||||
- [x] `GET /session/:sessionID/children` - get child sessions.
|
||||
- [x] `GET /session/:sessionID/todo` - get session todos.
|
||||
- [x] `POST /session` - create session.
|
||||
- [x] `DELETE /session/:sessionID` - delete session.
|
||||
- [x] `PATCH /session/:sessionID` - update session metadata.
|
||||
- [x] `POST /session/:sessionID/init` - run project init command.
|
||||
- [x] `POST /session/:sessionID/fork` - fork session.
|
||||
- [x] `POST /session/:sessionID/abort` - abort session.
|
||||
- [x] `POST /session/:sessionID/share` - share session.
|
||||
- [x] `GET /session/:sessionID/diff` - session diff.
|
||||
- [x] `DELETE /session/:sessionID/share` - unshare session.
|
||||
- [x] `POST /session/:sessionID/summarize` - summarize session.
|
||||
- [x] `GET /session/:sessionID/message` - list session messages.
|
||||
- [x] `GET /session/:sessionID/message/:messageID` - get message.
|
||||
- [x] `DELETE /session/:sessionID/message/:messageID` - delete message.
|
||||
- [x] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
|
||||
- [x] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
|
||||
- [x] `POST /session/:sessionID/message` - prompt with streaming response.
|
||||
- [x] `POST /session/:sessionID/prompt_async` - async prompt.
|
||||
- [x] `POST /session/:sessionID/command` - run command.
|
||||
- [x] `POST /session/:sessionID/shell` - run shell command.
|
||||
- [x] `POST /session/:sessionID/revert` - revert message.
|
||||
- [x] `POST /session/:sessionID/unrevert` - restore reverted messages.
|
||||
- [x] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route.
|
||||
|
||||
### Event Routes
|
||||
|
||||
- [x] `GET /event` - SSE event stream via raw Effect HTTP.
|
||||
|
||||
### PTY Routes
|
||||
|
||||
- [x] `GET /pty` - list PTY sessions.
|
||||
- [x] `POST /pty` - create PTY session.
|
||||
- [x] `GET /pty/:ptyID` - get PTY session.
|
||||
- [x] `PUT /pty/:ptyID` - update PTY session.
|
||||
- [x] `DELETE /pty/:ptyID` - remove PTY session.
|
||||
- [x] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support.
|
||||
|
||||
### TUI Routes
|
||||
|
||||
- [x] `POST /tui/append-prompt` - append prompt.
|
||||
- [x] `POST /tui/open-help` - open help.
|
||||
- [x] `POST /tui/open-sessions` - open sessions.
|
||||
- [x] `POST /tui/open-themes` - open themes.
|
||||
- [x] `POST /tui/open-models` - open models.
|
||||
- [x] `POST /tui/submit-prompt` - submit prompt.
|
||||
- [x] `POST /tui/clear-prompt` - clear prompt.
|
||||
- [x] `POST /tui/execute-command` - execute command.
|
||||
- [x] `POST /tui/show-toast` - show toast.
|
||||
- [x] `POST /tui/publish` - publish TUI event.
|
||||
- [x] `POST /tui/select-session` - select session.
|
||||
- [x] `GET /tui/control/next` - get next TUI request.
|
||||
- [x] `POST /tui/control/response` - submit TUI control response.
|
||||
|
||||
## Remaining PR Plan
|
||||
|
||||
Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays reviewable.
|
||||
|
||||
1. [x] Bridge `PATCH /project/:projectID`.
|
||||
2. [x] Bridge MCP add/connect/disconnect routes.
|
||||
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
|
||||
4. [x] Bridge experimental console switch and tool list routes.
|
||||
5. [x] Bridge experimental global session list.
|
||||
6. [x] Bridge workspace create/remove/session-restore routes.
|
||||
7. [x] Bridge sync start/replay/history routes.
|
||||
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
|
||||
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
|
||||
10. [x] Bridge remaining session mutation and prompt routes.
|
||||
11. [ ] Replace event SSE with non-Hono Effect HTTP. The Effect backend has a raw Effect HTTP `httpapi/event.ts`; the Hono backend still uses `hono/streaming` `streamSSE`. Either port Hono `/event` to raw Effect HTTP for the fallback window, or skip and delete it together with Hono in step 15.
|
||||
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP for the Effect backend. Hono `pty.ts` remains in the Hono backend.
|
||||
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer for the Effect backend. Hono `tui.ts` remains in the Hono backend.
|
||||
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. Effect path is implemented and opt-in via `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. Close the schema-shape gaps in `public.ts` (branded `pattern`, per-property `description`, `Event.*` / `SyncEvent.*` naming, dedup collisions), then flip `packages/sdk/js/script/build.ts` default.
|
||||
15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `OPENCODE_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Add first `HttpApi` JSON route slices.
|
||||
- [x] Bridge selected `HttpApi` routes behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.)
|
||||
- [x] Reuse existing Effect services in handlers.
|
||||
- [x] Provide auth, instance lookup, and observability in the Effect route layer.
|
||||
- [x] Centralize auth via Effect `Config` for the Effect backend.
|
||||
- [x] Support `auth_token` as a query security scheme.
|
||||
- [x] Add bridge-level auth and instance tests.
|
||||
- [x] Complete exact Hono route inventory.
|
||||
- [x] Resolve implemented-but-unmounted route groups.
|
||||
- [x] Port remaining top-level JSON reads.
|
||||
- [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`.
|
||||
- [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default.
|
||||
- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag.
|
||||
- [ ] Delete replaced Hono route implementations.
|
||||
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono).
|
||||
@@ -59,10 +59,10 @@ Rules:
|
||||
|
||||
## Schema → Zod interop
|
||||
|
||||
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`:
|
||||
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`:
|
||||
|
||||
```ts
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
||||
```
|
||||
|
||||
@@ -8,7 +8,7 @@ Zod-first definitions to Effect Schema with Zod compatibility shims.
|
||||
Use Effect Schema as the source of truth for domain models, IDs, inputs,
|
||||
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
|
||||
compatibility boundaries by exposing a `.zod` static derived from the Effect
|
||||
schema via `@/util/effect-zod`.
|
||||
schema via `@opencode-ai/core/effect-zod`.
|
||||
|
||||
The long-term driver is `specs/effect/http-api.md` — once the HTTP server
|
||||
moves to `@effect/platform`, every Schema-first DTO can flow through
|
||||
@@ -97,7 +97,7 @@ creating a parallel schema source of truth.
|
||||
|
||||
## Escape hatches
|
||||
|
||||
The walker in `@/util/effect-zod` exposes two explicit escape hatches for
|
||||
The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for
|
||||
cases the pure-Schema path cannot express. Each one stays in the codebase
|
||||
only as long as its upstream or local dependency requires it — inline
|
||||
comments document when each can be deleted.
|
||||
@@ -389,7 +389,7 @@ piecewise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `@/util/effect-zod` for all Schema → Zod conversion.
|
||||
- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion.
|
||||
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
|
||||
Effect definitions for the same domain type.
|
||||
- Keep the migration incremental. Converting the domain model first is more
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# TUI Command Shim Removal
|
||||
|
||||
Problem:
|
||||
|
||||
- v1 keeps a deprecated `api.command` TUI plugin shim so older plugins do not fail during initialization
|
||||
- v2 should expose only the keymap command API
|
||||
- tests and fixtures should not encode legacy command behavior as expected behavior
|
||||
|
||||
## Remove Public Types
|
||||
|
||||
In `packages/plugin/src/tui.ts`, remove:
|
||||
|
||||
- `TuiCommand`
|
||||
- `TuiCommandApi`
|
||||
- `TuiPluginApi.command`
|
||||
|
||||
Keep `api.keymap` as the only TUI command registration and execution surface.
|
||||
|
||||
## Remove Runtime Shim
|
||||
|
||||
Delete `packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts`.
|
||||
|
||||
In `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`, remove:
|
||||
|
||||
- the `createCommandShim` import
|
||||
- the `command: createCommandShim(...)` field from `createTuiApi(...)`
|
||||
|
||||
In `packages/opencode/src/cli/cmd/tui/plugin/runtime.ts`, remove:
|
||||
|
||||
- the `createCommandShim` import
|
||||
- the `command: createCommandShim(...)` field from `pluginApi(...)`
|
||||
|
||||
## Migration Target
|
||||
|
||||
Plugin authors should replace old calls with keymap calls:
|
||||
|
||||
```ts
|
||||
api.keymap.registerLayer({
|
||||
commands: [
|
||||
{
|
||||
name: "plugin.command",
|
||||
title: "Plugin Command",
|
||||
namespace: "palette",
|
||||
slashName: "plugin",
|
||||
run() {
|
||||
api.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: [{ key: "ctrl+shift+p", cmd: "plugin.command" }],
|
||||
})
|
||||
```
|
||||
|
||||
Direct replacements:
|
||||
|
||||
- `api.command.register(cb)` -> `api.keymap.registerLayer({ commands, bindings })`
|
||||
- `api.command.trigger(name)` -> `api.keymap.dispatchCommand(name)`
|
||||
- `api.command.show()` -> `api.keymap.dispatchCommand("command.palette.show")`
|
||||
- `onSelect(dialog)` -> use `api.ui.dialog` from the plugin API closure
|
||||
|
||||
## Verification
|
||||
|
||||
After removal, run from package directories:
|
||||
|
||||
- `bun typecheck` in `packages/plugin`
|
||||
- `bun typecheck` in `packages/opencode`
|
||||
- TUI plugin loader tests in `packages/opencode` if runtime plugin API wiring changed
|
||||
@@ -347,33 +347,7 @@ export class Agent implements ACPAgent {
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
}
|
||||
const content = completedToolContent(part, kind)
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = decodeTodos(part.state.output)
|
||||
@@ -413,10 +387,7 @@ export class Agent implements ACPAgent {
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawInput: part.state.input,
|
||||
rawOutput: {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
rawOutput: completedToolRawOutput(part),
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -860,33 +831,7 @@ export class Agent implements ACPAgent {
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
}
|
||||
const content = completedToolContent(part, kind)
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = decodeTodos(part.state.output)
|
||||
@@ -926,10 +871,7 @@ export class Agent implements ACPAgent {
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawInput: part.state.input,
|
||||
rawOutput: {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
rawOutput: completedToolRawOutput(part),
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -1655,6 +1597,70 @@ function toLocations(toolName: string, input: Record<string, any>): { path: stri
|
||||
}
|
||||
}
|
||||
|
||||
function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] {
|
||||
if (part.state.status !== "completed") return []
|
||||
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
}
|
||||
|
||||
content.push(...imageContents(part.state.attachments ?? []))
|
||||
return content
|
||||
}
|
||||
|
||||
function completedToolRawOutput(part: ToolPart) {
|
||||
if (part.state.status !== "completed") return {}
|
||||
return {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] {
|
||||
return attachments.flatMap((attachment): ToolCallContent[] => {
|
||||
const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/)
|
||||
const mime = match?.[1] ?? attachment.mime
|
||||
if (!mime.startsWith("image/")) return []
|
||||
const data = match?.[2]
|
||||
if (data === undefined) return []
|
||||
return [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "image" as const,
|
||||
mimeType: mime,
|
||||
data,
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> {
|
||||
const sdk = config.sdk
|
||||
const configured = config.defaultModel
|
||||
|
||||
@@ -24,8 +24,8 @@ import { Effect, Context, Layer, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics, type DeepMutable } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
|
||||
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
|
||||
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Permission } from "../permission"
|
||||
import type { Agent } from "./agent"
|
||||
|
||||
/**
|
||||
* Build the `permission` ruleset for a subagent's session when it's spawned
|
||||
* via the task tool. Combines:
|
||||
*
|
||||
* 1. The parent **agent's** deny rules — Plan Mode and other agent-level
|
||||
* restrictions live on the agent ruleset, not on the session, so a
|
||||
* subagent that only inherited the parent SESSION's permission would
|
||||
* silently bypass them. (#26514)
|
||||
* 2. The parent **session's** deny rules and external_directory rules —
|
||||
* same forwarding the original code already did.
|
||||
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
|
||||
* doesn't already permit them.
|
||||
*/
|
||||
export function deriveSubagentSessionPermission(input: {
|
||||
parentSessionPermission: Permission.Ruleset
|
||||
parentAgent: Agent.Info | undefined
|
||||
subagent: Agent.Info
|
||||
}): Permission.Ruleset {
|
||||
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
|
||||
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
|
||||
const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
|
||||
return [
|
||||
...parentAgentDenies,
|
||||
...input.parentSessionPermission.filter(
|
||||
(rule) => rule.permission === "external_directory" || rule.action === "deny",
|
||||
),
|
||||
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
|
||||
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
|
||||
type: Type
|
||||
@@ -18,23 +16,6 @@ export function define<Type extends string, Properties extends Schema.Top>(
|
||||
return result
|
||||
}
|
||||
|
||||
export function payloads() {
|
||||
return registry
|
||||
.entries()
|
||||
.map(([type, def]) => {
|
||||
return z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: z.literal(type),
|
||||
properties: zodObject(def.properties),
|
||||
})
|
||||
.meta({
|
||||
ref: `Event.${def.type}`,
|
||||
})
|
||||
})
|
||||
.toArray()
|
||||
}
|
||||
|
||||
export function effectPayloads() {
|
||||
return registry
|
||||
.entries()
|
||||
|
||||
@@ -23,11 +23,11 @@ function span(id: string, value: { value: string; start: number; end: number })
|
||||
}
|
||||
}
|
||||
|
||||
function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) {
|
||||
function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefined) {
|
||||
return diffs?.map((item, i) => ({
|
||||
...item,
|
||||
file: redact(`${kind}-file`, String(i), item.file),
|
||||
patch: redact(`${kind}-patch`, String(i), item.patch),
|
||||
file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file),
|
||||
patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import { Server } from "../../server/server"
|
||||
import type { CommandModule } from "yargs"
|
||||
|
||||
type Args = {
|
||||
httpapi: boolean
|
||||
hono: boolean
|
||||
}
|
||||
type Args = {}
|
||||
|
||||
export const GenerateCommand = {
|
||||
command: "generate",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option("httpapi", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description:
|
||||
"Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)",
|
||||
})
|
||||
.option("hono", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const specs = args.hono ? await Server.openapiHono() : await Server.openapi()
|
||||
builder: (yargs) => yargs,
|
||||
handler: async () => {
|
||||
const specs = (await Server.openapi()) as { paths: Record<string, Record<string, any>> }
|
||||
for (const item of Object.values(specs.paths)) {
|
||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||
const operation = item[method]
|
||||
|
||||
@@ -119,15 +119,29 @@ export function DialogSessionList() {
|
||||
))
|
||||
}
|
||||
|
||||
function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
|
||||
return sessionsList
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => x.id)
|
||||
}
|
||||
|
||||
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return sessions()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.toSorted((a, b) => {
|
||||
const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0)
|
||||
if (updatedDay !== 0) return updatedDay
|
||||
return b.time.created - a.time.created
|
||||
})
|
||||
const sessionMap = new Map(
|
||||
sessions()
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.map((x) => [x.id, x]),
|
||||
)
|
||||
|
||||
const searchResult = searchResults()
|
||||
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
|
||||
|
||||
return displayOrder
|
||||
.map((id) => sessionMap.get(id))
|
||||
.filter((x) => x !== undefined)
|
||||
.map((x) => {
|
||||
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Aggregate Promise.allSettled results into a single Error that names every
|
||||
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
|
||||
* boundaries so a single 4xx doesn't drown its parallel siblings as
|
||||
* unhandled rejections — every failure surfaces in one labeled message.
|
||||
*/
|
||||
export type LabeledSettled = {
|
||||
name: string
|
||||
result: PromiseSettledResult<unknown>
|
||||
}
|
||||
|
||||
export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
|
||||
const failed = labeled.filter(
|
||||
(x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected",
|
||||
)
|
||||
if (failed.length === 0) return null
|
||||
|
||||
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ")
|
||||
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
|
||||
const err = new Error(summary)
|
||||
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
|
||||
return err
|
||||
}
|
||||
|
||||
function reasonMessage(reason: unknown): string {
|
||||
if (reason instanceof Error) return reason.message
|
||||
if (typeof reason === "string") return reason
|
||||
if (reason && typeof reason === "object") {
|
||||
const obj = reason as { message?: unknown; name?: unknown }
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.name === "string") return obj.name
|
||||
}
|
||||
return String(reason)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
||||
import path from "path"
|
||||
import { useKV } from "./kv"
|
||||
import { aggregateFailures } from "./aggregate-failures"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
@@ -391,16 +392,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
.catch(() => emptyConsoleState)
|
||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||
const blockingRequests: Promise<unknown>[] = [
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
projectPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
|
||||
{ name: "config.providers", promise: providersPromise },
|
||||
{ name: "provider.list", promise: providerListPromise },
|
||||
{ name: "app.agents", promise: agentsPromise },
|
||||
{ name: "config.get", promise: configPromise },
|
||||
{ name: "project.sync", promise: projectPromise },
|
||||
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
|
||||
]
|
||||
|
||||
await Promise.all(blockingRequests)
|
||||
await Promise.allSettled(blockingRequests.map((r) => r.promise))
|
||||
.then((settled) => {
|
||||
// Surface every failed endpoint in one labeled message instead of
|
||||
// letting the first rejection drown its siblings as unhandled
|
||||
// rejections.
|
||||
const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })))
|
||||
if (failure) throw failure
|
||||
})
|
||||
.then(async () => {
|
||||
const providersResponse = providersPromise.then((x) => x.data!)
|
||||
const providerListResponse = providerListPromise.then((x) => x.data!)
|
||||
@@ -526,10 +534,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
if (match.found) draft.session[match.index] = session.data!
|
||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
||||
draft.todo[sessionID] = todo.data ?? []
|
||||
draft.message[sessionID] = messages.data!.map((x) => x.info)
|
||||
for (const message of messages.data!) {
|
||||
const infos: (typeof draft.message)[string] = []
|
||||
for (const message of messages.data ?? []) {
|
||||
infos.push(message.info)
|
||||
draft.part[message.info.id] = message.parts
|
||||
}
|
||||
draft.message[sessionID] = infos
|
||||
draft.session_diff[sessionID] = diff.data ?? []
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { PositiveInt } from "@/util/schema"
|
||||
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const DEFAULT_TOAST_DURATION = 5000
|
||||
|
||||
@@ -8,7 +8,7 @@ function View(props: { api: TuiPluginApi }) {
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const theme = () => props.api.theme.current
|
||||
const list = createMemo(() => props.api.state.lsp())
|
||||
const off = createMemo(() => props.api.state.config.lsp === false)
|
||||
const off = createMemo(() => !props.api.state.config.lsp)
|
||||
|
||||
return (
|
||||
<box>
|
||||
@@ -22,9 +22,7 @@ function View(props: { api: TuiPluginApi }) {
|
||||
</box>
|
||||
<Show when={list().length <= 2 || open()}>
|
||||
<Show when={list().length === 0}>
|
||||
<text fg={theme().textMuted}>
|
||||
{off() ? "LSPs have been disabled in settings" : "LSPs will activate as files are read"}
|
||||
</text>
|
||||
<text fg={theme().textMuted}>{off() ? "LSPs are disabled" : "LSPs will activate as files are read"}</text>
|
||||
</Show>
|
||||
<For each={list()}>
|
||||
{(item) => (
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Slot as HostSlot } from "./slots"
|
||||
import type { useToast } from "../ui/toast"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import * as Keymap from "../keymap"
|
||||
import { createCommandShim } from "./command-shim"
|
||||
|
||||
type RouteEntry = {
|
||||
key: symbol
|
||||
@@ -147,7 +148,9 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
return sync.data.session.length
|
||||
},
|
||||
diff(sessionID) {
|
||||
return sync.data.session_diff[sessionID] ?? []
|
||||
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
|
||||
item.file === undefined ? [] : [{ ...item, file: item.file }],
|
||||
)
|
||||
},
|
||||
todo(sessionID) {
|
||||
return sync.data.todo[sessionID] ?? []
|
||||
@@ -200,6 +203,8 @@ export function createTuiApi(input: Input): TuiPluginApi {
|
||||
}
|
||||
return {
|
||||
app: appApi(),
|
||||
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
|
||||
command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
|
||||
keys: {
|
||||
formatSequence(parts) {
|
||||
return Keymap.formatKeySequence(parts, input.tuiConfig)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Legacy `api.command` bridge for v1 plugins; remove in v2.
|
||||
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { TuiKeybind } from "../config/keybind"
|
||||
import type { DialogContext } from "../ui/dialog"
|
||||
|
||||
const COMMAND_PALETTE_SHOW = "command.palette.show"
|
||||
const warned = new Set<string>()
|
||||
|
||||
type Warn = (api: string, replacement: string) => void
|
||||
type LegacyDialog = TuiPluginApi["ui"]["dialog"]
|
||||
type CommandShimDialog = DialogContext | LegacyDialog
|
||||
type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"]
|
||||
|
||||
function warnCommandShim(api: string, replacement: string) {
|
||||
// Warn v1 plugins about deprecated `api.command`; remove this shim path in v2.
|
||||
console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement })
|
||||
}
|
||||
|
||||
function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog {
|
||||
if (!("stack" in dialog)) return dialog
|
||||
return {
|
||||
replace(render, onClose) {
|
||||
dialog.replace(render, onClose)
|
||||
},
|
||||
clear() {
|
||||
dialog.clear()
|
||||
},
|
||||
setSize(size) {
|
||||
dialog.setSize(size)
|
||||
},
|
||||
get size() {
|
||||
return dialog.size
|
||||
},
|
||||
get depth() {
|
||||
return dialog.stack.length
|
||||
},
|
||||
get open() {
|
||||
return dialog.stack.length > 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function warnOnce(api: string, replacement: string, warn: Warn) {
|
||||
if (warned.has(api)) return
|
||||
warned.add(api)
|
||||
warn(api, replacement)
|
||||
}
|
||||
|
||||
function toCommand(item: TuiCommand, dialog: LegacyDialog) {
|
||||
return {
|
||||
namespace: "palette",
|
||||
name: item.value,
|
||||
title: item.title,
|
||||
desc: item.description,
|
||||
category: item.category,
|
||||
suggested: item.suggested,
|
||||
hidden: item.hidden,
|
||||
enabled: item.enabled,
|
||||
slashName: item.slash?.name,
|
||||
slashAliases: item.slash?.aliases,
|
||||
run() {
|
||||
return item.onSelect?.(dialog)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) {
|
||||
return commands.flatMap((item) =>
|
||||
item.keybind
|
||||
? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
|
||||
? keybinds
|
||||
.get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
|
||||
.map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title }))
|
||||
: [
|
||||
{
|
||||
key: item.keybind,
|
||||
cmd: item.value,
|
||||
desc: item.title,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
export function createCommandShim(
|
||||
keymap: TuiPluginApi["keymap"],
|
||||
dialog: CommandShimDialog,
|
||||
keybinds: LegacyKeybinds,
|
||||
): TuiPluginApi["command"] {
|
||||
const shimDialog = createCommandShimDialog(dialog)
|
||||
return {
|
||||
register(cb) {
|
||||
warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim)
|
||||
const commands = cb()
|
||||
return keymap.registerLayer({
|
||||
commands: commands.map((item) => toCommand(item, shimDialog)),
|
||||
bindings: toBindings(commands, keybinds),
|
||||
})
|
||||
},
|
||||
trigger(value) {
|
||||
warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim)
|
||||
keymap.dispatchCommand(value)
|
||||
},
|
||||
show() {
|
||||
warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim)
|
||||
keymap.dispatchCommand(COMMAND_PALETTE_SHOW)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
||||
import { setupSlots, Slot as View } from "./slots"
|
||||
import type { HostPluginApi, HostSlots } from "./slots"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { createCommandShim } from "./command-shim"
|
||||
|
||||
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
|
||||
|
||||
@@ -576,6 +577,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
|
||||
|
||||
return {
|
||||
app: api.app,
|
||||
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
|
||||
command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds),
|
||||
keys: api.keys,
|
||||
keymap,
|
||||
route,
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { InstanceContext } from "@/project/instance"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { Config } from "@/config/config"
|
||||
import { MCP } from "../mcp"
|
||||
import { Skill } from "../skill"
|
||||
|
||||
@@ -2,8 +2,8 @@ export * as ConfigAgent from "./agent"
|
||||
|
||||
import { Exit, Schema, SchemaGetter } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Schema } from "effect"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { Bus } from "@/bus"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { configEntryNameFromPath } from "./entry-name"
|
||||
import { InvalidError } from "./error"
|
||||
import * as ConfigMarkdown from "./markdown"
|
||||
|
||||
@@ -22,8 +22,8 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { containsPath } from "../project/instance-context"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { ConfigAgent } from "./agent"
|
||||
import { ConfigCommand } from "./command"
|
||||
import { ConfigFormatter } from "./formatter"
|
||||
@@ -121,12 +121,12 @@ const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate
|
||||
})
|
||||
|
||||
// The Effect Schema is the canonical source of truth. The `.zod` compatibility
|
||||
// surface is derived so existing Hono validators keep working without a parallel
|
||||
// Zod definition.
|
||||
// surface is derived from it so plugin/SDK Zod consumers keep working without
|
||||
// a parallel hand-maintained Zod definition.
|
||||
//
|
||||
// The walker emits `z.object({...})` which is non-strict by default. Config
|
||||
// historically uses `.strict()` (additionalProperties: false in openapi.json),
|
||||
// so layer that on after derivation. Re-apply the Config ref afterward
|
||||
// so layer that on after derivation. Re-apply the Config ref afterward
|
||||
// since `.strict()` strips the walker's meta annotation.
|
||||
export const Info = Schema.Struct({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
@@ -307,7 +307,7 @@ export const Info = Schema.Struct({
|
||||
})),
|
||||
)
|
||||
|
||||
// Uses the shared `DeepMutable` from `@/util/schema`. See the definition
|
||||
// Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition
|
||||
// there for why the local variant is needed over `Types.DeepMutable` from
|
||||
// effect-smol (the upstream version collapses `unknown` to `{}`).
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
|
||||
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigFormatter from "./formatter"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Entry = Schema.Struct({
|
||||
disabled: Schema.optional(Schema.Boolean),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Layout = Schema.Literals(["auto", "stretch"])
|
||||
.annotate({ identifier: "LayoutConfig" })
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigLSP from "./lsp"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import * as LSPServer from "../lsp/server"
|
||||
|
||||
export const Disabled = Schema.Struct({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Local = Schema.Struct({
|
||||
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
// The original Zod schema carried an external $ref pointing at the models.dev
|
||||
// JSON schema. That external reference is not a named SDK component — it is a
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as ConfigParse from "./parse"
|
||||
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
|
||||
import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
|
||||
import z from "zod"
|
||||
import type { DeepMutable } from "@/util/schema"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { InvalidError, JsonError } from "./error"
|
||||
|
||||
type ZodSchema<T> = z.ZodType<T>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ConfigPermission from "./permission"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Action = Schema.Literals(["ask", "allow", "deny"])
|
||||
.annotate({ identifier: "PermissionActionConfig" })
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { Schema } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import path from "path"
|
||||
|
||||
export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
import { ModelStatus } from "@/provider/model-status"
|
||||
|
||||
export const Model = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
@@ -49,7 +50,7 @@ export const Model = Schema.Struct({
|
||||
}),
|
||||
),
|
||||
experimental: Schema.optional(Schema.Boolean),
|
||||
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
|
||||
status: Schema.optional(ModelStatus),
|
||||
provider: Schema.optional(
|
||||
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigReference from "./reference"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const Git = Schema.Struct({
|
||||
repository: Schema.String.annotate({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Server = Schema.Struct({
|
||||
port: Schema.optional(PositiveInt).annotate({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
paths: Schema.optional(Schema.Array(Schema.String)).annotate({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe(
|
||||
Schema.brand("WorkspaceID"),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Schema, Struct } from "effect"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { WorkspaceID } from "./schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { type DeepMutable, withStatics } from "@/util/schema"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
|
||||
export const WorkspaceInfo = Schema.Struct({
|
||||
id: WorkspaceID,
|
||||
@@ -12,21 +11,19 @@ export const WorkspaceInfo = Schema.Struct({
|
||||
directory: Schema.NullOr(Schema.String),
|
||||
extra: Schema.NullOr(Schema.Unknown),
|
||||
projectID: ProjectID,
|
||||
})
|
||||
.annotate({ identifier: "Workspace" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
}).annotate({ identifier: "Workspace" })
|
||||
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
|
||||
|
||||
export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"]))
|
||||
.annotate({ identifier: "WorkspaceListedInfo" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"])).annotate({
|
||||
identifier: "WorkspaceListedInfo",
|
||||
})
|
||||
export type WorkspaceListedInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceListedInfo>>
|
||||
|
||||
export const WorkspaceAdapterEntry = Schema.Struct({
|
||||
type: Schema.String,
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
})
|
||||
export type WorkspaceAdapterEntry = Schema.Schema.Type<typeof WorkspaceAdapterEntry>
|
||||
|
||||
export type Target =
|
||||
|
||||
@@ -28,8 +28,6 @@ import { errorData } from "@/util/error"
|
||||
import { waitEvent } from "./util"
|
||||
import { WorkspaceContext } from "./workspace-context"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod as effectZod, zodObject } from "@/util/effect-zod"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
@@ -37,9 +35,7 @@ import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
export const Info = Schema.Struct({
|
||||
...WorkspaceInfoSchema.fields,
|
||||
timeUsed: Schema.Number,
|
||||
})
|
||||
.annotate({ identifier: "Workspace" })
|
||||
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
|
||||
}).annotate({ identifier: "Workspace" })
|
||||
export type Info = WorkspaceInfo & { timeUsed: number }
|
||||
|
||||
export const ConnectionStatus = Schema.Struct({
|
||||
@@ -88,14 +84,14 @@ export const CreateInput = Schema.Struct({
|
||||
branch: Info.fields.branch,
|
||||
projectID: ProjectID,
|
||||
extra: Schema.optional(Info.fields.extra),
|
||||
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
||||
})
|
||||
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
|
||||
|
||||
export const SessionWarpInput = Schema.Struct({
|
||||
workspaceID: Schema.NullOr(WorkspaceID),
|
||||
sessionID: SessionID,
|
||||
copyChanges: Schema.optional(Schema.Boolean),
|
||||
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
|
||||
})
|
||||
export type SessionWarpInput = Schema.Schema.Type<typeof SessionWarpInput>
|
||||
|
||||
export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
|
||||
|
||||
@@ -14,8 +14,8 @@ import { containsPath } from "../project/instance-context"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, type DeepMutable, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
path: Schema.String,
|
||||
|
||||
@@ -11,8 +11,8 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
|
||||
import { which } from "@/util/which"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "ripgrep" })
|
||||
const VERSION = "15.1.0"
|
||||
|
||||
@@ -7,8 +7,8 @@ import { mergeDeep } from "remeda"
|
||||
import { Config } from "@/config/config"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import * as Formatter from "./formatter"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import { spawn as lspspawn } from "./launch"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { containsPath } from "@/project/instance-context"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { zod as effectZod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod as effectZod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "mcp" })
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
@@ -7,9 +7,9 @@ import { MessageID, SessionID } from "@/session/schema"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { Database } from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
||||
import os from "os"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { Newtype } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Newtype } from "@opencode-ai/core/schema"
|
||||
|
||||
export class PermissionID extends Newtype<PermissionID>()(
|
||||
"PermissionID",
|
||||
Schema.String.annotate({ [ZodOverride]: Identifier.schema("permission") }),
|
||||
Schema.String.check(Schema.isStartsWith("per")).annotate({ [ZodOverride]: Identifier.schema("permission") }),
|
||||
) {
|
||||
static ascending(id?: string): PermissionID {
|
||||
return this.make(Identifier.ascending("permission", id))
|
||||
|
||||
@@ -18,8 +18,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
|
||||
const log = Log.create({ service: "project" })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID"))
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { zod, zodObject } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod, zodObject } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
const PATCH_CONTEXT_LINES = 2_147_483_647
|
||||
@@ -230,7 +230,10 @@ export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export const FileDiff = Schema.Struct({
|
||||
file: Schema.String,
|
||||
patch: Schema.String,
|
||||
// Mirrors Snapshot.FileDiff (see #26574). The current producer always
|
||||
// populates patch, but loosening matches the sibling schema so a
|
||||
// future code path that omits it can't crash /instance/vcs/diff.
|
||||
patch: Schema.optional(Schema.String),
|
||||
additions: NonNegativeInt,
|
||||
deletions: NonNegativeInt,
|
||||
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import { optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||
import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
|
||||
import { Plugin } from "../plugin"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
||||
|
||||
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
|
||||
export type ModelStatus = typeof ModelStatus.Type
|
||||
|
||||
export * as ProviderModelStatus from "./model-status"
|
||||
@@ -8,6 +8,7 @@ import { Flock } from "@opencode-ai/core/util/flock"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { CatalogModelStatus } from "./model-status"
|
||||
|
||||
const Cost = Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
@@ -71,7 +72,7 @@ export const Model = Schema.Struct({
|
||||
),
|
||||
}),
|
||||
),
|
||||
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
|
||||
status: Schema.optional(CatalogModelStatus),
|
||||
provider: Schema.optional(
|
||||
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
|
||||
),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Auth } from "../auth"
|
||||
import { Env } from "../env"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -24,10 +24,11 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||
import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
import * as ProviderTransform from "./transform"
|
||||
import { ModelID, ProviderID } from "./schema"
|
||||
import { ModelStatus } from "./model-status"
|
||||
|
||||
const log = Log.create({ service: "provider" })
|
||||
|
||||
@@ -897,7 +898,7 @@ export const Model = Schema.Struct({
|
||||
capabilities: ProviderCapabilities,
|
||||
cost: ProviderCost,
|
||||
limit: ProviderLimit,
|
||||
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
|
||||
status: ModelStatus,
|
||||
options: Schema.Record(Schema.String, Schema.Any),
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
release_date: Schema.String,
|
||||
@@ -935,6 +936,16 @@ export const ConfigProvidersResult = Schema.Struct({
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type ConfigProvidersResult = Types.DeepMutable<Schema.Schema.Type<typeof ConfigProvidersResult>>
|
||||
|
||||
export function toPublicInfo(provider: Info): Info {
|
||||
return JSON.parse(
|
||||
JSON.stringify(provider, (_, value) => {
|
||||
if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined
|
||||
if (typeof value === "bigint") return value.toString()
|
||||
return value
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function defaultModelIDs<T extends { models: Record<string, { id: string }> }>(providers: Record<string, T>) {
|
||||
return mapValues(providers, (item) => sort(Object.values(item.models))[0].id)
|
||||
}
|
||||
@@ -1152,7 +1163,7 @@ const layer: Layer.Layer<
|
||||
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
|
||||
|
||||
provider.models = yield* Effect.promise(async () => {
|
||||
const next = await models(provider, { auth: pluginAuth })
|
||||
const next = await models(toPublicInfo(provider), { auth: pluginAuth })
|
||||
return Object.fromEntries(
|
||||
Object.entries(next).map(([id, model]) => [
|
||||
id,
|
||||
@@ -1299,7 +1310,7 @@ const layer: Layer.Layer<
|
||||
const options = yield* Effect.promise(() =>
|
||||
plugin.auth!.loader!(
|
||||
() => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any,
|
||||
database[plugin.auth!.provider],
|
||||
toPublicInfo(database[plugin.auth!.provider]),
|
||||
),
|
||||
)
|
||||
const opts = options ?? {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID"))
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import type { Proc } from "#pty"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { PtyID } from "./schema"
|
||||
import { Effect, Layer, Context, Schema, Types } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { NonNegativeInt, PositiveInt, withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "pty" })
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
|
||||
const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID"))
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as PtyTicket from "./ticket"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PositiveInt } from "@/util/schema"
|
||||
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
|
||||
const DEFAULT_TTL = Duration.seconds(60)
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { QuestionID } from "./schema"
|
||||
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
import { Identifier } from "@/id/id"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { Newtype } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Newtype } from "@opencode-ai/core/schema"
|
||||
|
||||
export class QuestionID extends Newtype<QuestionID>()(
|
||||
"QuestionID",
|
||||
Schema.String.annotate({ [ZodOverride]: Identifier.schema("question") }),
|
||||
Schema.String.check(Schema.isStartsWith("que")).annotate({ [ZodOverride]: Identifier.schema("question") }),
|
||||
) {
|
||||
static ascending(id?: string): QuestionID {
|
||||
return this.make(Identifier.ascending("question", id))
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { Hono } from "hono"
|
||||
import { createBunWebSocket } from "hono/bun"
|
||||
import type { Adapter, FetchApp, Opts } from "./adapter"
|
||||
|
||||
function listen(app: FetchApp, opts: Opts, websocket?: ReturnType<typeof createBunWebSocket>["websocket"]) {
|
||||
const start = (port: number) => {
|
||||
try {
|
||||
if (websocket) {
|
||||
return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, websocket, port })
|
||||
}
|
||||
return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, port })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
|
||||
if (!server) {
|
||||
throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
}
|
||||
if (!server.port) {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
return {
|
||||
port: server.port,
|
||||
stop(close?: boolean) {
|
||||
return Promise.resolve(server.stop(close))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const adapter: Adapter = {
|
||||
create(app: Hono) {
|
||||
const ws = createBunWebSocket()
|
||||
return {
|
||||
upgradeWebSocket: ws.upgradeWebSocket,
|
||||
listen: (opts) => Promise.resolve(listen(app, opts, ws.websocket)),
|
||||
}
|
||||
},
|
||||
createFetch(app) {
|
||||
return {
|
||||
listen: (opts) => Promise.resolve(listen(app, opts)),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { EventEmitter } from "node:events"
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import type { Hono } from "hono"
|
||||
import type { Adapter, FetchApp, Opts } from "./adapter"
|
||||
|
||||
async function listen(app: FetchApp, opts: Opts, inject?: (server: ServerType) => void) {
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: app.fetch })
|
||||
const events = server as EventEmitter
|
||||
inject?.(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const ready = () => {
|
||||
cleanup()
|
||||
resolve(server)
|
||||
}
|
||||
const cleanup = () => {
|
||||
events.off("error", fail)
|
||||
events.off("listening", ready)
|
||||
}
|
||||
events.once("error", fail)
|
||||
events.once("listening", ready)
|
||||
server.listen(port, opts.hostname)
|
||||
})
|
||||
|
||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === "string") {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
port: addr.port,
|
||||
stop(close?: boolean) {
|
||||
closing ??= new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
if (close) {
|
||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||
server.closeAllConnections()
|
||||
}
|
||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||
server.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
})
|
||||
return closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const adapter: Adapter = {
|
||||
create(app: Hono) {
|
||||
const ws = createNodeWebSocket({ app })
|
||||
return {
|
||||
upgradeWebSocket: ws.upgradeWebSocket,
|
||||
listen: (opts) => listen(app, opts, ws.injectWebSocket),
|
||||
}
|
||||
},
|
||||
createFetch(app) {
|
||||
return {
|
||||
listen: (opts) => listen(app, opts),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
|
||||
export type FetchApp = {
|
||||
fetch(request: Request): Response | Promise<Response>
|
||||
}
|
||||
|
||||
export type Opts = {
|
||||
port: number
|
||||
hostname: string
|
||||
}
|
||||
|
||||
export type Listener = {
|
||||
port: number
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export interface Runtime {
|
||||
upgradeWebSocket: UpgradeWebSocket
|
||||
listen(opts: Opts): Promise<Listener>
|
||||
}
|
||||
|
||||
export interface Adapter {
|
||||
create(app: Hono): Runtime
|
||||
createFetch(app: FetchApp): Omit<Runtime, "upgradeWebSocket">
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
||||
export type Backend = "effect-httpapi" | "hono"
|
||||
|
||||
export type Selection = {
|
||||
backend: Backend
|
||||
reason: "env" | "stable" | "explicit"
|
||||
}
|
||||
|
||||
export type Attributes = ReturnType<typeof attributes>
|
||||
|
||||
export function select(): Selection {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" }
|
||||
return { backend: "hono", reason: "stable" }
|
||||
}
|
||||
|
||||
export function attributes(selection: Selection): Record<string, string> {
|
||||
return {
|
||||
"opencode.server.backend": selection.backend,
|
||||
"opencode.server.backend.reason": selection.reason,
|
||||
"opencode.installation.channel": InstallationChannel,
|
||||
"opencode.installation.version": InstallationVersion,
|
||||
}
|
||||
}
|
||||
|
||||
export function force(selection: Selection, backend: Backend): Selection {
|
||||
return {
|
||||
backend,
|
||||
reason: selection.backend === backend ? selection.reason : "explicit",
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
|
||||
export const ERRORS = {
|
||||
400: {
|
||||
description: "Bad request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
data: z.any(),
|
||||
errors: z.array(z.record(z.string(), z.any())),
|
||||
success: z.literal(false),
|
||||
})
|
||||
.meta({
|
||||
ref: "BadRequestError",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(NotFoundError.Schema),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export function errors(...codes: number[]) {
|
||||
return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]]))
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { HEADER, diff, load } from "./shared/fence"
|
||||
|
||||
const log = Log.create({ service: "fence-middleware" })
|
||||
|
||||
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()
|
||||
|
||||
const prev = load()
|
||||
await next()
|
||||
const current = diff(prev, load())
|
||||
|
||||
if (Object.keys(current).length > 0) {
|
||||
log.info("header", {
|
||||
diff: current,
|
||||
})
|
||||
c.res.headers.set(HEADER, JSON.stringify(current))
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { createServer } from "node:http"
|
||||
import type { Opts } from "./adapter"
|
||||
import { Service } from "./httpapi-server"
|
||||
|
||||
export { Service }
|
||||
|
||||
export const name = "node-http-server"
|
||||
|
||||
export type Opts = { port: number; hostname: string }
|
||||
|
||||
export const layer = (opts: Opts) => {
|
||||
const server = createServer()
|
||||
const serverRef = { closeStarted: false, forceStop: false }
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { Session } from "@/session/session"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import type { ErrorHandler, MiddlewareHandler } from "hono"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { basicAuth } from "hono/basic-auth"
|
||||
import { cors } from "hono/cors"
|
||||
import { compress } from "hono/compress"
|
||||
import * as ServerBackend from "./backend"
|
||||
import { isAllowedCorsOrigin, type CorsOptions } from "./cors"
|
||||
import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket"
|
||||
import { isPublicUIPath } from "./shared/public-ui"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const ErrorMiddleware: ErrorHandler = (err, c) => {
|
||||
log.error("failed", {
|
||||
error: err,
|
||||
})
|
||||
if (err instanceof NamedError) {
|
||||
let status: ContentfulStatusCode
|
||||
if (err instanceof NotFoundError) status = 404
|
||||
else if (err instanceof Provider.ModelNotFoundError) status = 400
|
||||
else if (err.name === "ProviderAuthValidationFailed") status = 400
|
||||
else if (err.name.startsWith("Worktree")) status = 400
|
||||
else status = 500
|
||||
return c.json(err.toObject(), { status })
|
||||
}
|
||||
if (err instanceof Session.BusyError) {
|
||||
return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 })
|
||||
}
|
||||
if (err instanceof HTTPException) return err.getResponse()
|
||||
const message = err instanceof Error && err.stack ? err.stack : err.toString()
|
||||
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
|
||||
export const AuthMiddleware: MiddlewareHandler = (c, next) => {
|
||||
// Allow CORS preflight requests to succeed without auth.
|
||||
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
||||
if (c.req.method === "OPTIONS") return next()
|
||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return next()
|
||||
if (isPublicUIPath(c.req.method, c.req.path)) return next()
|
||||
if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next()
|
||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
|
||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||
|
||||
return basicAuth({ username, password })(c, next)
|
||||
}
|
||||
|
||||
export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler {
|
||||
return async (c, next) => {
|
||||
const skip = c.req.path === "/log"
|
||||
if (skip) return next()
|
||||
const attributes = {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
// If this logger grows full-URL fields, redact auth_token and ticket query params.
|
||||
...backendAttributes,
|
||||
}
|
||||
log.info("request", attributes)
|
||||
const timer = log.time("request", attributes)
|
||||
await next()
|
||||
timer.stop()
|
||||
}
|
||||
}
|
||||
|
||||
export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler {
|
||||
return cors({
|
||||
maxAge: 86_400,
|
||||
origin(input) {
|
||||
if (isAllowedCorsOrigin(input, opts)) return input
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const zipped = compress()
|
||||
export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
|
||||
const path = c.req.path
|
||||
const method = c.req.method
|
||||
if (path === "/event" || path === "/global/event") return next()
|
||||
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
|
||||
return zipped(c, next)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import * as Fence from "./shared/fence"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { ProxyUtil } from "./proxy-util"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
|
||||
type Msg = string | ArrayBuffer | Uint8Array
|
||||
|
||||
function send(ws: { send(data: string | ArrayBuffer | Uint8Array): void }, data: any) {
|
||||
if (data instanceof Blob) {
|
||||
return data.arrayBuffer().then((x) => ws.send(x))
|
||||
}
|
||||
return ws.send(data)
|
||||
}
|
||||
|
||||
const app = (upgrade: UpgradeWebSocket) =>
|
||||
new Hono().get(
|
||||
"/__workspace_ws",
|
||||
upgrade((c) => {
|
||||
const url = c.req.header("x-opencode-proxy-url")
|
||||
const queue: Msg[] = []
|
||||
let remote: WebSocket | undefined
|
||||
return {
|
||||
onOpen(_, ws) {
|
||||
if (!url) {
|
||||
ws.close(1011, "missing proxy target")
|
||||
return
|
||||
}
|
||||
remote = new WebSocket(url, ProxyUtil.websocketProtocols(c.req.raw))
|
||||
remote.binaryType = "arraybuffer"
|
||||
remote.onopen = () => {
|
||||
for (const item of queue) remote?.send(item)
|
||||
queue.length = 0
|
||||
}
|
||||
remote.onmessage = (event) => {
|
||||
void send(ws, event.data)
|
||||
}
|
||||
remote.onerror = () => {
|
||||
ws.close(1011, "proxy error")
|
||||
}
|
||||
remote.onclose = (event) => {
|
||||
ws.close(event.code, event.reason)
|
||||
}
|
||||
},
|
||||
onMessage(event) {
|
||||
const data = event.data
|
||||
if (typeof data !== "string" && !(data instanceof Uint8Array) && !(data instanceof ArrayBuffer)) return
|
||||
if (remote?.readyState === WebSocket.OPEN) {
|
||||
remote.send(data)
|
||||
return
|
||||
}
|
||||
queue.push(data)
|
||||
},
|
||||
onClose(event) {
|
||||
remote?.close(event.code, event.reason)
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const log = Log.create({ service: "server-proxy" })
|
||||
|
||||
function statusText(response: unknown) {
|
||||
return (response as { source?: Response }).source?.statusText
|
||||
}
|
||||
|
||||
export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
|
||||
return Effect.gen(function* () {
|
||||
const syncing = yield* Workspace.Service.use((workspace) => workspace.isSyncing(workspaceID))
|
||||
if (!syncing) {
|
||||
return new Response(`broken sync connection for workspace: ${workspaceID}`, {
|
||||
status: 503,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const response = yield* HttpClient.execute(
|
||||
HttpClientRequest.make(req.method as never)(url, {
|
||||
headers: ProxyUtil.headers(req, extra),
|
||||
body:
|
||||
req.method === "GET" || req.method === "HEAD"
|
||||
? HttpBody.empty
|
||||
: HttpBody.raw(req.body, {
|
||||
contentType: req.headers.get("content-type") ?? undefined,
|
||||
contentLength: req.headers.get("content-length")
|
||||
? Number(req.headers.get("content-length"))
|
||||
: undefined,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const next = new Headers(response.headers as HeadersInit)
|
||||
const sync = Fence.parse(next)
|
||||
next.delete("content-encoding")
|
||||
next.delete("content-length")
|
||||
|
||||
if (sync) yield* Fence.waitEffect(workspaceID, sync, req.signal)
|
||||
const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty)))
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: statusText(response),
|
||||
headers: next,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
Effect.catch(() => Effect.succeed(new Response(null, { status: 500 }))),
|
||||
)
|
||||
}
|
||||
|
||||
export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
|
||||
return AppRuntime.runPromise(httpEffect(url, extra, req, workspaceID))
|
||||
}
|
||||
|
||||
export function websocket(
|
||||
upgrade: UpgradeWebSocket,
|
||||
target: string | URL,
|
||||
extra: HeadersInit | undefined,
|
||||
req: Request,
|
||||
env: unknown,
|
||||
) {
|
||||
const proxy = new URL(req.url)
|
||||
proxy.pathname = "/__workspace_ws"
|
||||
proxy.search = ""
|
||||
const next = new Headers(req.headers)
|
||||
next.set("x-opencode-proxy-url", ProxyUtil.websocketTargetURL(target))
|
||||
for (const [key, value] of new Headers(extra).entries()) {
|
||||
next.set(key, value)
|
||||
}
|
||||
log.info("proxy websocket", {
|
||||
request: req.url,
|
||||
target: String(target),
|
||||
})
|
||||
return app(upgrade).fetch(
|
||||
new Request(proxy, {
|
||||
method: req.method,
|
||||
headers: next,
|
||||
signal: req.signal,
|
||||
}),
|
||||
env as never,
|
||||
)
|
||||
}
|
||||
|
||||
export * as ServerProxy from "./proxy"
|
||||
@@ -1,160 +0,0 @@
|
||||
import { Auth } from "@/auth"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { errors } from "../../error"
|
||||
|
||||
export function ControlPlaneRoutes(): Hono {
|
||||
const app = new Hono()
|
||||
return app
|
||||
.put(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Set auth credentials",
|
||||
description: "Set authentication credentials",
|
||||
operationId: "auth.set",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully set authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Auth.Info.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set(providerID, info)
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/auth/:providerID",
|
||||
describeRoute({
|
||||
summary: "Remove auth credentials",
|
||||
description: "Remove authentication credentials",
|
||||
operationId: "auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully removed authentication credentials",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
providerID: ProviderID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove(providerID)
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/doc",
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "opencode",
|
||||
version: "0.0.3",
|
||||
description: "opencode api",
|
||||
},
|
||||
openapi: "3.1.1",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.post(
|
||||
"/log",
|
||||
describeRoute({
|
||||
summary: "Write log",
|
||||
description: "Write a log entry to the server logs with specified level and metadata.",
|
||||
operationId: "app.log",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Log entry written successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
service: z.string().meta({ description: "Service name for the log entry" }),
|
||||
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
||||
message: z.string().meta({ description: "Log message" }),
|
||||
extra: z
|
||||
.record(z.string(), z.any())
|
||||
.optional()
|
||||
.meta({ description: "Additional metadata for the log entry" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { service, level, message, extra } = c.req.valid("json")
|
||||
const logger = Log.create({ service })
|
||||
|
||||
switch (level) {
|
||||
case "debug":
|
||||
logger.debug(message, extra)
|
||||
break
|
||||
case "info":
|
||||
logger.info(message, extra)
|
||||
break
|
||||
case "error":
|
||||
logger.error(message, extra)
|
||||
break
|
||||
case "warn":
|
||||
logger.warn(message, extra)
|
||||
break
|
||||
}
|
||||
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { listAdapters } from "@/control-plane/adapters"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { WorkspaceAdapterEntry } from "@/control-plane/types"
|
||||
import { zodObject } from "@/util/effect-zod"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
|
||||
export const WorkspaceRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/adapter",
|
||||
describeRoute({
|
||||
summary: "List workspace adapters",
|
||||
description: "List all available workspace adapters for the current project.",
|
||||
operationId: "experimental.workspace.adapter.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace adapters",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await listAdapters(Instance.project.id))
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Create workspace",
|
||||
description: "Create a workspace for the current project.",
|
||||
operationId: "experimental.workspace.create",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Workspace.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
Workspace.CreateInput.zodObject.omit({
|
||||
projectID: true,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json") as Omit<Workspace.CreateInput, "projectID">
|
||||
const workspace = await AppRuntime.runPromise(
|
||||
Workspace.Service.use((svc) =>
|
||||
svc.create({
|
||||
projectID: Instance.project.id,
|
||||
...body,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(workspace)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "List workspaces",
|
||||
description: "List all workspaces.",
|
||||
operationId: "experimental.workspace.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspaces",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(Workspace.Info.zod)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project))))
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/sync-list",
|
||||
describeRoute({
|
||||
summary: "Sync workspace list",
|
||||
description: "Register missing workspaces returned by workspace adapters.",
|
||||
operationId: "experimental.workspace.syncList",
|
||||
responses: {
|
||||
204: {
|
||||
description: "Workspace list synced",
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.syncList(Instance.project)))
|
||||
return c.body(null, 204)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/status",
|
||||
describeRoute({
|
||||
summary: "Workspace status",
|
||||
description: "Get connection status for workspaces in the current project.",
|
||||
operationId: "experimental.workspace.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(zodObject(Workspace.ConnectionStatus))),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const result = await AppRuntime.runPromise(
|
||||
Workspace.Service.use((svc) => Effect.all([svc.list(Instance.project), svc.status()])),
|
||||
)
|
||||
const ids = new Set(result[0].map((item) => item.id))
|
||||
return c.json(result[1].filter((item) => ids.has(item.workspaceID)))
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/:id",
|
||||
describeRoute({
|
||||
summary: "Remove workspace",
|
||||
description: "Remove an existing workspace.",
|
||||
operationId: "experimental.workspace.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Workspace removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Workspace.Info.zod.optional()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: zodObject(Workspace.Info).shape.id,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { id } = c.req.valid("param")
|
||||
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.remove(id))))
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/warp",
|
||||
describeRoute({
|
||||
summary: "Warp session into workspace",
|
||||
description: "Move a session's sync history into the target workspace, or detach it to the local project.",
|
||||
operationId: "experimental.workspace.warp",
|
||||
responses: {
|
||||
204: {
|
||||
description: "Session warped",
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
id: zodObject(Workspace.Info).shape.id.nullable(),
|
||||
sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID,
|
||||
copyChanges: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
return AppRuntime.runPromise(
|
||||
Workspace.Service.use((workspace) =>
|
||||
workspace.sessionWarp({
|
||||
workspaceID: body.id,
|
||||
sessionID: body.sessionID,
|
||||
copyChanges: body.copyChanges,
|
||||
}),
|
||||
).pipe(
|
||||
Effect.match({
|
||||
onFailure: (error) => {
|
||||
if (error instanceof Vcs.PatchApplyError) {
|
||||
return c.json(
|
||||
{
|
||||
name: "VcsApplyError",
|
||||
data: {
|
||||
message: error.message,
|
||||
reason: error.reason,
|
||||
},
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
name: "WorkspaceWarpError",
|
||||
data: {
|
||||
message: error.message,
|
||||
},
|
||||
},
|
||||
400,
|
||||
)
|
||||
},
|
||||
onSuccess: () => c.body(null, 204),
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,286 +0,0 @@
|
||||
import { Hono, type Context } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Bus } from "@/bus"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
import { Installation } from "@/installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Config } from "@/config/config"
|
||||
import { errors } from "../error"
|
||||
import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>()
|
||||
let done = false
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
id: Bus.createID(),
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
id: Bus.createID(),
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 10_000)
|
||||
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
q.push(null)
|
||||
log.info("global event disconnected")
|
||||
}
|
||||
|
||||
const unsub = subscribe(q)
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
try {
|
||||
for await (const data of q) {
|
||||
if (data === null) return
|
||||
await stream.writeSSE({ data })
|
||||
}
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const GlobalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/health",
|
||||
describeRoute({
|
||||
summary: "Get health",
|
||||
description: "Get health information about the OpenCode server.",
|
||||
operationId: "global.health",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Health information",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({ healthy: true, version: InstallationVersion })
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/event",
|
||||
describeRoute({
|
||||
summary: "Get global events",
|
||||
description: "Subscribe to global events from the OpenCode system using server-sent events.",
|
||||
operationId: "global.event",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
directory: z.string(),
|
||||
project: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
payload: z.union([...BusEvent.payloads(), ...SyncEvent.payloads()]),
|
||||
})
|
||||
.meta({
|
||||
ref: "GlobalEvent",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("global event connected")
|
||||
c.header("Cache-Control", "no-cache, no-transform")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
return streamEvents(c, (q) => {
|
||||
async function handler(event: any) {
|
||||
q.push(JSON.stringify(event))
|
||||
}
|
||||
GlobalBus.on("event", handler)
|
||||
return () => GlobalBus.off("event", handler)
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/config",
|
||||
describeRoute({
|
||||
summary: "Get global configuration",
|
||||
description: "Retrieve the current global OpenCode configuration settings and preferences.",
|
||||
operationId: "global.config.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get global config info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())))
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/config",
|
||||
describeRoute({
|
||||
summary: "Update global configuration",
|
||||
description: "Update global OpenCode configuration settings and preferences.",
|
||||
operationId: "global.config.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated global config",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Config.Info.zod),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
const result = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
|
||||
if (result.changed) {
|
||||
void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })).catch(
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
return c.json(result.info)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/dispose",
|
||||
describeRoute({
|
||||
summary: "Dispose instance",
|
||||
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
|
||||
operationId: "global.dispose",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Global disposed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed())
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/upgrade",
|
||||
describeRoute({
|
||||
summary: "Upgrade opencode",
|
||||
description: "Upgrade opencode to the specified version or latest if not specified.",
|
||||
operationId: "global.upgrade",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Upgrade result",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.union([
|
||||
z.object({
|
||||
success: z.literal(true),
|
||||
version: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
success: z.literal(false),
|
||||
error: z.string(),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
target: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const result = await AppRuntime.runPromise(
|
||||
Installation.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const method = yield* svc.method()
|
||||
if (method === "unknown") {
|
||||
return { success: false as const, status: 400 as const, error: "Unknown installation method" }
|
||||
}
|
||||
|
||||
const target = c.req.valid("json").target || (yield* svc.latest(method))
|
||||
const result = yield* Effect.catch(
|
||||
svc.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })),
|
||||
(err) =>
|
||||
Effect.succeed({
|
||||
success: false as const,
|
||||
status: 500 as const,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
)
|
||||
if (!result.success) return result
|
||||
return { ...result, status: 200 as const }
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!result.success) {
|
||||
return c.json({ success: false, error: result.error }, result.status)
|
||||
}
|
||||
const target = result.version
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Installation.Event.Updated.type,
|
||||
properties: { version: target },
|
||||
},
|
||||
})
|
||||
return c.json({ success: true, version: target })
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1,8 +0,0 @@
|
||||
# Instance Route Parity
|
||||
|
||||
This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned.
|
||||
|
||||
- When adding, removing, or changing a legacy Hono route, update the matching Effect HttpApi group and handler in `httpapi/` in the same change unless the route is intentionally unsupported.
|
||||
- When changing an Effect HttpApi route, verify the legacy Hono route has the same public behavior, request shape, response shape, status codes, and instance/workspace routing semantics.
|
||||
- Keep OpenAPI/SDK-visible schemas aligned. If a difference is only an OpenAPI generation artifact, prefer fixing the source schema first; use `httpapi/public.ts` normalization only for compatibility shims that cannot be represented cleanly in the source schema.
|
||||
- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress.
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
import { Effect } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "server.config" })
|
||||
|
||||
export const ConfigRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Get configuration",
|
||||
description: "Retrieve the current OpenCode configuration settings and preferences.",
|
||||
operationId: "config.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Get config info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ConfigRoutes.get", c, function* () {
|
||||
const cfg = yield* Config.Service
|
||||
return yield* cfg.get()
|
||||
}),
|
||||
)
|
||||
.patch(
|
||||
"/",
|
||||
describeRoute({
|
||||
summary: "Update configuration",
|
||||
description: "Update OpenCode configuration settings and preferences.",
|
||||
operationId: "config.update",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successfully updated config",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Config.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Config.Info.zod),
|
||||
async (c) => {
|
||||
const result = await runRequest(
|
||||
"ConfigRoutes.update",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const config = c.req.valid("json")
|
||||
const cfg = yield* Config.Service
|
||||
yield* cfg.update(config)
|
||||
return { config, ctx: yield* InstanceState.context }
|
||||
}),
|
||||
)
|
||||
const response = c.json(result.config)
|
||||
void runRequest(
|
||||
"ConfigRoutes.update.dispose",
|
||||
c,
|
||||
InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe(
|
||||
Effect.uninterruptible,
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))),
|
||||
),
|
||||
)
|
||||
return response
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/providers",
|
||||
describeRoute({
|
||||
summary: "List config providers",
|
||||
description: "Get a list of all configured AI providers and their default models.",
|
||||
operationId: "config.providers",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of providers",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Provider.ConfigProvidersResult.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ConfigRoutes.providers", c, function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const providers = yield* svc.list()
|
||||
return {
|
||||
providers: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,90 +0,0 @@
|
||||
import z from "zod"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const EventRoutes = () =>
|
||||
new Hono().get(
|
||||
"/event",
|
||||
describeRoute({
|
||||
summary: "Subscribe to events",
|
||||
description: "Get events",
|
||||
operationId: "event.subscribe",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(
|
||||
z.union(BusEvent.payloads()).meta({
|
||||
ref: "Event",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("event connected")
|
||||
c.header("Cache-Control", "no-cache, no-transform")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>()
|
||||
let done = false
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
id: Bus.createID(),
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
}),
|
||||
)
|
||||
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
id: Bus.createID(),
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
}),
|
||||
)
|
||||
}, 10_000)
|
||||
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
q.push(null)
|
||||
log.info("event disconnected")
|
||||
}
|
||||
|
||||
const unsub = Bus.subscribeAll((event) => {
|
||||
q.push(JSON.stringify(event))
|
||||
if (event.type === Bus.InstanceDisposed.type) {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
try {
|
||||
for await (const data of q) {
|
||||
if (data === null) return
|
||||
await stream.writeSSE({ data })
|
||||
}
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -1,419 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import * as EffectZod from "@/util/effect-zod"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Project } from "@/project/project"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Session } from "@/session/session"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConsoleState } from "@/config/console-state"
|
||||
import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID } from "@/account/schema"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
const ConsoleOrgOption = z.object({
|
||||
accountID: z.string(),
|
||||
accountEmail: z.string(),
|
||||
accountUrl: z.string(),
|
||||
orgID: z.string(),
|
||||
orgName: z.string(),
|
||||
active: z.boolean(),
|
||||
})
|
||||
|
||||
const ConsoleOrgList = z.object({
|
||||
orgs: z.array(ConsoleOrgOption),
|
||||
})
|
||||
|
||||
const ConsoleSwitchBody = z.object({
|
||||
accountID: z.string(),
|
||||
orgID: z.string(),
|
||||
})
|
||||
|
||||
const QueryBoolean = z.union([
|
||||
z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()),
|
||||
z.enum(["true", "false"]),
|
||||
])
|
||||
|
||||
function queryBoolean(value: z.infer<typeof QueryBoolean> | undefined) {
|
||||
if (value === undefined) return
|
||||
return value === true || value === "true"
|
||||
}
|
||||
|
||||
export const ExperimentalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/console",
|
||||
describeRoute({
|
||||
summary: "Get active Console provider metadata",
|
||||
description: "Get the active Console org name and the set of provider IDs managed by that Console org.",
|
||||
operationId: "experimental.console.get",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Active Console provider metadata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ConsoleState.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.console.get", c, function* () {
|
||||
const config = yield* Config.Service
|
||||
const account = yield* Account.Service
|
||||
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
return {
|
||||
...state,
|
||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/console/orgs",
|
||||
describeRoute({
|
||||
summary: "List switchable Console orgs",
|
||||
description: "Get the available Console orgs across logged-in accounts, including the current active org.",
|
||||
operationId: "experimental.console.listOrgs",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Switchable Console orgs",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(ConsoleOrgList),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () {
|
||||
const account = yield* Account.Service
|
||||
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const info = Option.getOrUndefined(active)
|
||||
const orgs = groups.flatMap((group) =>
|
||||
group.orgs.map((org) => ({
|
||||
accountID: group.account.id,
|
||||
accountEmail: group.account.email,
|
||||
accountUrl: group.account.url,
|
||||
orgID: org.id,
|
||||
orgName: org.name,
|
||||
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
|
||||
})),
|
||||
)
|
||||
return { orgs }
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/console/switch",
|
||||
describeRoute({
|
||||
summary: "Switch active Console org",
|
||||
description: "Persist a new active Console account/org selection for the current local OpenCode state.",
|
||||
operationId: "experimental.console.switchOrg",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Switch success",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", ConsoleSwitchBody),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const account = yield* Account.Service
|
||||
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/tool/ids",
|
||||
describeRoute({
|
||||
summary: "List tool IDs",
|
||||
description:
|
||||
"Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.",
|
||||
operationId: "tool.ids",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tool IDs",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.tool.ids", c, function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
return yield* registry.ids()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/tool",
|
||||
describeRoute({
|
||||
summary: "List tools",
|
||||
description:
|
||||
"Get a list of available tools with their JSON schema parameters for a specific provider and model combination.",
|
||||
operationId: "tool.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tools",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
description: z.string(),
|
||||
parameters: z.any(),
|
||||
})
|
||||
.meta({ ref: "ToolListItem" }),
|
||||
)
|
||||
.meta({ ref: "ToolList" }),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { provider, model } = c.req.valid("query")
|
||||
const tools = await runRequest(
|
||||
"ExperimentalRoutes.tool.list",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
return yield* registry.tools({
|
||||
providerID: ProviderID.make(provider),
|
||||
modelID: ModelID.make(model),
|
||||
agent: yield* agents.get(yield* agents.defaultAgent()),
|
||||
})
|
||||
}),
|
||||
)
|
||||
return c.json(
|
||||
tools.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
parameters: EffectZod.toJsonSchema(t.parameters),
|
||||
})),
|
||||
)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
summary: "Create worktree",
|
||||
description: "Create a new git worktree for the current project and run any configured startup scripts.",
|
||||
operationId: "worktree.create",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Worktree created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Worktree.Info.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.CreateInput.zod.optional()),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Worktree.Service
|
||||
return yield* svc.create(body)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
summary: "List worktrees",
|
||||
description: "List all sandbox worktrees for the current project.",
|
||||
operationId: "worktree.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of worktree directories",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.array(z.string())),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.list", c, function* () {
|
||||
const svc = yield* Project.Service
|
||||
return yield* svc.sandboxes(Instance.project.id)
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/worktree",
|
||||
describeRoute({
|
||||
summary: "Remove worktree",
|
||||
description: "Remove a git worktree and delete its branch.",
|
||||
operationId: "worktree.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Worktree removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.RemoveInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const worktree = yield* Worktree.Service
|
||||
const project = yield* Project.Service
|
||||
yield* worktree.remove(body)
|
||||
yield* project.removeSandbox(Instance.project.id, body.directory)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/worktree/reset",
|
||||
describeRoute({
|
||||
summary: "Reset worktree",
|
||||
description: "Reset a worktree branch to the primary default branch.",
|
||||
operationId: "worktree.reset",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Worktree reset",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.ResetInput.zod),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Worktree.Service
|
||||
yield* svc.reset(body)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/session",
|
||||
describeRoute({
|
||||
summary: "List sessions",
|
||||
description:
|
||||
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
|
||||
operationId: "experimental.session.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.GlobalInfo.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
|
||||
roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }),
|
||||
start: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
|
||||
cursor: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
|
||||
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
|
||||
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
|
||||
archived: QueryBoolean.optional().meta({ description: "Include archived sessions (default false)" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const limit = query.limit ?? 100
|
||||
const sessions: Session.GlobalInfo[] = []
|
||||
for await (const session of Session.listGlobal({
|
||||
directory: query.directory,
|
||||
roots: queryBoolean(query.roots),
|
||||
start: query.start,
|
||||
cursor: query.cursor,
|
||||
search: query.search,
|
||||
limit: limit + 1,
|
||||
archived: queryBoolean(query.archived),
|
||||
})) {
|
||||
sessions.push(session)
|
||||
}
|
||||
const hasMore = sessions.length > limit
|
||||
const list = hasMore ? sessions.slice(0, limit) : sessions
|
||||
if (hasMore && list.length > 0) {
|
||||
c.header("x-next-cursor", String(list[list.length - 1].time.updated))
|
||||
}
|
||||
return c.json(list)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/resource",
|
||||
describeRoute({
|
||||
summary: "Get MCP resources",
|
||||
description: "Get all available MCP resources from connected servers. Optionally filter by name.",
|
||||
operationId: "experimental.resource.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP resources",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.record(z.string(), MCP.Resource.zod)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("ExperimentalRoutes.resource.list", c, function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.resources()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -1,190 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { File } from "@/file"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const FileRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
"/find",
|
||||
describeRoute({
|
||||
summary: "Find text",
|
||||
description: "Search for text patterns across files in the project using ripgrep.",
|
||||
operationId: "find.text",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Matches",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Ripgrep.SearchMatch.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
pattern: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.findText", c, function* () {
|
||||
const pattern = c.req.valid("query").pattern
|
||||
const svc = yield* Ripgrep.Service
|
||||
const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 })
|
||||
return result.items
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/find/file",
|
||||
describeRoute({
|
||||
summary: "Find files",
|
||||
description: "Search for files or directories by name or pattern in the project directory.",
|
||||
operationId: "find.files",
|
||||
responses: {
|
||||
200: {
|
||||
description: "File paths",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.string().array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
query: z.string(),
|
||||
dirs: z.enum(["true", "false"]).optional(),
|
||||
type: z.enum(["file", "directory"]).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.findFile", c, function* () {
|
||||
const query = c.req.valid("query")
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.search({
|
||||
query: query.query,
|
||||
limit: query.limit ?? 10,
|
||||
dirs: query.dirs !== "false",
|
||||
type: query.type,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/find/symbol",
|
||||
describeRoute({
|
||||
summary: "Find symbols",
|
||||
description: "Search for workspace symbols like functions, classes, and variables using LSP.",
|
||||
operationId: "find.symbols",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Symbols",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(LSP.Symbol.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
query: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
return c.json([])
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/file",
|
||||
describeRoute({
|
||||
summary: "List files",
|
||||
description: "List files and directories in a specified path.",
|
||||
operationId: "file.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Files and directories",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(File.Node.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.list", c, function* () {
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.list(c.req.valid("query").path)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/file/content",
|
||||
describeRoute({
|
||||
summary: "Read file",
|
||||
description: "Read the content of a specified file.",
|
||||
operationId: "file.read",
|
||||
responses: {
|
||||
200: {
|
||||
description: "File content",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(File.Content.zod),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.read", c, function* () {
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.read(c.req.valid("query").path)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/file/status",
|
||||
describeRoute({
|
||||
summary: "Get file status",
|
||||
description: "Get the git status of all files in the project.",
|
||||
operationId: "file.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "File status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(File.Info.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
jsonRequest("FileRoutes.status", c, function* () {
|
||||
const svc = yield* File.Service
|
||||
return yield* svc.status()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -22,8 +22,7 @@ import { WorkspaceApi } from "./groups/workspace"
|
||||
import { V2Api } from "./groups/v2"
|
||||
import { Authorization } from "./middleware/authorization"
|
||||
|
||||
// SSE event schemas built from the same BusEvent/SyncEvent registries that
|
||||
// the Hono spec uses, so both specs emit identical Event/SyncEvent components.
|
||||
// SSE event schemas built from the BusEvent/SyncEvent registries.
|
||||
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
|
||||
const SyncEventSchemas = SyncEvent.effectPayloads()
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import { MCP } from "@/mcp"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { Session } from "@/session/session"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const ConsoleStateResponse = Schema.Struct({
|
||||
@@ -43,6 +43,7 @@ const ToolListItem = Schema.Struct({
|
||||
}).annotate({ identifier: "ToolListItem" })
|
||||
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
|
||||
export const ToolListQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
provider: ProviderID,
|
||||
model: ModelID,
|
||||
})
|
||||
@@ -55,7 +56,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
||||
)
|
||||
const WorktreeList = Schema.Array(Schema.String)
|
||||
export const SessionListQuery = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
...WorkspaceRoutingQueryFields,
|
||||
roots: Schema.optional(QueryBoolean),
|
||||
start: Schema.optional(Schema.NumberFromString),
|
||||
cursor: Schema.optional(Schema.NumberFromString),
|
||||
@@ -82,6 +83,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
.add(
|
||||
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
||||
success: described(ConsoleStateResponse, "Active Console provider metadata"),
|
||||
error: HttpApiError.InternalServerError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.console.get",
|
||||
@@ -91,6 +93,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
),
|
||||
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
|
||||
success: described(ConsoleOrgList, "Switchable Console orgs"),
|
||||
error: HttpApiError.InternalServerError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.console.listOrgs",
|
||||
|
||||
@@ -5,18 +5,21 @@ import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||
import { described } from "./metadata"
|
||||
|
||||
export const FileQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
path: Schema.String,
|
||||
})
|
||||
|
||||
export const FindTextQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
pattern: Schema.String,
|
||||
})
|
||||
|
||||
export const FindFileQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
query: Schema.String,
|
||||
dirs: Schema.optional(Schema.Literals(["true", "false"])),
|
||||
type: Schema.optional(Schema.Literals(["file", "directory"])),
|
||||
@@ -26,6 +29,7 @@ export const FindFileQuery = Schema.Struct({
|
||||
})
|
||||
|
||||
export const FindSymbolQuery = Schema.Struct({
|
||||
...WorkspaceRoutingQueryFields,
|
||||
query: Schema.String,
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user