Compare commits

..

11 Commits

Author SHA1 Message Date
Brendan Allan 7423b4872c rename desktop-darwin to desktop-mac 2026-05-01 11:54:56 +08:00
Brendan Allan 92d44ce72e create git token after downloading artifacts 2026-04-29 14:25:36 +08:00
Brendan Allan a3cb00a1ab try improve 2026-04-29 10:17:54 +08:00
Brendan Allan 6a7b415894 read signature from sig file 2026-04-28 19:29:39 +08:00
Brendan Allan 682c4eecd6 don't do tag lookup istg 2026-04-28 15:41:05 +08:00
Brendan Allan 9fbff1bc7d Merge branch 'dev' into brendan/desktop-electron-only 2026-04-28 14:05:53 +08:00
Brendan Allan dced9c9baa lookup release by id not name 2026-04-28 14:03:15 +08:00
Brendan Allan 55e7bb08d0 remove build-tauri 2026-04-28 12:31:53 +08:00
Brendan Allan 6620054fe1 fix error 2026-04-28 12:28:09 +08:00
Brendan Allan db830c636b rename opencode-election-* to opencode-desktop-* 2026-04-28 12:28:09 +08:00
Brendan Allan 04c03fa612 ci: only build electron desktop 2026-04-28 12:28:09 +08:00
159 changed files with 3647 additions and 7908 deletions
+34 -185
View File
@@ -209,182 +209,6 @@ jobs:
packages/opencode/dist/opencode-windows-x64
packages/opencode/dist/opencode-windows-x64-baseline
build-tauri:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
settings:
- host: macos-latest
target: x86_64-apple-darwin
- host: macos-latest
target: aarch64-apple-darwin
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: windows-2025
target: aarch64-pc-windows-msvc
- host: blacksmith-4vcpu-windows-2025
target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404
target: x86_64-unknown-linux-gnu
- host: blacksmith-8vcpu-ubuntu-2404-arm
target: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.settings.host }}
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: apple-actions/import-codesign-certs@v2
if: ${{ runner.os == 'macOS' }}
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Verify Certificate
if: ${{ runner.os == 'macOS' }}
run: |
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported."
- name: Setup Apple API Key
if: ${{ runner.os == 'macOS' }}
run: |
echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
with:
path: ~/apt-cache
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
restore-keys: |
${{ runner.os }}-${{ matrix.settings.target }}-apt-
- name: install dependencies (ubuntu only)
if: contains(matrix.settings.host, 'ubuntu')
run: |
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
sudo apt-get update
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo chmod -R a+rw ~/apt-cache
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: packages/desktop/src-tauri
shared-key: ${{ matrix.settings.target }}
- name: Prepare
run: |
cd packages/desktop
bun ./scripts/prepare.ts
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Resolve tauri portable SHA
if: contains(matrix.settings.host, 'ubuntu')
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- name: Install tauri-cli from portable appimage branch
uses: taiki-e/cache-cargo-install-action@v3
if: contains(matrix.settings.host, 'ubuntu')
with:
tool: tauri-cli
git: https://github.com/tauri-apps/tauri
# branch: feat/truly-portable-appimage
rev: ${{ env.TAURI_PORTABLE_SHA }}
- name: Show tauri-cli version
if: contains(matrix.settings.host, 'ubuntu')
run: cargo tauri --version
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build and upload artifacts
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
timeout-minutes: 60
with:
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.version.outputs.release }}
tagName: ${{ needs.version.outputs.tag }}
releaseDraft: true
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }}
releaseCommitish: ${{ github.sha }}
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
- name: Verify signed Windows desktop artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
)
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
build-electron:
needs:
- build-cli
@@ -517,6 +341,30 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Create and upload macOS .app.tar.gz
if: runner.os == 'macOS' && needs.version.outputs.release
working-directory: packages/desktop-electron/dist
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
APP_DIR="mac"
OUT_NAME="opencode-desktop-mac-x64.app.tar.gz"
elif [[ "${{ matrix.settings.target }}" == "aarch64-apple-darwin" ]]; then
APP_DIR="mac-arm64"
OUT_NAME="opencode-desktop-mac-arm64.app.tar.gz"
else
echo "Unknown macOS target: ${{ matrix.settings.target }}"
exit 1
fi
APP_PATH=$(find "$APP_DIR" -maxdepth 1 -name "*.app" -type d | head -1)
if [ -z "$APP_PATH" ]; then
echo "No .app bundle found in $APP_DIR"
exit 1
fi
tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")"
gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}"
- name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows'
shell: pwsh
@@ -535,7 +383,7 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: opencode-electron-${{ matrix.settings.target }}
name: opencode-desktop-${{ matrix.settings.target }}
path: packages/desktop-electron/dist/*
- uses: actions/upload-artifact@v4
@@ -549,7 +397,6 @@ jobs:
- version
- build-cli
- sign-cli-windows
- build-tauri
- build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -576,13 +423,6 @@ jobs:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- uses: actions/download-artifact@v4
with:
name: opencode-cli
@@ -604,6 +444,13 @@ jobs:
pattern: latest-yml-*
path: /tmp/latest-yml
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Cache apt packages (AUR)
uses: actions/cache@v4
with:
@@ -632,3 +479,5 @@ jobs:
GH_REPO: ${{ needs.version.outputs.repo }}
NPM_CONFIG_PROVENANCE: false
LATEST_YML_DIR: /tmp/latest-yml
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+16 -16
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.29",
"version": "1.14.28",
"bin": {
"opencode": "./bin/opencode",
},
@@ -226,7 +226,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -259,7 +259,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -303,7 +303,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -332,7 +332,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -348,7 +348,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.29",
"version": "1.14.28",
"bin": {
"opencode": "./bin/opencode",
},
@@ -491,7 +491,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -526,7 +526,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -541,7 +541,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -576,7 +576,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -625,7 +625,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.29",
"version": "1.14.28",
"description": "",
"type": "module",
"exports": {
+1 -8
View File
@@ -391,14 +391,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
const base = { ...metadata, ...project }
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
return base
return { ...metadata, ...project }
}
const roots = createMemo(() => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.29",
"version": "1.14.28",
"type": "module",
"license": "MIT",
"scripts": {
+2 -2
View File
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "150K",
full: "150,000",
compact: "140K",
full: "140,000",
},
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.29",
"version": "1.14.28",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.29",
"version": "1.14.28",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.29",
"version": "1.14.28",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.29",
"version": "1.14.28",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -27,7 +27,7 @@ const channel = (() => {
})()
const getBase = (): Configuration => ({
artifactName: "opencode-electron-${os}-${arch}.${ext}",
artifactName: "opencode-desktop-${os}-${arch}.${ext}",
directories: {
output: "dist",
buildResources: "resources",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.29",
"version": "1.14.28",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.29",
"version": "1.14.28",
"type": "module",
"license": "MIT",
"scripts": {
+145 -90
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bun
import { Buffer } from "node:buffer"
import { $ } from "bun"
import path from "node:path"
import { parseArgs } from "node:util"
const { values } = parseArgs({
args: Bun.argv.slice(2),
@@ -12,8 +13,6 @@ const { values } = parseArgs({
const dryRun = values["dry-run"]
import { parseArgs } from "node:util"
const repo = process.env.GH_REPO
if (!repo) throw new Error("GH_REPO is required")
@@ -23,20 +22,22 @@ if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
const version = process.env.OPENCODE_VERSION
if (!version) throw new Error("OPENCODE_VERSION is required")
const dir = process.env.LATEST_YML_DIR
if (!dir) throw new Error("LATEST_YML_DIR is required")
const root = dir
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
const apiHeaders = {
Authorization: `token ${token}`,
Accept: "application/vnd.github+json",
}
const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, {
headers: apiHeaders,
const rel = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, {
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github+json",
},
})
if (!releaseRes.ok) {
throw new Error(`Failed to fetch release: ${releaseRes.status} ${releaseRes.statusText}`)
if (!rel.ok) {
throw new Error(`Failed to fetch release: ${rel.status} ${rel.statusText}`)
}
type Asset = {
@@ -45,115 +46,169 @@ type Asset = {
}
type Release = {
tag_name?: string
assets?: Asset[]
}
const release = (await releaseRes.json()) as Release
const assets = release.assets ?? []
const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
const assets = ((await rel.json()) as Release).assets ?? []
const amap = new Map(assets.map((item) => [item.name, item]))
const latestAsset = assetByName.get("latest.json")
if (!latestAsset) {
console.log("latest.json not found, skipping tauri finalization")
process.exit(0)
type Item = {
url: string
}
const latestRes = await fetch(latestAsset.url, {
headers: {
Authorization: `token ${token}`,
Accept: "application/octet-stream",
},
})
if (!latestRes.ok) {
throw new Error(`Failed to fetch latest.json: ${latestRes.status} ${latestRes.statusText}`)
type Yml = {
version: string
files: Item[]
}
const latestText = new TextDecoder().decode(await latestRes.arrayBuffer())
const latest = JSON.parse(latestText)
const base = { ...latest }
delete base.platforms
function parse(text: string): Yml {
const lines = text.split("\n")
let version = ""
const files: Item[] = []
let url = ""
const fetchSignature = async (asset: Asset) => {
const res = await fetch(asset.url, {
const flush = () => {
if (!url) return
files.push({ url })
url = ""
}
for (const line of lines) {
const trim = line.trim()
if (line.startsWith("version:")) {
version = line.slice("version:".length).trim()
continue
}
if (trim.startsWith("- url:")) {
flush()
url = trim.slice("- url:".length).trim()
continue
}
const indented = line.startsWith(" ") || line.startsWith("\t")
if (!indented) flush()
}
flush()
return { version, files }
}
async function read(sub: string, file: string) {
const item = Bun.file(path.join(root, sub, file))
if (!(await item.exists())) return undefined
return parse(await item.text())
}
function pick(list: Item[], exts: string[]) {
for (const ext of exts) {
const found = list.find((item) => item.url.split("?")[0]?.toLowerCase().endsWith(ext))
if (found) return found.url
}
}
function link(raw: string) {
if (raw.startsWith("https://") || raw.startsWith("http://")) return raw
return `https://github.com/${repo}/releases/download/v${version}/${raw}`
}
async function sign(url: string, key: string) {
const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? key)
const asset = amap.get(name)
const res = await fetch(asset?.url ?? url, {
headers: {
Authorization: `token ${token}`,
Accept: "application/octet-stream",
...(asset ? { Accept: "application/octet-stream" } : {}),
},
})
if (!res.ok) {
throw new Error(`Failed to fetch signature: ${res.status} ${res.statusText}`)
throw new Error(`Failed to fetch file ${name}: ${res.status} ${res.statusText} (${asset?.url ?? url})`)
}
return Buffer.from(await res.arrayBuffer()).toString()
const tmp = process.env.RUNNER_TEMP ?? "/tmp"
const file = path.join(tmp, name)
await Bun.write(file, await res.arrayBuffer())
await $`bunx @tauri-apps/cli signer sign ${file}`
const sigFile = Bun.file(`${file}.sig`)
if (!(await sigFile.exists())) throw new Error(`Signature file not found for ${name}`)
return (await sigFile.text()).trim()
}
const entries: Record<string, { url: string; signature: string }> = {}
const add = (key: string, asset: Asset, signature: string) => {
if (entries[key]) return
entries[key] = {
url: `https://github.com/${repo}/releases/download/v${version}/${asset.name}`,
signature,
}
const add = async (data: Record<string, { url: string; signature: string }>, key: string, raw: string | undefined) => {
if (!raw) return
if (data[key]) return
const url = link(raw)
data[key] = { url, signature: await sign(url, key) }
}
const targets = [
{ key: "linux-x86_64-deb", asset: "opencode-desktop-linux-amd64.deb" },
{ key: "linux-x86_64-rpm", asset: "opencode-desktop-linux-x86_64.rpm" },
{ key: "linux-aarch64-deb", asset: "opencode-desktop-linux-arm64.deb" },
{ key: "linux-aarch64-rpm", asset: "opencode-desktop-linux-aarch64.rpm" },
{ key: "windows-aarch64-nsis", asset: "opencode-desktop-windows-arm64.exe" },
{ key: "windows-x86_64-nsis", asset: "opencode-desktop-windows-x64.exe" },
{ key: "darwin-x86_64-app", asset: "opencode-desktop-darwin-x64.app.tar.gz" },
{
key: "darwin-aarch64-app",
asset: "opencode-desktop-darwin-aarch64.app.tar.gz",
},
]
for (const target of targets) {
const asset = assetByName.get(target.asset)
if (!asset) continue
const sig = assetByName.get(`${target.asset}.sig`)
if (!sig) continue
const signature = await fetchSignature(sig)
add(target.key, asset, signature)
const alias = (data: Record<string, { url: string; signature: string }>, key: string, src: string) => {
if (data[key]) return
if (!data[src]) return
data[key] = data[src]
}
const alias = (key: string, source: string) => {
if (entries[key]) return
const entry = entries[source]
if (!entry) return
entries[key] = entry
}
const winx = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml")
const wina = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml")
const macx = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml")
const maca = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml")
const linx = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml")
const lina = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml")
alias("linux-x86_64", "linux-x86_64-deb")
alias("linux-aarch64", "linux-aarch64-deb")
alias("windows-aarch64", "windows-aarch64-nsis")
alias("windows-x86_64", "windows-x86_64-nsis")
alias("darwin-x86_64", "darwin-x86_64-app")
alias("darwin-aarch64", "darwin-aarch64-app")
const yver = winx?.version ?? wina?.version ?? macx?.version ?? maca?.version ?? linx?.version ?? lina?.version
if (yver && yver !== version) throw new Error(`latest.yml version mismatch: expected ${version}, got ${yver}`)
const out: Record<string, { url: string; signature: string }> = {}
const winxexe = pick(winx?.files ?? [], [".exe"])
const winaexe = pick(wina?.files ?? [], [".exe"])
const macxTarGz = "opencode-desktop-mac-x64.app.tar.gz"
const macaTarGz = "opencode-desktop-mac-arm64.app.tar.gz"
const linxDeb = pick(linx?.files ?? [], [".deb"])
const linxRpm = pick(linx?.files ?? [], [".rpm"])
const linxAppImage = pick(linx?.files ?? [], [".appimage"])
const linaDeb = pick(lina?.files ?? [], [".deb"])
const linaRpm = pick(lina?.files ?? [], [".rpm"])
const linaAppImage = pick(lina?.files ?? [], [".appimage"])
await add(out, "windows-x86_64-nsis", winxexe)
await add(out, "windows-aarch64-nsis", winaexe)
await add(out, "darwin-x86_64-app", macxTarGz)
await add(out, "darwin-aarch64-app", macaTarGz)
await add(out, "linux-x86_64-deb", linxDeb)
await add(out, "linux-x86_64-rpm", linxRpm)
await add(out, "linux-x86_64-appimage", linxAppImage)
await add(out, "linux-aarch64-deb", linaDeb)
await add(out, "linux-aarch64-rpm", linaRpm)
await add(out, "linux-aarch64-appimage", linaAppImage)
alias(out, "windows-x86_64", "windows-x86_64-nsis")
alias(out, "windows-aarch64", "windows-aarch64-nsis")
alias(out, "darwin-x86_64", "darwin-x86_64-app")
alias(out, "darwin-aarch64", "darwin-aarch64-app")
alias(out, "linux-x86_64", "linux-x86_64-deb")
alias(out, "linux-aarch64", "linux-aarch64-deb")
const platforms = Object.fromEntries(
Object.keys(entries)
Object.keys(out)
.sort()
.map((key) => [key, entries[key]]),
.map((key) => [key, out[key]]),
)
const output = {
...base,
if (!Object.keys(platforms).length) throw new Error("No updater files found in latest.yml artifacts")
const data = {
version,
notes: "",
pub_date: new Date().toISOString(),
platforms,
}
const dir = process.env.RUNNER_TEMP ?? "/tmp"
const file = `${dir}/latest.json`
await Bun.write(file, JSON.stringify(output, null, 2))
const tmp = process.env.RUNNER_TEMP ?? "/tmp"
const file = path.join(tmp, "latest.json")
await Bun.write(file, JSON.stringify(data, null, 2))
const tag = release.tag_name
if (!tag) throw new Error("Release tag not found")
const tag = `v${version}`
if (dryRun) {
console.log(`dry-run: wrote latest.json for ${tag} to ${file}`)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.29",
"version": "1.14.28",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.29"
version = "1.14.28"
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.29/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.28/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.29/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.28/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.28/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.29/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.28/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.29/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.28/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.29",
"version": "1.14.28",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
-1
View File
@@ -7,4 +7,3 @@ src/provider/models-snapshot.js
src/provider/models-snapshot.d.ts
script/build-*.ts
temporary-*.md
.artifacts
@@ -1 +0,0 @@
ALTER TABLE `session` ADD `path` text;
@@ -1,1419 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"id": "aaa2ebeb-caa4-478d-8365-4fc595d16856",
"prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"],
"ddl": [
{
"name": "account_state",
"entityType": "tables"
},
{
"name": "account",
"entityType": "tables"
},
{
"name": "control_account",
"entityType": "tables"
},
{
"name": "workspace",
"entityType": "tables"
},
{
"name": "project",
"entityType": "tables"
},
{
"name": "message",
"entityType": "tables"
},
{
"name": "part",
"entityType": "tables"
},
{
"name": "permission",
"entityType": "tables"
},
{
"name": "session_entry",
"entityType": "tables"
},
{
"name": "session",
"entityType": "tables"
},
{
"name": "todo",
"entityType": "tables"
},
{
"name": "session_share",
"entityType": "tables"
},
{
"name": "event_sequence",
"entityType": "tables"
},
{
"name": "event",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "account_state"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "active_account_id",
"entityType": "columns",
"table": "account_state"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "active_org_id",
"entityType": "columns",
"table": "account_state"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "token_expiry",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "control_account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "control_account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "access_token",
"entityType": "columns",
"table": "control_account"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "refresh_token",
"entityType": "columns",
"table": "control_account"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "token_expiry",
"entityType": "columns",
"table": "control_account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "active",
"entityType": "columns",
"table": "control_account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "control_account"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "control_account"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "''",
"generated": null,
"name": "name",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "branch",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "extra",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "worktree",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "vcs",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_url",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_url_override",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "icon_color",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "project"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_initialized",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sandboxes",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "commands",
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "message"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "message"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "message_id",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "part"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "part"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "permission"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "permission"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "permission"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "permission"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "session_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "session_entry"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "workspace_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "parent_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "slug",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "path",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "title",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "version",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "share_url",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_additions",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_deletions",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_files",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary_diffs",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "revert",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "permission",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_compacting",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_archived",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "content",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "status",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "priority",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "position",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "todo"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "todo"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "secret",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_share"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_share"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "aggregate_id",
"entityType": "columns",
"table": "event_sequence"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "seq",
"entityType": "columns",
"table": "event_sequence"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "event"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "aggregate_id",
"entityType": "columns",
"table": "event"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "seq",
"entityType": "columns",
"table": "event"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "event"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "data",
"entityType": "columns",
"table": "event"
},
{
"columns": ["active_account_id"],
"tableTo": "account",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
"name": "fk_account_state_active_account_id_account_id_fk",
"entityType": "fks",
"table": "account_state"
},
{
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_workspace_project_id_project_id_fk",
"entityType": "fks",
"table": "workspace"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_message_session_id_session_id_fk",
"entityType": "fks",
"table": "message"
},
{
"columns": ["message_id"],
"tableTo": "message",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_part_message_id_message_id_fk",
"entityType": "fks",
"table": "part"
},
{
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_permission_project_id_project_id_fk",
"entityType": "fks",
"table": "permission"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_entry_session_id_session_id_fk",
"entityType": "fks",
"table": "session_entry"
},
{
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_project_id_project_id_fk",
"entityType": "fks",
"table": "session"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_todo_session_id_session_id_fk",
"entityType": "fks",
"table": "todo"
},
{
"columns": ["session_id"],
"tableTo": "session",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_share_session_id_session_id_fk",
"entityType": "fks",
"table": "session_share"
},
{
"columns": ["aggregate_id"],
"tableTo": "event_sequence",
"columnsTo": ["aggregate_id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk",
"entityType": "fks",
"table": "event"
},
{
"columns": ["email", "url"],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": ["session_id", "position"],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": ["project_id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_entry_pk",
"table": "session_entry",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": ["session_id"],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
"entityType": "pks"
},
{
"columns": ["aggregate_id"],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "time_created",
"isExpression": false
},
{
"value": "id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "message_session_time_created_id_idx",
"entityType": "indexes",
"table": "message"
},
{
"columns": [
{
"value": "message_id",
"isExpression": false
},
{
"value": "id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "part_message_id_id_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "part_session_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_entry_session_idx",
"entityType": "indexes",
"table": "session_entry"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "type",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_entry_session_type_idx",
"entityType": "indexes",
"table": "session_entry"
},
{
"columns": [
{
"value": "time_created",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_entry_time_created_idx",
"entityType": "indexes",
"table": "session_entry"
},
{
"columns": [
{
"value": "project_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_project_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "workspace_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_workspace_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "parent_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_parent_idx",
"entityType": "indexes",
"table": "session"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "todo_session_idx",
"entityType": "indexes",
"table": "todo"
}
],
"renames": []
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.29",
"version": "1.14.28",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -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
@@ -129,14 +129,6 @@ Required before route deletion:
- 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:
+3 -3
View File
@@ -31,8 +31,8 @@ export const Info = Schema.Struct({
mode: Schema.Literals(["subagent", "primary", "all"]),
native: Schema.optional(Schema.Boolean),
hidden: Schema.optional(Schema.Boolean),
topP: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Finite),
topP: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
color: Schema.optional(Schema.String),
permission: Permission.Ruleset,
model: Schema.optional(
@@ -44,7 +44,7 @@ export const Info = Schema.Struct({
variant: Schema.optional(Schema.String),
prompt: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown),
steps: Schema.optional(Schema.Finite),
steps: Schema.optional(Schema.Number),
})
.annotate({ identifier: "Agent" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
+1 -2
View File
@@ -1,7 +1,6 @@
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 { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -15,7 +14,7 @@ export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
-12
View File
@@ -34,16 +34,4 @@ export function payloads() {
.toArray()
}
export function effectPayloads() {
return registry
.entries()
.map(([type, def]) =>
Schema.Struct({
type: Schema.Literal(type),
properties: def.properties,
}).annotate({ identifier: `Event.${type}` }),
)
.toArray()
}
export * as BusEvent from "./bus-event"
+4 -15
View File
@@ -1,26 +1,15 @@
import { Server } from "../../server/server"
import { PublicApi } from "../../server/routes/instance/httpapi/public"
import type { CommandModule } from "yargs"
import { OpenApi } from "effect/unstable/httpapi"
type Args = {
httpapi: boolean
}
export const GenerateCommand = {
command: "generate",
builder: (yargs) =>
yargs.option("httpapi", {
type: "boolean",
default: false,
description: "Generate OpenAPI from the experimental Effect HttpApi contract",
}),
handler: async (args) => {
const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi()
handler: async () => {
const specs = await Server.openapi()
for (const item of Object.values(specs.paths)) {
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method]
if (!operation?.operationId) continue
// @ts-expect-error
operation["x-codeSamples"] = [
{
lang: "js",
@@ -58,4 +47,4 @@ export const GenerateCommand = {
})
})
},
} satisfies CommandModule<object, Args>
} satisfies CommandModule
-12
View File
@@ -736,18 +736,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
{
title: kv.get("session_directory_filter_enabled", true)
? "Disable session directory filtering"
: "Enable session directory filtering",
value: "app.toggle.session_directory_filter",
category: "System",
onSelect: async (dialog) => {
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
await sync.session.refresh()
dialog.clear()
},
},
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
@@ -32,14 +32,11 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }),
async (input) => {
if (!input.query) return undefined
const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
return result.data ?? []
},
)
const [searchResults, { refetch }] = createResource(search, async (query) => {
if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? []
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
@@ -20,8 +20,6 @@ const ZedEditorContentsSchema = z.object({
contents: z.string().nullable(),
})
const utf8 = new TextEncoder()
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
@@ -47,8 +45,8 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
.catch(() => undefined)
if (text == null) return { type: "unavailable" }
const startOffset = utf8ByteOffsetToStringIndex(text, Math.min(row.selection_start, row.selection_end))
const endOffset = utf8ByteOffsetToStringIndex(text, Math.max(row.selection_start, row.selection_end))
const startOffset = Math.min(row.selection_start, row.selection_end)
const endOffset = Math.max(row.selection_start, row.selection_end)
return {
type: "selection",
@@ -160,25 +158,7 @@ function zedWorkspacePaths(value: string | null) {
}
export function offsetToPosition(text: string, offset: number) {
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
return offsetsToSelection(text, stringOffset, stringOffset).start
}
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
if (byteOffset <= 0) return 0
let bytes = 0
for (let index = 0; index < text.length; ) {
const codePoint = text.codePointAt(index)
if (codePoint === undefined) return text.length
const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
bytes += utf8.encode(text.slice(index, nextIndex)).length
if (bytes >= byteOffset) return nextIndex
index = nextIndex
}
return text.length
return offsetsToSelection(text, offset, offset).start
}
function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
@@ -30,8 +30,6 @@ import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
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"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -109,27 +107,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const event = useEvent()
const project = useProject()
const sdk = useSDK()
const kv = useKV()
const fullSyncedSessions = new Set<string>()
let syncedWorkspace = project.workspace.current()
function sessionListQuery(): { scope?: "project"; path?: string } {
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
return {
path: path
.relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
.replaceAll("\\", "/"),
}
}
function listSessions() {
return sdk.client.session
.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
}
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
@@ -379,8 +360,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.clear()
syncedWorkspace = workspace
}
const projectPromise = project.sync()
const sessionListPromise = projectPromise.then(() => listSessions())
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
.list({ start: start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
@@ -391,6 +374,7 @@ 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 projectPromise = project.sync()
const blockingRequests: Promise<unknown>[] = [
providersPromise,
providerListPromise,
@@ -495,11 +479,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index]
return undefined
},
query() {
return sessionListQuery()
},
async refresh() {
const list = await listSessions()
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const list = await sdk.client.session
.list({ start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
setStore("session", reconcile(list))
},
status(sessionID: string) {
@@ -500,8 +500,7 @@ async function getCustomThemes() {
symlink: true,
})) {
const name = path.basename(item, ".json")
const theme = await Filesystem.readJson(item)
if (isTheme(theme)) result[name] = theme
result[name] = await Filesystem.readJson(item)
}
}
return result
+1 -2
View File
@@ -1,6 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
import { PositiveInt } from "@/util/schema"
import { Effect, Schema } from "effect"
const DEFAULT_TOAST_DURATION = 5000
@@ -39,7 +38,7 @@ export const TuiEvent = {
title: Schema.optional(Schema.String),
message: Schema.String,
variant: Schema.Literals(["info", "success", "warning", "error"]),
duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
duration: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
description: "Duration in milliseconds",
}),
}),
+2 -2
View File
@@ -26,8 +26,8 @@ const AgentSchema = Schema.StructWithRest(
variant: Schema.optional(Schema.String).annotate({
description: "Default model variant for this agent (applies only when using the agent's configured model).",
}),
temperature: Schema.optional(Schema.Finite),
top_p: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
prompt: Schema.optional(Schema.String),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "@deprecated Use 'permission' field instead",
@@ -1,11 +1,10 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt } from "@/util/schema"
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: NonNegativeInt,
switchableOrgCount: Schema.Number,
}) {
static readonly zod = zod(this)
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { PositiveInt, withStatics } from "@/util/schema"
import { withStatics } from "@/util/schema"
export const Local = Schema.Struct({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
@@ -13,7 +13,7 @@ export const Local = Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Enable or disable the MCP server on startup",
}),
timeout: Schema.optional(PositiveInt).annotate({
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
})
@@ -49,7 +49,7 @@ export const Remote = Schema.Struct({
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
}),
timeout: Schema.optional(PositiveInt).annotate({
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
})
+11 -11
View File
@@ -21,25 +21,25 @@ export const Model = Schema.Struct({
),
cost: Schema.optional(
Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
context_over_200k: Schema.optional(
Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
}),
),
}),
),
limit: Schema.optional(
Schema.Struct({
context: Schema.Finite,
input: Schema.optional(Schema.Finite),
output: Schema.Finite,
context: Schema.Number,
input: Schema.optional(Schema.Number),
output: Schema.Number,
}),
),
modalities: Schema.optional(
+7 -7
View File
@@ -15,12 +15,12 @@ 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 { type DeepMutable, withStatics } from "@/util/schema"
export const Info = Schema.Struct({
path: Schema.String,
added: NonNegativeInt,
removed: NonNegativeInt,
added: Schema.Int,
removed: Schema.Int,
status: Schema.Literals(["added", "deleted", "modified"]),
})
.annotate({ identifier: "File" })
@@ -39,10 +39,10 @@ export const Node = Schema.Struct({
export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
const Hunk = Schema.Struct({
oldStart: NonNegativeInt,
oldLines: NonNegativeInt,
newStart: NonNegativeInt,
newLines: NonNegativeInt,
oldStart: Schema.Number,
oldLines: Schema.Number,
newStart: Schema.Number,
newLines: Schema.Number,
lines: Schema.Array(Schema.String),
})
+14 -14
View File
@@ -12,7 +12,7 @@ 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 { withStatics } from "@/util/schema"
const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0"
@@ -27,19 +27,19 @@ const PLATFORM = {
} as const
const TimeStats = Schema.Struct({
secs: NonNegativeInt,
nanos: NonNegativeInt,
secs: Schema.Number,
nanos: Schema.Number,
human: Schema.String,
})
const Stats = Schema.Struct({
elapsed: TimeStats,
searches: NonNegativeInt,
searches_with_match: NonNegativeInt,
bytes_searched: NonNegativeInt,
bytes_printed: NonNegativeInt,
matched_lines: NonNegativeInt,
matches: NonNegativeInt,
searches: Schema.Number,
searches_with_match: Schema.Number,
bytes_searched: Schema.Number,
bytes_printed: Schema.Number,
matched_lines: Schema.Number,
matches: Schema.Number,
})
const PathText = Schema.Struct({
@@ -58,15 +58,15 @@ export const SearchMatch = Schema.Struct({
lines: Schema.Struct({
text: Schema.String,
}),
line_number: NonNegativeInt,
absolute_offset: NonNegativeInt,
line_number: Schema.Number,
absolute_offset: Schema.Number,
submatches: Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
start: NonNegativeInt,
end: NonNegativeInt,
start: Schema.Number,
end: Schema.Number,
}),
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -80,7 +80,7 @@ const End = Schema.Struct({
type: Schema.Literal("end"),
data: Schema.Struct({
path: PathText,
binary_offset: Schema.NullOr(NonNegativeInt),
binary_offset: Schema.NullOr(Schema.Number),
stats: Stats,
}),
})
+5 -5
View File
@@ -13,7 +13,7 @@ import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { withStatics } from "@/util/schema"
import { zod, ZodOverride } from "@/util/effect-zod"
const log = Log.create({ service: "lsp" })
@@ -23,8 +23,8 @@ export const Event = {
}
const Position = Schema.Struct({
line: NonNegativeInt,
character: NonNegativeInt,
line: Schema.Number,
character: Schema.Number,
})
export const Range = Schema.Struct({
@@ -37,7 +37,7 @@ export type Range = typeof Range.Type
export const Symbol = Schema.Struct({
name: Schema.String,
kind: NonNegativeInt,
kind: Schema.Number,
location: Schema.Struct({
uri: Schema.String,
range: Range,
@@ -50,7 +50,7 @@ export type Symbol = typeof Symbol.Type
export const DocumentSymbol = Schema.Struct({
name: Schema.String,
detail: Schema.optional(Schema.String),
kind: NonNegativeInt,
kind: Schema.Number,
range: Range,
selectionRange: Range,
})
@@ -58,7 +58,7 @@ function build(key: string, remote: Item, url: string, prev?: Model): Model {
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
const model: Model = {
return {
id: key,
providerID: "github-copilot",
api: {
@@ -107,50 +107,8 @@ function build(key: string, remote: Item, url: string, prev?: Model): Model {
release_date:
prev?.release_date ??
(remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version),
variants: prev?.variants ?? {},
}
const efforts = remote.capabilities.supports.reasoning_effort
const variants: NonNullable<Model["variants"]> = {}
if (!isMsgApi && efforts?.length) {
efforts.forEach((effort) => {
variants[effort] = {
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
}
})
} else {
if (efforts?.length && remote.capabilities.supports.adaptive_thinking) {
efforts.forEach((effort) => {
variants[effort] = {
thinking: {
type: "adaptive",
...(model.api.id.includes("opus-4.7") ? { display: "summarized" } : {}),
},
effort,
}
})
} else if (remote.capabilities.supports.max_thinking_budget) {
const max = remote.capabilities.supports.max_thinking_budget
variants["max"] = {
thinking: {
type: "enabled",
budgetTokens: max - 1,
},
}
variants["high"] = {
thinking: {
type: "enabled",
budgetTokens: Math.floor(max / 2),
},
}
}
}
if (Object.keys(variants).length > 0) {
model.variants = variants
}
return model
}
export async function get(
+1 -1
View File
@@ -127,7 +127,7 @@ export const layer = Layer.effect(
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
}
: undefined,
fetch: async (...args) => Server.Default().app.fetch(...args),
fetch: async (...args) => (await Server.Default()).app.fetch(...args),
})
const cfg = yield* config.get()
const input: PluginInput = {
+4 -4
View File
@@ -16,7 +16,7 @@ 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, withStatics } from "@/util/schema"
import { withStatics } from "@/util/schema"
const log = Log.create({ service: "project" })
@@ -35,9 +35,9 @@ const ProjectCommands = Schema.Struct({
})
const ProjectTime = Schema.Struct({
created: NonNegativeInt,
updated: NonNegativeInt,
initialized: Schema.optional(NonNegativeInt),
created: Schema.Number,
updated: Schema.Number,
initialized: Schema.optional(Schema.Number),
})
export const Info = Schema.Struct({
+3 -3
View File
@@ -9,7 +9,7 @@ import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import * as Log from "@opencode-ai/core/util/log"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { withStatics } from "@/util/schema"
const log = Log.create({ service: "vcs" })
@@ -125,8 +125,8 @@ export type Info = Schema.Schema.Type<typeof Info>
export const FileDiff = Schema.Struct({
file: Schema.String,
patch: Schema.String,
additions: NonNegativeInt,
deletions: NonNegativeInt,
additions: Schema.Number,
deletions: Schema.Number,
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
})
.annotate({ identifier: "VcsFileDiff" })
+2 -2
View File
@@ -58,13 +58,13 @@ export class Authorization extends Schema.Class<Authorization>("ProviderAuthAuth
}
export const AuthorizeInput = Schema.Struct({
method: Schema.Finite.annotate({ description: "Auth method index" }),
method: Schema.Number.annotate({ description: "Auth method index" }),
inputs: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ description: "Prompt inputs" }),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type AuthorizeInput = Schema.Schema.Type<typeof AuthorizeInput>
export const CallbackInput = Schema.Struct({
method: Schema.Finite.annotate({ description: "Auth method index" }),
method: Schema.Number.annotate({ description: "Auth method index" }),
code: Schema.optional(Schema.String).annotate({ description: "OAuth authorization code" }),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
+11 -11
View File
@@ -22,16 +22,16 @@ const filepath = path.join(
const ttl = 5 * 60 * 1000
const Cost = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
context_over_200k: Schema.optional(
Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
}),
),
})
@@ -55,9 +55,9 @@ export const Model = Schema.Struct({
),
cost: Schema.optional(Cost),
limit: Schema.Struct({
context: Schema.Finite,
input: Schema.optional(Schema.Finite),
output: Schema.Finite,
context: Schema.Number,
input: Schema.optional(Schema.Number),
output: Schema.Number,
}),
modalities: Schema.optional(
Schema.Struct({
+10 -12
View File
@@ -848,27 +848,27 @@ const ProviderCapabilities = Schema.Struct({
})
const ProviderCacheCost = Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
read: Schema.Number,
write: Schema.Number,
})
const ProviderCost = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
input: Schema.Number,
output: Schema.Number,
cache: ProviderCacheCost,
experimentalOver200K: Schema.optional(
Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
input: Schema.Number,
output: Schema.Number,
cache: ProviderCacheCost,
}),
),
})
const ProviderLimit = Schema.Struct({
context: Schema.Finite,
input: Schema.optional(Schema.Finite),
output: Schema.Finite,
context: Schema.Number,
input: Schema.optional(Schema.Number),
output: Schema.Number,
})
export const Model = Schema.Struct({
@@ -1358,9 +1358,7 @@ const layer: Layer.Layer<
)
delete provider.models[modelID]
if (!model.variants || Object.keys(model.variants).length === 0) {
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
}
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
const configVariants = configProvider?.models?.[modelID]?.variants
if (configVariants && model.variants) {
+8 -9
View File
@@ -630,17 +630,16 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
case "@ai-sdk/google-vertex/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
if (adaptiveEfforts) {
let efforts = [...adaptiveEfforts]
if (model.providerID === "github-copilot") {
if (model.api.id.includes("opus-4.7")) {
efforts = ["medium"]
}
// Efforts currently supported are: low, medium, high
efforts = efforts.filter((v) => v !== "max" && v !== "xhigh")
if (model.providerID === "github-copilot") {
if (model.api.id.includes("opus-4.7")) {
return Object.fromEntries(["medium"].map((effort) => [effort, { reasoningEffort: effort }]))
}
}
if (adaptiveEfforts) {
return Object.fromEntries(
efforts.map((effort) => [
adaptiveEfforts.map((effort) => [
effort,
{
thinking: {
+5 -5
View File
@@ -12,7 +12,7 @@ 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 { withStatics } from "@/util/schema"
const log = Log.create({ service: "pty" })
@@ -62,7 +62,7 @@ export const Info = Schema.Struct({
args: Schema.Array(Schema.String),
cwd: Schema.String,
status: Schema.Literals(["running", "exited"]),
pid: PositiveInt,
pid: Schema.Number,
})
.annotate({ identifier: "Pty" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -83,8 +83,8 @@ export const UpdateInput = Schema.Struct({
title: Schema.optional(Schema.String),
size: Schema.optional(
Schema.Struct({
rows: PositiveInt,
cols: PositiveInt,
rows: Schema.Number,
cols: Schema.Number,
}),
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -94,7 +94,7 @@ export type UpdateInput = Types.DeepMutable<Schema.Schema.Type<typeof UpdateInpu
export const Event = {
Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })),
Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: NonNegativeInt })),
Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: Schema.Number })),
Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })),
}
+29 -33
View File
@@ -1,44 +1,40 @@
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))
},
}
}
import type { Adapter } from "./adapter"
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)),
async listen(opts) {
const args = {
fetch: app.fetch,
hostname: opts.hostname,
idleTimeout: 0,
websocket: ws.websocket,
} as const
const start = (port: number) => {
try {
return Bun.serve({ ...args, 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))
},
}
},
}
},
}
+54 -61
View File
@@ -1,73 +1,66 @@
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 })
inject?.(server)
const fail = (err: Error) => {
cleanup()
reject(err)
}
const ready = () => {
cleanup()
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
}
server.once("error", fail)
server.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
},
}
}
import type { Adapter } from "./adapter"
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),
async listen(opts) {
const start = (port: number) =>
new Promise<ServerType>((resolve, reject) => {
const server = createAdaptorServer({ fetch: app.fetch })
ws.injectWebSocket(server)
const fail = (err: Error) => {
cleanup()
reject(err)
}
const ready = () => {
cleanup()
resolve(server)
}
const cleanup = () => {
server.off("error", fail)
server.off("listening", ready)
}
server.once("error", fail)
server.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((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
},
}
},
}
},
}
-5
View File
@@ -1,10 +1,6 @@
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
@@ -22,5 +18,4 @@ export interface Runtime {
export interface Adapter {
create(app: Hono): Runtime
createFetch(app: FetchApp): Omit<Runtime, "upgradeWebSocket">
}
-32
View File
@@ -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",
}
}
+11 -12
View File
@@ -10,7 +10,6 @@ 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"
const log = Log.create({ service: "server" })
@@ -50,20 +49,20 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {
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 = {
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
const skip = c.req.path === "/log"
if (!skip) {
log.info("request", {
method: c.req.method,
path: c.req.path,
...backendAttributes,
}
log.info("request", attributes)
const timer = log.time("request", attributes)
await next()
timer.stop()
})
}
const timer = log.time("request", {
method: c.req.method,
path: c.req.path,
})
await next()
if (!skip) timer.stop()
}
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
+4 -4
View File
@@ -33,7 +33,7 @@ function headers(req: Request, extra?: HeadersInit) {
return out
}
export function websocketProtocols(req: Request) {
function protocols(req: Request) {
const value = req.headers.get("sec-websocket-protocol")
if (!value) return []
return value
@@ -42,7 +42,7 @@ export function websocketProtocols(req: Request) {
.filter(Boolean)
}
export function websocketTargetURL(url: string | URL) {
function socket(url: string | URL) {
const next = new URL(url)
if (next.protocol === "http:") next.protocol = "ws:"
if (next.protocol === "https:") next.protocol = "wss:"
@@ -69,7 +69,7 @@ const app = (upgrade: UpgradeWebSocket) =>
ws.close(1011, "missing proxy target")
return
}
remote = new WebSocket(url, websocketProtocols(c.req.raw))
remote = new WebSocket(url, protocols(c.req.raw))
remote.binaryType = "arraybuffer"
remote.onopen = () => {
for (const item of queue) remote?.send(item)
@@ -150,7 +150,7 @@ export function websocket(
proxy.pathname = "/__workspace_ws"
proxy.search = ""
const next = new Headers(req.headers)
next.set("x-opencode-proxy-url", websocketTargetURL(target))
next.set("x-opencode-proxy-url", socket(target))
for (const [key, value] of new Headers(extra).entries()) {
next.set(key, value)
}
@@ -1,54 +0,0 @@
import { Schema } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { ConfigApi } from "./groups/config"
import { ControlApi } from "./groups/control"
import { EventApi } from "./event"
import { ExperimentalApi } from "./groups/experimental"
import { FileApi } from "./groups/file"
import { GlobalApi } from "./groups/global"
import { InstanceApi } from "./groups/instance"
import { McpApi } from "./groups/mcp"
import { PermissionApi } from "./groups/permission"
import { ProjectApi } from "./groups/project"
import { ProviderApi } from "./groups/provider"
import { PtyApi, PtyConnectApi } from "./groups/pty"
import { QuestionApi } from "./groups/question"
import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace"
// SSE event schemas built from the same BusEvent/SyncEvent registries that
// the Hono spec uses, so both specs emit identical Event/SyncEvent components.
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
const SyncEventSchemas = SyncEvent.effectPayloads()
export const RootHttpApi = HttpApi.make("opencode-root").addHttpApi(ControlApi).addHttpApi(GlobalApi)
export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(ConfigApi)
.addHttpApi(ExperimentalApi)
.addHttpApi(FileApi)
.addHttpApi(InstanceApi)
.addHttpApi(McpApi)
.addHttpApi(ProjectApi)
.addHttpApi(PtyApi)
.addHttpApi(QuestionApi)
.addHttpApi(PermissionApi)
.addHttpApi(ProviderApi)
.addHttpApi(SessionApi)
.addHttpApi(SyncApi)
.addHttpApi(TuiApi)
.addHttpApi(WorkspaceApi)
export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(RootHttpApi)
.addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi)
.addHttpApi(PtyConnectApi)
.annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas])
export type RootHttpApiType = typeof RootHttpApi
export type InstanceHttpApiType = typeof InstanceHttpApi
@@ -1,9 +1,10 @@
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
import * as InstanceState from "@/effect/instance-state"
import { Effect, Layer } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForDisposal } from "./lifecycle"
const root = "/config"
@@ -12,7 +13,7 @@ export const ConfigApi = HttpApi.make("config")
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("get", root, {
success: described(Config.Info, "Get config info"),
success: Config.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "config.get",
@@ -22,8 +23,7 @@ export const ConfigApi = HttpApi.make("config")
),
HttpApiEndpoint.patch("update", root, {
payload: Config.Info,
success: described(Config.Info, "Successfully updated config"),
error: HttpApiError.BadRequest,
success: Config.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "config.update",
@@ -32,7 +32,7 @@ export const ConfigApi = HttpApi.make("config")
}),
),
HttpApiEndpoint.get("providers", `${root}/providers`, {
success: described(Provider.ConfigProvidersResult, "List of providers"),
success: Provider.ConfigProvidersResult,
}).annotateMerge(
OpenApi.annotations({
identifier: "config.providers",
@@ -47,7 +47,6 @@ export const ConfigApi = HttpApi.make("config")
description: "Experimental HttpApi config routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -57,3 +56,32 @@ export const ConfigApi = HttpApi.make("config")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const configHandlers = Layer.unwrap(
Effect.gen(function* () {
const providerSvc = yield* Provider.Service
const configSvc = yield* Config.Service
const get = Effect.fn("ConfigHttpApi.get")(function* () {
return yield* configSvc.get()
})
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
yield* configSvc.update(ctx.payload, { dispose: false })
yield* markInstanceForDisposal(yield* InstanceState.context)
return ctx.payload
})
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
const providers = yield* providerSvc.list()
return {
providers: Object.values(providers),
default: Provider.defaultModelIDs(providers),
}
})
return HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
handlers.handle("get", get).handle("update", update).handle("providers", providers),
)
}),
).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer))
@@ -1,8 +1,7 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const AuthParams = Schema.Struct({
providerID: ProviderID,
@@ -13,7 +12,7 @@ const LogQuery = Schema.Struct({
workspace: Schema.optional(Schema.String),
})
export const LogInput = Schema.Struct({
const LogInput = Schema.Struct({
service: Schema.String.annotate({ description: "Service name for the log entry" }),
level: Schema.Union([
Schema.Literal("debug"),
@@ -25,7 +24,7 @@ export const LogInput = Schema.Struct({
extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({
description: "Additional metadata for the log entry",
}),
})
}).annotate({ identifier: "AppLogInput" })
export const ControlPaths = {
auth: "/auth/:providerID",
@@ -38,8 +37,7 @@ export const ControlApi = HttpApi.make("control").add(
HttpApiEndpoint.put("authSet", ControlPaths.auth, {
params: AuthParams,
payload: Auth.Info,
success: described(Schema.Boolean, "Successfully set authentication credentials"),
error: HttpApiError.BadRequest,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "auth.set",
@@ -49,8 +47,7 @@ export const ControlApi = HttpApi.make("control").add(
),
HttpApiEndpoint.delete("authRemove", ControlPaths.auth, {
params: AuthParams,
success: described(Schema.Boolean, "Successfully removed authentication credentials"),
error: HttpApiError.BadRequest,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "auth.remove",
@@ -61,8 +58,7 @@ export const ControlApi = HttpApi.make("control").add(
HttpApiEndpoint.post("log", ControlPaths.log, {
query: LogQuery,
payload: LogInput,
success: described(Schema.Boolean, "Log entry written successfully"),
error: HttpApiError.BadRequest,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "app.log",
@@ -4,7 +4,6 @@ import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
const log = Log.create({ service: "server" })
@@ -28,13 +27,8 @@ export const EventApi = HttpApi.make("event").add(
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
)
function eventData(data: unknown): Sse.Event {
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
}
function eventData(data: unknown) {
return `data: ${JSON.stringify(data)}\n\n`
}
export const eventRoute = HttpRouter.add(
@@ -53,7 +47,6 @@ export const eventRoute = HttpRouter.add(
Stream.make({ type: "server.connected", properties: {} }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
),
@@ -1,19 +1,24 @@
import { Account } from "@/account/account"
import { AccountID, OrgID } from "@/account/schema"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { MCP } from "@/mcp"
import { Project } from "@/project/project"
import { ProviderID, ModelID } from "@/provider/schema"
import { Session } from "@/session/session"
import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@/util/effect-zod"
import { Worktree } from "@/worktree"
import { NonNegativeInt } from "@/util/schema"
import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
import { Effect, Layer, Option, Schema, SchemaGetter } from "effect"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optionalKey(Schema.String),
switchableOrgCount: NonNegativeInt,
switchableOrgCount: Schema.Number,
}).annotate({ identifier: "ConsoleState" })
const ConsoleOrgOption = Schema.Struct({
@@ -23,25 +28,25 @@ const ConsoleOrgOption = Schema.Struct({
orgID: Schema.String,
orgName: Schema.String,
active: Schema.Boolean,
})
}).annotate({ identifier: "ConsoleOrgOption" })
const ConsoleOrgList = Schema.Struct({
orgs: Schema.Array(ConsoleOrgOption),
})
}).annotate({ identifier: "ConsoleOrgList" })
export const ConsoleSwitchPayload = Schema.Struct({
const ConsoleSwitchPayload = Schema.Struct({
accountID: AccountID,
orgID: OrgID,
})
}).annotate({ identifier: "ConsoleSwitchInput" })
const ToolIDs = Schema.Array(Schema.String).annotate({ identifier: "ToolIDs" })
const ToolListItem = Schema.Struct({
id: Schema.String,
description: Schema.String,
parameters: Schema.Unknown,
parameters: Schema.Record(Schema.String, Schema.Any),
}).annotate({ identifier: "ToolListItem" })
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
export const ToolListQuery = Schema.Struct({
const ToolListQuery = Schema.Struct({
provider: ProviderID,
model: ModelID,
})
@@ -52,8 +57,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
const WorktreeList = Schema.Array(Schema.String)
export const SessionListQuery = Schema.Struct({
const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
const SessionListQuery = Schema.Struct({
directory: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
@@ -80,7 +85,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
HttpApiGroup.make("experimental")
.add(
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
success: described(ConsoleStateResponse, "Active Console provider metadata"),
success: ConsoleStateResponse,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.console.get",
@@ -89,7 +94,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
success: described(ConsoleOrgList, "Switchable Console orgs"),
success: ConsoleOrgList,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.console.listOrgs",
@@ -99,7 +104,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
payload: ConsoleSwitchPayload,
success: described(Schema.Boolean, "Switch success"),
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
@@ -110,8 +115,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
query: ToolListQuery,
success: described(ToolList, "Tools"),
error: HttpApiError.BadRequest,
success: ToolList,
}).annotateMerge(
OpenApi.annotations({
identifier: "tool.list",
@@ -121,8 +125,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
success: described(ToolIDs, "Tool IDs"),
error: HttpApiError.BadRequest,
success: ToolIDs,
}).annotateMerge(
OpenApi.annotations({
identifier: "tool.ids",
@@ -132,7 +135,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
success: described(WorktreeList, "List of worktree directories"),
success: WorktreeList,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.list",
@@ -142,8 +145,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
payload: Schema.optional(Worktree.CreateInput),
success: described(Worktree.Info, "Worktree created"),
error: HttpApiError.BadRequest,
success: Worktree.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.create",
@@ -153,8 +155,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
payload: Worktree.RemoveInput,
success: described(Schema.Boolean, "Worktree removed"),
error: HttpApiError.BadRequest,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.remove",
@@ -164,8 +165,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
payload: Worktree.ResetInput,
success: described(Schema.Boolean, "Worktree reset"),
error: HttpApiError.BadRequest,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.reset",
@@ -175,7 +175,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.get("session", ExperimentalPaths.session, {
query: SessionListQuery,
success: described(Schema.Array(Session.GlobalInfo), "List of sessions"),
success: Schema.Array(Session.GlobalInfo),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.session.list",
@@ -185,7 +185,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
success: Schema.Record(Schema.String, MCP.Resource),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.resource.list",
@@ -200,7 +200,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
description: "Experimental HttpApi read-only routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -210,3 +209,153 @@ export const ExperimentalApi = HttpApi.make("experimental")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const experimentalHandlers = Layer.unwrap(
Effect.gen(function* () {
const account = yield* Account.Service
const agents = yield* Agent.Service
const config = yield* Config.Service
const mcp = yield* MCP.Service
const project = yield* Project.Service
const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
return {
consoleManagedProviders: state.consoleManagedProviders,
...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}),
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
})
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
const [groups, active] = yield* Effect.all(
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
const info = Option.getOrUndefined(active)
return {
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,
})),
),
}
})
const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: {
payload: typeof ConsoleSwitchPayload.Type
}) {
yield* account
.use(ctx.payload.accountID, Option.some(ctx.payload.orgID))
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) {
const list = yield* registry.tools({
providerID: ctx.query.provider,
modelID: ctx.query.model,
agent: yield* agents.get(yield* agents.defaultAgent()),
})
return list.map((item) => ({
id: item.id,
description: item.description,
parameters: EffectZod.toJsonSchema(item.parameters),
}))
})
const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () {
return yield* registry.ids()
})
const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () {
const ctx = yield* InstanceState.context
return yield* project.sandboxes(ctx.project.id)
})
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
payload: Worktree.CreateInput | undefined
}) {
return yield* worktreeSvc.create(ctx.payload)
})
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
payload: Worktree.RemoveInput
}) {
const ctx = yield* InstanceState.context
yield* worktreeSvc.remove(input.payload)
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
return true
})
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
payload: Worktree.ResetInput
}) {
yield* worktreeSvc.reset(ctx.payload)
return true
})
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
const limit = ctx.query.limit ?? 100
const sessions = Array.from(
Session.listGlobal({
directory: ctx.query.directory,
roots: ctx.query.roots,
start: ctx.query.start,
cursor: ctx.query.cursor,
search: ctx.query.search,
limit: limit + 1,
archived: ctx.query.archived,
}),
)
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
return HttpServerResponse.jsonUnsafe(list, {
headers:
sessions.length > limit && list.length > 0
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
: undefined,
})
})
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
return yield* mcp.resources()
})
return HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
handlers
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)
.handle("tool", tool)
.handle("toolIDs", toolIDs)
.handle("worktree", worktree)
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
.handle("session", session)
.handle("resource", resource),
)
}),
).pipe(
Layer.provide(Account.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(MCP.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(ToolRegistry.defaultLayer),
Layer.provide(Worktree.defaultLayer),
)
@@ -1,21 +1,20 @@
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import * as InstanceState from "@/effect/instance-state"
import { LSP } from "@/lsp/lsp"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
export const FileQuery = Schema.Struct({
const FileQuery = Schema.Struct({
path: Schema.String,
})
export const FindTextQuery = Schema.Struct({
const FindTextQuery = Schema.Struct({
pattern: Schema.String,
})
export const FindFileQuery = Schema.Struct({
const FindFileQuery = Schema.Struct({
query: Schema.String,
dirs: Schema.optional(Schema.Literals(["true", "false"])),
type: Schema.optional(Schema.Literals(["file", "directory"])),
@@ -24,7 +23,7 @@ export const FindFileQuery = Schema.Struct({
),
})
export const FindSymbolQuery = Schema.Struct({
const FindSymbolQuery = Schema.Struct({
query: Schema.String,
})
@@ -43,7 +42,7 @@ export const FileApi = HttpApi.make("file")
.add(
HttpApiEndpoint.get("findText", FilePaths.findText, {
query: FindTextQuery,
success: described(Schema.Array(Ripgrep.SearchMatch), "Matches"),
success: Schema.Array(Ripgrep.SearchMatch),
}).annotateMerge(
OpenApi.annotations({
identifier: "find.text",
@@ -53,7 +52,7 @@ export const FileApi = HttpApi.make("file")
),
HttpApiEndpoint.get("findFile", FilePaths.findFile, {
query: FindFileQuery,
success: described(Schema.Array(Schema.String), "File paths"),
success: Schema.Array(Schema.String),
}).annotateMerge(
OpenApi.annotations({
identifier: "find.files",
@@ -63,7 +62,7 @@ export const FileApi = HttpApi.make("file")
),
HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, {
query: FindSymbolQuery,
success: described(Schema.Array(LSP.Symbol), "Symbols"),
success: Schema.Array(LSP.Symbol),
}).annotateMerge(
OpenApi.annotations({
identifier: "find.symbols",
@@ -73,7 +72,7 @@ export const FileApi = HttpApi.make("file")
),
HttpApiEndpoint.get("list", FilePaths.list, {
query: FileQuery,
success: described(Schema.Array(File.Node), "Files and directories"),
success: Schema.Array(File.Node),
}).annotateMerge(
OpenApi.annotations({
identifier: "file.list",
@@ -83,7 +82,7 @@ export const FileApi = HttpApi.make("file")
),
HttpApiEndpoint.get("content", FilePaths.content, {
query: FileQuery,
success: described(File.Content, "File content"),
success: File.Content,
}).annotateMerge(
OpenApi.annotations({
identifier: "file.read",
@@ -92,7 +91,7 @@ export const FileApi = HttpApi.make("file")
}),
),
HttpApiEndpoint.get("status", FilePaths.status, {
success: described(Schema.Array(File.Info), "File status"),
success: Schema.Array(File.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "file.status",
@@ -107,7 +106,6 @@ export const FileApi = HttpApi.make("file")
description: "Experimental HttpApi file routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -117,3 +115,53 @@ export const FileApi = HttpApi.make("file")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const fileHandlers = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* File.Service
const ripgrep = yield* Ripgrep.Service
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
return (yield* ripgrep
.search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
.pipe(Effect.orDie)).items
})
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
}) {
return yield* svc.search({
query: ctx.query.query,
limit: ctx.query.limit ?? 10,
dirs: ctx.query.dirs !== "false",
type: ctx.query.type,
})
})
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
return []
})
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
return yield* svc.list(ctx.query.path)
})
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
return yield* svc.read(ctx.query.path)
})
const status = Effect.fn("FileHttpApi.status")(function* () {
return yield* svc.status()
})
return HttpApiBuilder.group(FileApi, "file", (handlers) =>
handlers
.handle("findText", findText)
.handle("findFile", findFile)
.handle("findSymbol", findSymbol)
.handle("list", list)
.handle("content", content)
.handle("status", status),
)
}),
).pipe(Layer.provide(File.defaultLayer), Layer.provide(Ripgrep.defaultLayer))
@@ -1,25 +1,22 @@
import { Config } from "@/config/config"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const GlobalHealth = Schema.Struct({
healthy: Schema.Literal(true),
version: Schema.String,
})
}).annotate({ identifier: "GlobalHealth" })
const GlobalEventSchema = Schema.Struct({
const GlobalEvent = Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
payload: Schema.Union([...BusEvent.effectPayloads(), ...SyncEvent.effectPayloads()]),
payload: Schema.Unknown,
}).annotate({ identifier: "GlobalEvent" })
export const GlobalUpgradeInput = Schema.Struct({
const GlobalUpgradeInput = Schema.Struct({
target: Schema.optional(Schema.String),
})
}).annotate({ identifier: "GlobalUpgradeInput" })
const GlobalUpgradeResult = Schema.Union([
Schema.Struct({
@@ -30,7 +27,7 @@ const GlobalUpgradeResult = Schema.Union([
success: Schema.Literal(false),
error: Schema.String,
}),
])
]).annotate({ identifier: "GlobalUpgradeResult" })
export const GlobalPaths = {
health: "/global/health",
@@ -44,7 +41,7 @@ export const GlobalApi = HttpApi.make("global").add(
HttpApiGroup.make("global")
.add(
HttpApiEndpoint.get("health", GlobalPaths.health, {
success: described(GlobalHealth, "Health information"),
success: GlobalHealth,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.health",
@@ -53,7 +50,7 @@ export const GlobalApi = HttpApi.make("global").add(
}),
),
HttpApiEndpoint.get("event", GlobalPaths.event, {
success: GlobalEventSchema,
success: GlobalEvent,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.event",
@@ -62,7 +59,7 @@ export const GlobalApi = HttpApi.make("global").add(
}),
),
HttpApiEndpoint.get("configGet", GlobalPaths.config, {
success: described(Config.Info, "Get global config info"),
success: Config.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.config.get",
@@ -72,8 +69,7 @@ export const GlobalApi = HttpApi.make("global").add(
),
HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, {
payload: Config.Info,
success: described(Config.Info, "Successfully updated global config"),
error: HttpApiError.BadRequest,
success: Config.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.config.update",
@@ -82,7 +78,7 @@ export const GlobalApi = HttpApi.make("global").add(
}),
),
HttpApiEndpoint.post("dispose", GlobalPaths.dispose, {
success: described(Schema.Boolean, "Global disposed"),
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.dispose",
@@ -92,8 +88,7 @@ export const GlobalApi = HttpApi.make("global").add(
),
HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
payload: GlobalUpgradeInput,
success: described(GlobalUpgradeResult, "Upgrade result"),
error: HttpApiError.BadRequest,
success: GlobalUpgradeResult,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.upgrade",
@@ -1,18 +0,0 @@
import { Schema } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
export function described<S extends Schema.Top>(schema: S, description: string): S {
return schema.annotate({ description }) as S
}
export function responseDescription(description: string) {
return OpenApi.annotations({
transform: (operation) => {
const response = operation.responses?.["200"]
if (response && typeof response === "object" && "description" in response) {
response.description = description
}
return operation
},
})
}
@@ -1,75 +0,0 @@
import { Project } from "@/project/project"
import { ProjectID } from "@/project/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/project"
const UpdatePayload = Schema.Struct({
name: Schema.optional(Schema.String),
icon: Schema.optional(Project.Info.fields.icon),
commands: Schema.optional(Project.Info.fields.commands),
})
export const ProjectApi = HttpApi.make("project")
.add(
HttpApiGroup.make("project")
.add(
HttpApiEndpoint.get("list", root, {
success: described(Schema.Array(Project.Info), "List of projects"),
}).annotateMerge(
OpenApi.annotations({
identifier: "project.list",
summary: "List all projects",
description: "Get a list of projects that have been opened with OpenCode.",
}),
),
HttpApiEndpoint.get("current", `${root}/current`, {
success: described(Project.Info, "Current project information"),
}).annotateMerge(
OpenApi.annotations({
identifier: "project.current",
summary: "Get current project",
description: "Retrieve the currently active project that OpenCode is working with.",
}),
),
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
success: described(Project.Info, "Project information after git initialization"),
}).annotateMerge(
OpenApi.annotations({
identifier: "project.initGit",
summary: "Initialize git repository",
description: "Create a git repository for the current project and return the refreshed project info.",
}),
),
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
params: { projectID: ProjectID },
payload: UpdatePayload,
success: described(Project.Info, "Updated project information"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "project.update",
summary: "Update project",
description: "Update project properties such as name, icon, and commands.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "project",
description: "Experimental HttpApi project routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -1,74 +0,0 @@
import { ProviderAuth } from "@/provider/auth"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/provider"
export const ProviderApi = HttpApi.make("provider")
.add(
HttpApiGroup.make("provider")
.add(
HttpApiEndpoint.get("list", root, {
success: described(Provider.ListResult, "List of providers"),
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.list",
summary: "List providers",
description: "Get a list of all available AI providers, including both available and connected ones.",
}),
),
HttpApiEndpoint.get("auth", `${root}/auth`, {
success: described(ProviderAuth.Methods, "Provider auth methods"),
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.auth",
summary: "Get provider auth methods",
description: "Retrieve available authentication methods for all AI providers.",
}),
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
payload: ProviderAuth.AuthorizeInput,
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.authorize",
summary: "Start OAuth authorization",
description: "Start the OAuth authorization flow for a provider.",
}),
),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID },
payload: ProviderAuth.CallbackInput,
success: described(Schema.Boolean, "OAuth callback processed successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.callback",
summary: "Handle OAuth callback",
description: "Handle the OAuth callback from a provider after user authorization.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "provider",
description: "Experimental HttpApi provider routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -1,121 +0,0 @@
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/pty"
export const Params = Schema.Struct({ ptyID: PtyID })
export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) })
export const ShellItem = Schema.Struct({
path: Schema.String,
name: Schema.String,
acceptable: Schema.Boolean,
})
export const PtyPaths = {
shells: `${root}/shells`,
list: root,
create: root,
get: `${root}/:ptyID`,
update: `${root}/:ptyID`,
remove: `${root}/:ptyID`,
connect: `${root}/:ptyID/connect`,
} as const
export const PtyApi = HttpApi.make("pty")
.add(
HttpApiGroup.make("pty")
.add(
HttpApiEndpoint.get("shells", PtyPaths.shells, { success: described(Schema.Array(ShellItem), "List of shells") }).annotateMerge(
OpenApi.annotations({
identifier: "pty.shells",
summary: "List available shells",
description: "Get a list of available shells on the system.",
}),
),
HttpApiEndpoint.get("list", PtyPaths.list, { success: described(Schema.Array(Pty.Info), "List of sessions") }).annotateMerge(
OpenApi.annotations({
identifier: "pty.list",
summary: "List PTY sessions",
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
}),
),
HttpApiEndpoint.post("create", PtyPaths.create, {
payload: Pty.CreateInput,
success: described(Pty.Info, "Created session"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.create",
summary: "Create PTY session",
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
}),
),
HttpApiEndpoint.get("get", PtyPaths.get, {
params: { ptyID: PtyID },
success: described(Pty.Info, "Session info"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.get",
summary: "Get PTY session",
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.put("update", PtyPaths.update, {
params: { ptyID: PtyID },
payload: Pty.UpdateInput,
success: described(Pty.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.update",
summary: "Update PTY session",
description: "Update properties of an existing pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
params: { ptyID: PtyID },
success: described(Schema.Boolean, "Session removed"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.remove",
summary: "Remove PTY session",
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." }))
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const PtyConnectApi = HttpApi.make("pty-connect").add(
HttpApiGroup.make("pty-connect")
.add(
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
success: described(Schema.Boolean, "Connected session"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connect",
summary: "Connect to PTY session",
description:
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
)
@@ -1,428 +0,0 @@
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { SessionRevert } from "@/session/revert"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import { NonNegativeInt } from "@/util/schema"
import { Schema, SchemaGetter, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/session"
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
export const ListQuery = Schema.Struct({
directory: Schema.optional(Schema.String),
scope: Schema.optional(Schema.Literals(["project"])),
path: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
search: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
})
export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
export const MessagesQuery = Schema.Struct({
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
before: Schema.optional(Schema.String),
})
export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
export const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String),
permission: Schema.optional(Permission.Ruleset),
time: Schema.optional(
Schema.Struct({
archived: Schema.optional(NonNegativeInt),
}),
),
})
export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"]))
export const InitPayload = Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
messageID: MessageID,
})
export const SummarizePayload = Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
auto: Schema.optional(Schema.Boolean),
})
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"]))
export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))
export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"]))
export const PermissionResponsePayload = Schema.Struct({
response: Permission.Reply,
})
export const SessionPaths = {
list: root,
status: `${root}/status`,
get: `${root}/:sessionID`,
children: `${root}/:sessionID/children`,
todo: `${root}/:sessionID/todo`,
diff: `${root}/:sessionID/diff`,
messages: `${root}/:sessionID/message`,
message: `${root}/:sessionID/message/:messageID`,
create: root,
remove: `${root}/:sessionID`,
update: `${root}/:sessionID`,
fork: `${root}/:sessionID/fork`,
abort: `${root}/:sessionID/abort`,
share: `${root}/:sessionID/share`,
init: `${root}/:sessionID/init`,
summarize: `${root}/:sessionID/summarize`,
prompt: `${root}/:sessionID/message`,
promptAsync: `${root}/:sessionID/prompt_async`,
command: `${root}/:sessionID/command`,
shell: `${root}/:sessionID/shell`,
revert: `${root}/:sessionID/revert`,
unrevert: `${root}/:sessionID/unrevert`,
permissions: `${root}/:sessionID/permissions/:permissionID`,
deleteMessage: `${root}/:sessionID/message/:messageID`,
deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,
updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,
} as const
export const SessionApi = HttpApi.make("session")
.add(
HttpApiGroup.make("session")
.add(
HttpApiEndpoint.get("list", SessionPaths.list, {
query: ListQuery,
success: described(Schema.Array(Session.Info), "List of sessions"),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.list",
summary: "List sessions",
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
}),
),
HttpApiEndpoint.get("status", SessionPaths.status, {
success: described(StatusMap, "Get session status"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.status",
summary: "Get session status",
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
}),
),
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
success: described(Session.Info, "Get session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.get",
summary: "Get session",
description: "Retrieve detailed information about a specific OpenCode session.",
}),
),
HttpApiEndpoint.get("children", SessionPaths.children, {
params: { sessionID: SessionID },
success: described(Schema.Array(Session.Info), "List of children"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.children",
summary: "Get session children",
description: "Retrieve all child sessions that were forked from the specified parent session.",
}),
),
HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID },
success: described(Schema.Array(Todo.Info), "Todo list"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.todo",
summary: "Get session todos",
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
}),
),
HttpApiEndpoint.get("diff", SessionPaths.diff, {
params: { sessionID: SessionID },
query: DiffQuery,
success: described(Schema.Array(Snapshot.FileDiff), "Successfully retrieved diff"),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.diff",
summary: "Get message diff",
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
}),
),
HttpApiEndpoint.get("messages", SessionPaths.messages, {
params: { sessionID: SessionID },
query: MessagesQuery,
success: described(Schema.Array(MessageV2.WithParts), "List of messages"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.messages",
summary: "Get session messages",
description: "Retrieve all messages in a session, including user prompts and AI responses.",
}),
),
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
success: described(MessageV2.WithParts, "Message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.message",
summary: "Get message",
description: "Retrieve a specific message from a session by its message ID.",
}),
),
HttpApiEndpoint.post("create", SessionPaths.create, {
payload: [HttpApiSchema.NoContent, Session.CreateInput],
success: described(Session.Info, "Successfully created session"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.create",
summary: "Create session",
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
}),
),
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
success: described(Schema.Boolean, "Successfully deleted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.delete",
summary: "Delete session",
description: "Delete a session and permanently remove all associated data, including messages and history.",
}),
),
HttpApiEndpoint.patch("update", SessionPaths.update, {
params: { sessionID: SessionID },
payload: UpdatePayload,
success: described(Session.Info, "Successfully updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.update",
summary: "Update session",
description: "Update properties of an existing session, such as title or other metadata.",
}),
),
HttpApiEndpoint.post("fork", SessionPaths.fork, {
params: { sessionID: SessionID },
payload: ForkPayload,
success: described(Session.Info, "200"),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
summary: "Fork session",
description: "Create a new session by forking an existing session at a specific message point.",
}),
),
HttpApiEndpoint.post("abort", SessionPaths.abort, {
params: { sessionID: SessionID },
success: described(Schema.Boolean, "Aborted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.abort",
summary: "Abort session",
description: "Abort an active session and stop any ongoing AI processing or command execution.",
}),
),
HttpApiEndpoint.post("init", SessionPaths.init, {
params: { sessionID: SessionID },
payload: InitPayload,
success: described(Schema.Boolean, "200"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.init",
summary: "Initialize session",
description:
"Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
}),
),
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully shared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.share",
summary: "Share session",
description: "Create a shareable link for a session, allowing others to view the conversation.",
}),
),
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
success: described(Session.Info, "Successfully unshared session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unshare",
summary: "Unshare session",
description: "Remove the shareable link for a session, making it private again.",
}),
),
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
params: { sessionID: SessionID },
payload: SummarizePayload,
success: described(Schema.Boolean, "Summarized session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.summarize",
summary: "Summarize session",
description: "Generate a concise summary of the session using AI compaction to preserve key information.",
}),
),
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
params: { sessionID: SessionID },
payload: PromptPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt",
summary: "Send message",
description: "Create and send a new message to a session, streaming the AI response.",
}),
),
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
params: { sessionID: SessionID },
payload: PromptPayload,
success: described(HttpApiSchema.NoContent, "Prompt accepted"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt_async",
summary: "Send async message",
description:
"Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
}),
),
HttpApiEndpoint.post("command", SessionPaths.command, {
params: { sessionID: SessionID },
payload: CommandPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.command",
summary: "Send command",
description: "Send a new command to a session for execution by the AI assistant.",
}),
),
HttpApiEndpoint.post("shell", SessionPaths.shell, {
params: { sessionID: SessionID },
payload: ShellPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.shell",
summary: "Run shell command",
description: "Execute a shell command within the session context and return the AI's response.",
}),
),
HttpApiEndpoint.post("revert", SessionPaths.revert, {
params: { sessionID: SessionID },
payload: RevertPayload,
success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.revert",
summary: "Revert message",
description:
"Revert a specific message in a session, undoing its effects and restoring the previous state.",
}),
),
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
params: { sessionID: SessionID },
success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unrevert",
summary: "Restore reverted messages",
description: "Restore all previously reverted messages in a session.",
}),
),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID },
payload: PermissionResponsePayload,
success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.respond",
summary: "Respond to permission",
description: "Approve or deny a permission request from the AI assistant.",
deprecated: true,
}),
),
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID },
success: described(Schema.Boolean, "Successfully deleted message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.deleteMessage",
summary: "Delete message",
description:
"Permanently delete a specific message and all of its parts from a session without reverting file changes.",
}),
),
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
success: described(Schema.Boolean, "Successfully deleted part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "part.delete",
description: "Delete a part from a message.",
}),
),
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
payload: MessageV2.Part,
success: described(MessageV2.Part, "Successfully updated part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "part.update",
description: "Update a part in a message.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
description: "Experimental HttpApi session routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -1,90 +0,0 @@
import { NonNegativeInt } from "@/util/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/sync"
export const ReplayEvent = Schema.Struct({
id: Schema.String,
aggregateID: Schema.String,
seq: NonNegativeInt,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
})
export const ReplayPayload = Schema.Struct({
directory: Schema.String,
events: Schema.NonEmptyArray(ReplayEvent),
})
export const ReplayResponse = Schema.Struct({
sessionID: Schema.String,
})
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
export const HistoryEvent = Schema.Struct({
id: Schema.String,
aggregate_id: Schema.String,
seq: NonNegativeInt,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
})
export const SyncPaths = {
start: `${root}/start`,
replay: `${root}/replay`,
history: `${root}/history`,
} as const
export const SyncApi = HttpApi.make("sync")
.add(
HttpApiGroup.make("sync")
.add(
HttpApiEndpoint.post("start", SyncPaths.start, {
success: described(Schema.Boolean, "Workspace sync started"),
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.start",
summary: "Start workspace sync",
description: "Start sync loops for workspaces in the current project that have active sessions.",
}),
),
HttpApiEndpoint.post("replay", SyncPaths.replay, {
payload: ReplayPayload,
success: described(ReplayResponse, "Replayed sync events"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.replay",
summary: "Replay sync events",
description: "Validate and replay a complete sync event history.",
}),
),
HttpApiEndpoint.post("history", SyncPaths.history, {
payload: HistoryPayload,
success: described(Schema.Array(HistoryEvent), "Sync events"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.history.list",
summary: "List sync events",
description:
"List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "sync",
description: "Experimental HttpApi sync routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -1,164 +0,0 @@
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/tui"
export const CommandPayload = Schema.Struct({ command: Schema.String })
export const TuiRequestPayload = Schema.Struct({
path: Schema.String,
body: Schema.Unknown,
})
const EventTuiPromptAppend = Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }).annotate({ identifier: "EventTuiPromptAppend" })
const EventTuiCommandExecute = Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }).annotate({ identifier: "EventTuiCommandExecute" })
const EventTuiToastShow = Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }).annotate({ identifier: "EventTuiToastShow" })
const EventTuiSessionSelect = Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }).annotate({ identifier: "EventTuiSessionSelect" })
export const TuiPublishPayload = Schema.Union([EventTuiPromptAppend, EventTuiCommandExecute, EventTuiToastShow, EventTuiSessionSelect])
export const TuiPaths = {
appendPrompt: `${root}/append-prompt`,
openHelp: `${root}/open-help`,
openSessions: `${root}/open-sessions`,
openThemes: `${root}/open-themes`,
openModels: `${root}/open-models`,
submitPrompt: `${root}/submit-prompt`,
clearPrompt: `${root}/clear-prompt`,
executeCommand: `${root}/execute-command`,
showToast: `${root}/show-toast`,
publish: `${root}/publish`,
selectSession: `${root}/select-session`,
controlNext: `${root}/control/next`,
controlResponse: `${root}/control/response`,
} as const
export const TuiApi = HttpApi.make("tui")
.add(
HttpApiGroup.make("tui")
.add(
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
payload: TuiEvent.PromptAppend.properties,
success: described(Schema.Boolean, "Prompt processed successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.appendPrompt",
summary: "Append TUI prompt",
description: "Append prompt to the TUI.",
}),
),
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: described(Schema.Boolean, "Help dialog opened successfully") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openHelp",
summary: "Open help dialog",
description: "Open the help dialog in the TUI to display user assistance information.",
}),
),
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: described(Schema.Boolean, "Session dialog opened successfully") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openSessions",
summary: "Open sessions dialog",
description: "Open the session dialog.",
}),
),
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: described(Schema.Boolean, "Theme dialog opened successfully") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openThemes",
summary: "Open themes dialog",
description: "Open the theme dialog.",
}),
),
HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: described(Schema.Boolean, "Model dialog opened successfully") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openModels",
summary: "Open models dialog",
description: "Open the model dialog.",
}),
),
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: described(Schema.Boolean, "Prompt submitted successfully") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.submitPrompt",
summary: "Submit TUI prompt",
description: "Submit the prompt.",
}),
),
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: described(Schema.Boolean, "Prompt cleared successfully") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.clearPrompt",
summary: "Clear TUI prompt",
description: "Clear the prompt.",
}),
),
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
payload: CommandPayload,
success: described(Schema.Boolean, "Command executed successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.executeCommand",
summary: "Execute TUI command",
description: "Execute a TUI command.",
}),
),
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
payload: TuiEvent.ToastShow.properties,
success: described(Schema.Boolean, "Toast notification shown successfully"),
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.showToast",
summary: "Show TUI toast",
description: "Show a toast notification in the TUI.",
}),
),
HttpApiEndpoint.post("publish", TuiPaths.publish, {
payload: TuiPublishPayload,
success: described(Schema.Boolean, "Event published successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.publish",
summary: "Publish TUI event",
description: "Publish a TUI event.",
}),
),
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
payload: TuiEvent.SessionSelect.properties,
success: described(Schema.Boolean, "Session selected successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.selectSession",
summary: "Select session",
description: "Navigate the TUI to display the specified session.",
}),
),
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: described(TuiRequestPayload, "Next TUI request") }).annotateMerge(
OpenApi.annotations({
identifier: "tui.control.next",
summary: "Get next TUI request",
description: "Retrieve the next TUI request from the queue for processing.",
}),
),
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
payload: Schema.Unknown,
success: described(Schema.Boolean, "Response submitted successfully"),
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.control.response",
summary: "Submit TUI response",
description: "Submit a response to the TUI request queue to complete a pending request.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." }))
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -1,103 +0,0 @@
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import { NonNegativeInt } from "@/util/schema"
import { Schema, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
const root = "/experimental/workspace"
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
export const SessionRestorePayload = Schema.Struct(
Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]),
)
export const SessionRestoreResponse = Schema.Struct({
total: NonNegativeInt,
})
export const WorkspacePaths = {
adaptors: `${root}/adaptor`,
list: root,
status: `${root}/status`,
remove: `${root}/:id`,
sessionRestore: `${root}/:id/session-restore`,
} as const
export const WorkspaceApi = HttpApi.make("workspace")
.add(
HttpApiGroup.make("workspace")
.add(
HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, {
success: described(Schema.Array(WorkspaceAdaptorEntry), "Workspace adaptors"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.adaptor.list",
summary: "List workspace adaptors",
description: "List all available workspace adaptors for the current project.",
}),
),
HttpApiEndpoint.get("list", WorkspacePaths.list, {
success: described(Schema.Array(Workspace.Info), "Workspaces"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.list",
summary: "List workspaces",
description: "List all workspaces.",
}),
),
HttpApiEndpoint.post("create", WorkspacePaths.list, {
payload: CreatePayload,
success: described(Workspace.Info, "Workspace created"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.create",
summary: "Create workspace",
description: "Create a workspace for the current project.",
}),
),
HttpApiEndpoint.get("status", WorkspacePaths.status, {
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.status",
summary: "Workspace status",
description: "Get connection status for workspaces in the current project.",
}),
),
HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
params: { id: Workspace.Info.fields.id },
success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.remove",
summary: "Remove workspace",
description: "Remove an existing workspace.",
}),
),
HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, {
params: { id: Workspace.Info.fields.id },
payload: SessionRestorePayload,
success: described(SessionRestoreResponse, "Session replay started"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.sessionRestore",
summary: "Restore session into workspace",
description: "Replay a session's sync events into the target workspace in batches.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "workspace", description: "Experimental HttpApi workspace routes." }))
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -1,34 +0,0 @@
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import * as InstanceState from "@/effect/instance-state"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { markInstanceForDisposal } from "../lifecycle"
export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (handlers) =>
Effect.gen(function* () {
const providerSvc = yield* Provider.Service
const configSvc = yield* Config.Service
const get = Effect.fn("ConfigHttpApi.get")(function* () {
return yield* configSvc.get()
})
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
yield* configSvc.update(ctx.payload, { dispose: false })
yield* markInstanceForDisposal(yield* InstanceState.context)
return ctx.payload
})
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
const providers = yield* providerSvc.list()
return {
providers: Object.values(providers),
default: Provider.defaultModelIDs(providers),
}
})
return handlers.handle("get", get).handle("update", update).handle("providers", providers)
}),
)
@@ -1,34 +0,0 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { RootHttpApi } from "../api"
import { LogInput } from "../groups/control"
export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) =>
Effect.gen(function* () {
const auth = yield* Auth.Service
const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
params: { providerID: ProviderID }
payload: Auth.Info
}) {
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
return true
})
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
return true
})
const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) {
const logger = Log.create({ service: ctx.payload.service })
logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra)
return true
})
return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log)
}),
)
@@ -1,155 +0,0 @@
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { MCP } from "@/mcp"
import { Project } from "@/project/project"
import { Session } from "@/session/session"
import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@/util/effect-zod"
import { Worktree } from "@/worktree"
import { Effect, Option } from "effect"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental"
export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) =>
Effect.gen(function* () {
const account = yield* Account.Service
const agents = yield* Agent.Service
const config = yield* Config.Service
const mcp = yield* MCP.Service
const project = yield* Project.Service
const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
return {
consoleManagedProviders: state.consoleManagedProviders,
...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}),
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
})
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
const [groups, active] = yield* Effect.all(
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
const info = Option.getOrUndefined(active)
return {
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,
})),
),
}
})
const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: {
payload: typeof ConsoleSwitchPayload.Type
}) {
yield* account
.use(ctx.payload.accountID, Option.some(ctx.payload.orgID))
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) {
const list = yield* registry.tools({
providerID: ctx.query.provider,
modelID: ctx.query.model,
agent: yield* agents.get(yield* agents.defaultAgent()),
})
return list.map((item) => ({
id: item.id,
description: item.description,
parameters: EffectZod.toJsonSchema(item.parameters),
}))
})
const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () {
return yield* registry.ids()
})
const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () {
const ctx = yield* InstanceState.context
return yield* project.sandboxes(ctx.project.id)
})
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
payload: Worktree.CreateInput | undefined
}) {
return yield* worktreeSvc.create(ctx.payload)
})
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
payload: Worktree.RemoveInput
}) {
const ctx = yield* InstanceState.context
yield* worktreeSvc.remove(input.payload)
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
return true
})
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
payload: Worktree.ResetInput
}) {
yield* worktreeSvc.reset(ctx.payload)
return true
})
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
const limit = ctx.query.limit ?? 100
const sessions = Array.from(
Session.listGlobal({
directory: ctx.query.directory,
roots: ctx.query.roots,
start: ctx.query.start,
cursor: ctx.query.cursor,
search: ctx.query.search,
limit: limit + 1,
archived: ctx.query.archived,
}),
)
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
return HttpServerResponse.jsonUnsafe(list, {
headers:
sessions.length > limit && list.length > 0
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
: undefined,
})
})
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
return yield* mcp.resources()
})
return handlers
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)
.handle("tool", tool)
.handle("toolIDs", toolIDs)
.handle("worktree", worktree)
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
.handle("session", session)
.handle("resource", resource)
}),
)
@@ -1,54 +0,0 @@
import * as InstanceState from "@/effect/instance-state"
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
Effect.gen(function* () {
const svc = yield* File.Service
const ripgrep = yield* Ripgrep.Service
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
return (yield* ripgrep
.search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
.pipe(Effect.orDie)).items
})
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
}) {
return yield* svc.search({
query: ctx.query.query,
limit: ctx.query.limit ?? 10,
dirs: ctx.query.dirs !== "false",
type: ctx.query.type,
})
})
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
return []
})
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
return yield* svc.list(ctx.query.path)
})
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
return yield* svc.read(ctx.query.path)
})
const status = Effect.fn("FileHttpApi.status")(function* () {
return yield* svc.status()
})
return handlers
.handle("findText", findText)
.handle("findFile", findFile)
.handle("findSymbol", findSymbol)
.handle("list", list)
.handle("content", content)
.handle("status", status)
}),
)
@@ -1,156 +0,0 @@
import { Config } from "@/config/config"
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
import { Installation } from "@/installation"
import { Instance } from "@/project/instance"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Queue, Schema } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { RootHttpApi } from "../api"
import { GlobalUpgradeInput } from "../groups/global"
const log = Log.create({ service: "server" })
function eventData(data: unknown): Sse.Event {
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
}
}
function parseBody(body: string) {
try {
return JSON.parse(body || "{}") as unknown
} catch {
return undefined
}
}
function eventResponse() {
log.info("global event connected")
const events = Stream.callback<GlobalBusEvent>((queue) => {
const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event)
return Effect.acquireRelease(
Effect.sync(() => GlobalBus.on("event", handler)),
() => Effect.sync(() => GlobalBus.off("event", handler)),
)
})
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })),
)
return HttpServerResponse.stream(
Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("global event disconnected"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
)
}
export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handlers) =>
Effect.gen(function* () {
const config = yield* Config.Service
const installation = yield* Installation.Service
const health = Effect.fn("GlobalHttpApi.health")(function* () {
return { healthy: true as const, version: InstallationVersion }
})
const event = Effect.fn("GlobalHttpApi.event")(function* () {
return eventResponse()
})
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
return yield* config.getGlobal()
})
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
return yield* config.updateGlobal(ctx.payload)
})
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
yield* Effect.promise(() => Instance.disposeAll())
GlobalBus.emit("event", {
directory: "global",
payload: { type: "global.disposed", properties: {} },
})
return true
})
const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) {
const method = yield* installation.method()
if (method === "unknown") {
return {
status: 400,
body: { success: false as const, error: "Unknown installation method" },
}
}
const target = ctx.payload.target || (yield* installation.latest(method))
const result = yield* installation.upgrade(method, target).pipe(
Effect.as({ status: 200, body: { success: true as const, version: target } }),
Effect.catch((err) =>
Effect.succeed({
status: 500,
body: {
success: false as const,
error: err instanceof Error ? err.message : String(err),
},
}),
),
)
if (!result.body.success) return result
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: target },
},
})
return result
})
const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: {
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const json = parseBody(body)
if (json === undefined) {
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
}
const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe(
Effect.map((payload) => ({ valid: true as const, payload })),
Effect.catch(() => Effect.succeed({ valid: false as const })),
)
if (!payload.valid) {
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
}
const result = yield* upgrade({ payload: payload.payload })
return HttpServerResponse.jsonUnsafe(result.body, { status: result.status })
})
return handlers
.handle("health", health)
.handleRaw("event", event)
.handle("configGet", configGet)
.handle("configUpdate", configUpdate)
.handle("dispose", dispose)
.handleRaw("upgrade", upgradeRaw)
}),
)
@@ -1,79 +0,0 @@
import { Agent } from "@/agent/agent"
import { Command } from "@/command"
import * as InstanceState from "@/effect/instance-state"
import { Format } from "@/format"
import { Global } from "@opencode-ai/core/global"
import { LSP } from "@/lsp/lsp"
import { Vcs } from "@/project/vcs"
import { Skill } from "@/skill"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { markInstanceForDisposal } from "../lifecycle"
export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const command = yield* Command.Service
const format = yield* Format.Service
const lsp = yield* LSP.Service
const skill = yield* Skill.Service
const vcs = yield* Vcs.Service
const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () {
yield* markInstanceForDisposal(yield* InstanceState.context)
return true
})
const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
const ctx = yield* InstanceState.context
return {
home: Global.Path.home,
state: Global.Path.state,
config: Global.Path.config,
worktree: ctx.worktree,
directory: ctx.directory,
}
})
const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () {
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
return { branch, default_branch }
})
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
return yield* vcs.diff(ctx.query.mode)
})
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
return yield* command.list()
})
const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () {
return yield* agent.list()
})
const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () {
return yield* skill.all()
})
const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () {
return yield* lsp.status()
})
const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () {
return yield* format.status()
})
return handlers
.handle("dispose", dispose)
.handle("path", getPath)
.handle("vcs", getVcs)
.handle("vcsDiff", getVcsDiff)
.handle("command", getCommand)
.handle("agent", getAgent)
.handle("skill", getSkill)
.handle("lsp", getLsp)
.handle("formatter", getFormatter)
}),
)
@@ -1,68 +0,0 @@
import { MCP } from "@/mcp"
import { Effect, Schema } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp"
export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) =>
Effect.gen(function* () {
const mcp = yield* MCP.Service
const status = Effect.fn("McpHttpApi.status")(function* () {
return yield* mcp.status()
})
const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) {
const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status
return yield* Schema.decodeUnknownEffect(StatusMap)(
"status" in result ? { [ctx.payload.name]: result } : result,
).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
})
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
}
return yield* mcp.startAuth(ctx.params.name)
})
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
params: { name: string }
payload: typeof AuthCallbackPayload.Type
}) {
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
})
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
}
return yield* mcp.authenticate(ctx.params.name)
})
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
yield* mcp.removeAuth(ctx.params.name)
return { success: true as const }
})
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
yield* mcp.connect(ctx.params.name)
return true
})
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
yield* mcp.disconnect(ctx.params.name)
return true
})
return handlers
.handle("status", status)
.handle("add", add)
.handle("authStart", authStart)
.handle("authCallback", authCallback)
.handle("authAuthenticate", authAuthenticate)
.handle("authRemove", authRemove)
.handle("connect", connect)
.handle("disconnect", disconnect)
}),
)
@@ -1,29 +0,0 @@
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) =>
Effect.gen(function* () {
const svc = yield* Permission.Service
const list = Effect.fn("PermissionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
params: { requestID: PermissionID }
payload: Permission.ReplyBody
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
reply: ctx.payload.reply,
message: ctx.payload.message,
})
return true
})
return handlers.handle("list", list).handle("reply", reply)
}),
)
@@ -1,46 +0,0 @@
import { AppRuntime } from "@/effect/app-runtime"
import * as InstanceState from "@/effect/instance-state"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Project } from "@/project/project"
import { ProjectID } from "@/project/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { markInstanceForReload } from "../lifecycle"
export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
Effect.gen(function* () {
const svc = yield* Project.Service
const list = Effect.fn("ProjectHttpApi.list")(function* () {
return yield* svc.list()
})
const current = Effect.fn("ProjectHttpApi.current")(function* () {
return (yield* InstanceState.context).project
})
const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () {
const ctx = yield* InstanceState.context
const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project })
if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree)
return next
yield* markInstanceForReload(ctx, {
directory: ctx.directory,
worktree: ctx.directory,
project: next,
init: () => AppRuntime.runPromise(InstanceBootstrap),
})
return next
})
const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: {
params: { projectID: ProjectID }
payload: Project.UpdatePayload
}) {
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
})
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
}),
)
@@ -1,89 +0,0 @@
import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider", (handlers) =>
Effect.gen(function* () {
const cfg = yield* Config.Service
const provider = yield* Provider.Service
const svc = yield* ProviderAuth.Service
const list = Effect.fn("ProviderHttpApi.list")(function* () {
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
for (const [key, value] of Object.entries(all)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value
}
const connected = yield* provider.list()
const providers = Object.assign(
mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)),
connected,
)
return {
all: Object.values(providers),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
})
const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
return yield* svc.methods()
})
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.AuthorizeInput
}) {
return yield* svc
.authorize({
providerID: ctx.params.providerID,
method: ctx.payload.method,
inputs: ctx.payload.inputs,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
})
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
params: { providerID: ProviderID }
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
const result = yield* authorize({ params: ctx.params, payload })
if (result === undefined) return HttpServerResponse.empty({ status: 200 })
return HttpServerResponse.jsonUnsafe(result)
})
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.CallbackInput
}) {
yield* svc
.callback({
providerID: ctx.params.providerID,
method: ctx.payload.method,
code: ctx.payload.code,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
return handlers
.handle("list", list)
.handle("auth", auth)
.handleRaw("authorize", authorizeRaw)
.handle("callback", callback)
}),
)
@@ -1,118 +0,0 @@
import { EffectBridge } from "@/effect/bridge"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Shell } from "@/shell/shell"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list())
})
const list = Effect.fn("PtyHttpApi.list")(function* () {
return yield* pty.list()
})
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
const bridge = yield* EffectBridge.make()
return yield* Effect.promise(() =>
bridge.promise(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
),
)
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
const info = yield* pty.get(ctx.params.ptyID)
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
params: { ptyID: PtyID }
payload: typeof Pty.UpdateInput.Type
}) {
const info = yield* pty.update(ctx.params.ptyID, {
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
})
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
yield* pty.remove(ctx.params.ptyID)
return true
})
return handlers
.handle("shells", shells)
.handle("list", list)
.handle("create", create)
.handle("get", get)
.handle("update", update)
.handle("remove", remove)
}),
)
export const ptyConnectRoute = HttpRouter.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const pty = yield* Pty.Service
const params = yield* HttpRouter.schemaPathParams(Params)
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
Effect.runFork(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)))
},
close: (code?: number, reason?: string) => {
if (closed) return
closed = true
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => {
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
})
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
closed = true
handler.onClose()
}),
),
Effect.orDie,
)
return HttpServerResponse.empty()
}).pipe(Effect.provide(Pty.defaultLayer)),
)
@@ -1,33 +0,0 @@
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question", (handlers) =>
Effect.gen(function* () {
const svc = yield* Question.Service
const list = Effect.fn("QuestionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
params: { requestID: QuestionID }
payload: Question.Reply
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
answers: ctx.payload.answers,
})
return true
})
const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) {
yield* svc.reject(ctx.params.requestID)
return true
})
return handlers.handle("list", list).handle("reply", reply).handle("reject", reject)
}),
)
@@ -1,551 +0,0 @@
import * as InstanceState from "@/effect/instance-state"
import { AppRuntime } from "@/effect/app-runtime"
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { Command } from "@/command"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Instance } from "@/project/instance"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
import { MessageV2 } from "@/session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { NotFoundError } from "@/storage/storage"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CommandPayload, DiffQuery, ForkPayload, InitPayload, ListQuery, MessagesQuery, PermissionResponsePayload, PromptPayload, RevertPayload, ShellPayload, SummarizePayload, UpdatePayload } from "../groups/session"
const log = Log.create({ service: "server" })
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
Effect.catchDefect((error) =>
NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error),
),
)
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const statusSvc = yield* SessionStatus.Service
const todoSvc = yield* Todo.Service
const summary = yield* SessionSummary.Service
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
const instance = yield* InstanceState.context
return Instance.restore(instance, () =>
Array.from(
Session.list({
directory: ctx.query.directory,
scope: ctx.query.scope,
path: ctx.query.path,
roots: ctx.query.roots,
start: ctx.query.start,
search: ctx.query.search,
limit: ctx.query.limit,
}),
),
)
})
const status = Effect.fn("SessionHttpApi.status")(function* () {
return Object.fromEntries(yield* statusSvc.list())
})
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* mapNotFound(session.get(ctx.params.sessionID))
})
const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* session.children(ctx.params.sessionID)
})
const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* todoSvc.get(ctx.params.sessionID)
})
const diff = Effect.fn("SessionHttpApi.diff")(function* (ctx: {
params: { sessionID: SessionID }
query: typeof DiffQuery.Type
}) {
return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID })
})
const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: {
params: { sessionID: SessionID }
query: typeof MessagesQuery.Type
}) {
return yield* mapNotFound(Effect.gen(function* () {
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
if (ctx.query.before) {
const before = ctx.query.before
yield* Effect.try({
try: () => MessageV2.cursor.decode(before),
catch: () => new HttpApiError.BadRequest({}),
})
}
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
yield* session.get(ctx.params.sessionID)
return yield* session.messages({ sessionID: ctx.params.sessionID })
}
yield* session.get(ctx.params.sessionID)
const page = MessageV2.page({
sessionID: ctx.params.sessionID,
limit: ctx.query.limit,
before: ctx.query.before,
})
if (!page.cursor) return page.items
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
url.searchParams.set("limit", ctx.query.limit.toString())
url.searchParams.set("before", page.cursor)
return HttpServerResponse.jsonUnsafe(page.items, {
headers: {
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
Link: `<${url.toString()}>; rel="next"`,
"X-Next-Cursor": page.cursor,
},
})
}))
})
const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
}) {
return yield* mapNotFound(
Effect.sync(() => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })),
)
})
const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload?: Session.CreateInput }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionShare.Service.use((svc) => svc.create(ctx.payload)).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
})
const createRaw = Effect.fn("SessionHttpApi.createRaw")(function* (ctx: {
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
if (body.trim().length === 0) return yield* create({})
const json = yield* Effect.try({
try: () => JSON.parse(body) as unknown,
catch: () => new HttpApiError.BadRequest({}),
})
const payload = yield* Schema.decodeUnknownEffect(Session.CreateInput)(json).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
return yield* create({ payload })
})
const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.remove(ctx.params.sessionID)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
return true
})
const update = Effect.fn("SessionHttpApi.update")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof UpdatePayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) =>
Effect.gen(function* () {
const current = yield* svc.get(ctx.params.sessionID)
if (ctx.payload.title !== undefined) {
yield* svc.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
}
if (ctx.payload.permission !== undefined) {
yield* svc.setPermission({
sessionID: ctx.params.sessionID,
permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
})
}
if (ctx.payload.time?.archived !== undefined) {
yield* svc.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
}
return yield* svc.get(ctx.params.sessionID)
}),
).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
})
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ForkPayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }),
).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
})
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) => svc.cancel(ctx.params.sessionID)).pipe(
Effect.provide(SessionPrompt.defaultLayer),
),
),
),
)
return true
})
const init = Effect.fn("SessionHttpApi.init")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof InitPayload.Type
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({
sessionID: ctx.params.sessionID,
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
}),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
return true
})
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.share(ctx.params.sessionID)
return yield* session.get(ctx.params.sessionID)
}).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
})
const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.unshare(ctx.params.sessionID)
return yield* session.get(ctx.params.sessionID)
}).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
})
const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof SummarizePayload.Type
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const compact = yield* SessionCompaction.Service
const prompt = yield* SessionPrompt.Service
const agent = yield* Agent.Service
yield* revert.cleanup(yield* session.get(ctx.params.sessionID))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agent.defaultAgent()
const currentAgent =
messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
yield* compact.create({
sessionID: ctx.params.sessionID,
agent: currentAgent,
model: {
providerID: ctx.payload.providerID,
modelID: ctx.payload.modelID,
},
auto: ctx.payload.auto ?? false,
})
yield* prompt.loop({ sessionID: ctx.params.sessionID })
}).pipe(
Effect.provide(SessionRevert.defaultLayer),
Effect.provide(SessionCompaction.defaultLayer),
Effect.provide(SessionPrompt.defaultLayer),
Effect.provide(Agent.defaultLayer),
Effect.provide(Session.defaultLayer),
),
),
),
)
return true
})
const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof PromptPayload.Type
}) {
const instance = yield* InstanceState.context
return HttpServerResponse.stream(
Stream.fromEffect(
Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.prompt({
...ctx.payload,
sessionID: ctx.params.sessionID,
} as unknown as SessionPrompt.PromptInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
),
).pipe(
Stream.map((message) => JSON.stringify(message)),
Stream.encodeText,
),
{ contentType: "application/json" },
)
})
const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof PromptPayload.Type
}) {
const instance = yield* InstanceState.context
yield* Effect.sync(() => {
Instance.restore(instance, () => {
void AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
).catch((error) => {
log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error })
void Bus.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({
message: error instanceof Error ? error.message : String(error),
}).toObject(),
})
})
})
})
return HttpApiSchema.NoContent.make()
})
const command = Effect.fn("SessionHttpApi.command")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof CommandPayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
})
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ShellPayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
})
const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof RevertPayload.Type
}) {
const instance = yield* InstanceState.context
log.info("revert", ctx.payload)
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionRevert.Service.use((svc) => svc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })).pipe(
Effect.provide(SessionRevert.defaultLayer),
),
),
),
)
})
const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionRevert.Service.use((svc) => svc.unrevert({ sessionID: ctx.params.sessionID })).pipe(
Effect.provide(SessionRevert.defaultLayer),
),
),
),
)
})
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
params: { permissionID: PermissionID }
payload: typeof PermissionResponsePayload.Type
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }),
).pipe(Effect.provide(Permission.defaultLayer)),
),
),
)
return true
})
const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(ctx.params.sessionID)
yield* session.removeMessage(ctx.params)
}).pipe(Effect.provide(SessionRunState.defaultLayer), Effect.provide(Session.defaultLayer)),
),
),
)
return true
})
const deletePart = Effect.fn("SessionHttpApi.deletePart")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.removePart(ctx.params)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
return true
})
const updatePart = Effect.fn("SessionHttpApi.updatePart")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
payload: typeof MessageV2.Part.Type
}) {
const payload = ctx.payload as MessageV2.Part
if (
payload.id !== ctx.params.partID ||
payload.messageID !== ctx.params.messageID ||
payload.sessionID !== ctx.params.sessionID
) {
throw new Error(
`Part mismatch: body.id='${payload.id}' vs partID='${ctx.params.partID}', body.messageID='${payload.messageID}' vs messageID='${ctx.params.messageID}', body.sessionID='${payload.sessionID}' vs sessionID='${ctx.params.sessionID}'`,
)
}
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.updatePart(payload)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
})
return handlers
.handle("list", list)
.handle("status", status)
.handle("get", get)
.handle("children", children)
.handle("todo", todo)
.handle("diff", diff)
.handle("messages", messages)
.handle("message", message)
.handleRaw("create", createRaw)
.handle("remove", remove)
.handle("update", update)
.handle("fork", fork)
.handle("abort", abort)
.handle("init", init)
.handle("share", share)
.handle("unshare", unshare)
.handle("summarize", summarize)
.handle("prompt", prompt)
.handle("promptAsync", promptAsync)
.handle("command", command)
.handle("shell", shell)
.handle("revert", revert)
.handle("unrevert", unrevert)
.handle("permissionRespond", permissionRespond)
.handle("deleteMessage", deleteMessage)
.handle("deletePart", deletePart)
.handle("updatePart", updatePart)
}),
)
@@ -1,54 +0,0 @@
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Database } from "@/storage/db"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { asc } from "drizzle-orm"
import { and } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { lte } from "drizzle-orm"
import { not } from "drizzle-orm"
import { or } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { HistoryPayload, ReplayPayload } from "../groups/sync"
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
Effect.gen(function* () {
const start = Effect.fn("SyncHttpApi.start")(function* () {
startWorkspaceSyncing((yield* InstanceState.context).project.id)
return true
})
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: event.id,
aggregateID: event.aggregateID,
seq: event.seq,
type: event.type,
data: { ...event.data },
}))
SyncEvent.replayAll(events)
return { sessionID: events[0].aggregateID }
})
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
const exclude = Object.entries(ctx.payload)
return Database.use((db) =>
db
.select()
.from(EventTable)
.where(
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined,
)
.orderBy(asc(EventTable.seq))
.all(),
)
})
return handlers.handle("start", start).handle("replay", replay).handle("history", history)
}),
)
@@ -1,134 +0,0 @@
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { SessionTable } from "@/session/session.sql"
import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "../../tui"
import { InstanceHttpApi } from "../api"
import { CommandPayload, TuiPublishPayload } from "../groups/tui"
const commandAliases = {
session_new: "session.new",
session_share: "session.share",
session_interrupt: "session.interrupt",
session_compact: "session.compact",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_line_up: "session.line.up",
messages_line_down: "session.line.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",
messages_last: "session.last",
agent_cycle: "agent.cycle",
} as const
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
bus.publish(TuiEvent.CommandExecute, { command })
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
payload: typeof TuiEvent.PromptAppend.properties.Type
}) {
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
return true
})
const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () {
yield* publishCommand("help.show")
return true
})
const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () {
yield* publishCommand("session.list")
return true
})
const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () {
yield* publishCommand("session.list")
return true
})
const openModels = Effect.fn("TuiHttpApi.openModels")(function* () {
yield* publishCommand("model.list")
return true
})
const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () {
yield* publishCommand("prompt.submit")
return true
})
const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () {
yield* publishCommand("prompt.clear")
return true
})
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
payload: typeof CommandPayload.Type
}) {
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
return true
})
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
payload: typeof TuiEvent.ToastShow.properties.Type
}) {
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
return true
})
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
if (ctx.payload.type === TuiEvent.PromptAppend.type)
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.CommandExecute.type)
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.SessionSelect.type)
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
return true
})
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
payload: typeof TuiEvent.SessionSelect.properties.Type
}) {
if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
const row = yield* Effect.sync(() =>
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(),
),
)
if (!row) return yield* new HttpApiError.NotFound({})
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
return true
})
const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () {
return yield* Effect.promise(() => nextTuiRequest())
})
const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) {
submitTuiResponse(ctx.payload)
return true
})
return handlers
.handle("appendPrompt", appendPrompt)
.handle("openHelp", openHelp)
.handle("openSessions", openSessions)
.handle("openThemes", openThemes)
.handle("openModels", openModels)
.handle("submitPrompt", submitPrompt)
.handle("clearPrompt", clearPrompt)
.handle("executeCommand", executeCommand)
.handle("showToast", showToast)
.handle("publish", publish)
.handle("selectSession", selectSession)
.handle("controlNext", controlNext)
.handle("controlResponse", controlResponse)
}),
)
@@ -1,66 +0,0 @@
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Instance } from "@/project/instance"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
Effect.gen(function* () {
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => listAdaptors(instance.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
return Workspace.list((yield* InstanceState.context).project)
})
const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.create({
...ctx.payload,
projectID: instance.project.id,
}),
),
)
})
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
return Workspace.status().filter((item) => ids.has(item.workspaceID))
})
const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
})
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
params: { id: Workspace.Info["id"] }
payload: typeof SessionRestorePayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.sessionRestore({
workspaceID: ctx.params.id,
sessionID: ctx.payload.sessionID,
}),
),
)
})
return handlers
.handle("adaptors", adaptors)
.handle("list", list)
.handle("create", create)
.handle("status", status)
.handle("remove", remove)
.handle("sessionRestore", sessionRestore)
}),
)
@@ -1,191 +0,0 @@
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { getAdaptor } from "@/control-plane/adaptors"
import { WorkspaceID } from "@/control-plane/schema"
import type { Target } from "@/control-plane/types"
import { Workspace } from "@/control-plane/workspace"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import { Session } from "@/session/session"
import { ServerProxy } from "@/server/proxy"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Effect, Layer } from "effect"
import type { unhandled } from "effect/Types"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
type HandlerEffect = Effect.Effect<HttpServerResponse.HttpServerResponse, unhandled, never>
export class InstanceContextMiddleware extends HttpApiMiddleware.Service<InstanceContextMiddleware, {
requires: Session.Service
}>()(
"@opencode/ExperimentalHttpApiInstanceContext",
) {}
function decode(input: string) {
try {
return decodeURIComponent(input)
} catch {
return input
}
}
function currentDirectory() {
try {
return Instance.directory
} catch {
return process.cwd()
}
}
function sourceRequest(request: HttpServerRequest.HttpServerRequest) {
if (request.source instanceof Request) return request.source
return new Request(new URL(request.originalUrl, "http://localhost"), {
method: request.method,
headers: request.headers as HeadersInit,
})
}
function requestHeaders(request: HttpServerRequest.HttpServerRequest) {
return sourceRequest(request).headers
}
function writeSocket(write: (data: string | Uint8Array | Socket.CloseEvent) => Effect.Effect<void, unknown>, data: unknown) {
if (data instanceof Blob) {
void data.arrayBuffer().then((buffer) => Effect.runFork(write(new Uint8Array(buffer)).pipe(Effect.catch(() => Effect.void))))
return
}
if (typeof data === "string" || data instanceof Uint8Array) {
Effect.runFork(write(data).pipe(Effect.catch(() => Effect.void)))
return
}
if (data instanceof ArrayBuffer) Effect.runFork(write(new Uint8Array(data)).pipe(Effect.catch(() => Effect.void)))
}
function proxyWebSocket(request: HttpServerRequest.HttpServerRequest, target: string | URL) {
return Effect.gen(function* () {
const source = sourceRequest(request)
const socket = yield* Effect.orDie(request.upgrade)
const write = yield* socket.writer
const queue: Array<string | Uint8Array> = []
const remote = new WebSocket(ServerProxy.websocketTargetURL(target), ServerProxy.websocketProtocols(source))
remote.binaryType = "arraybuffer"
remote.onopen = () => {
for (const item of queue) remote.send(item)
queue.length = 0
}
remote.onmessage = (event) => writeSocket(write, event.data)
remote.onerror = () => Effect.runFork(write(new Socket.CloseEvent(1011, "proxy error")).pipe(Effect.catch(() => Effect.void)))
remote.onclose = (event) =>
Effect.runFork(write(new Socket.CloseEvent(event.code, event.reason)).pipe(Effect.catch(() => Effect.void)))
yield* socket
.runRaw((message) => {
const data = typeof message === "string" ? message : message.slice()
if (remote.readyState === WebSocket.OPEN) {
remote.send(data)
return
}
queue.push(data)
})
.pipe(
Effect.catch(() => Effect.void),
Effect.ensuring(Effect.sync(() => remote.close())),
Effect.orDie,
)
return HttpServerResponse.empty()
})
}
function proxyRemote(
request: HttpServerRequest.HttpServerRequest,
workspace: Workspace.Info,
target: Extract<Target, { type: "remote" }>,
requestURL: URL,
) {
const url = workspaceProxyURL(target.url, requestURL)
const source = sourceRequest(request)
if (source.headers.get("upgrade")?.toLowerCase() === "websocket") return proxyWebSocket(request, url)
return Effect.promise(() => ServerProxy.http(url, target.headers, source, workspace.id)).pipe(Effect.map(HttpServerResponse.raw))
}
function requestContext() {
return Effect.withFiber<HttpServerRequest.HttpServerRequest, never>((fiber) =>
Effect.succeed(Context.getUnsafe(fiber.context, HttpServerRequest.HttpServerRequest)),
)
}
function provideRequestContext(effect: HandlerEffect, request: HttpServerRequest.HttpServerRequest, sessionWorkspaceID?: WorkspaceID) {
return Effect.gen(function* () {
const url = new URL(request.url, "http://localhost")
const headers = requestHeaders(request)
const envWorkspaceID = Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined
const workspaceParam = url.searchParams.get("workspace")
const workspaceID = sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined)
const workspace = workspaceID && !envWorkspaceID ? yield* Effect.promise(() => Workspace.get(workspaceID)) : undefined
if (workspaceID && !workspace && !envWorkspaceID) {
return HttpServerResponse.text(`Workspace not found: ${workspaceID}`, {
status: 500,
contentType: "text/plain; charset=utf-8",
})
}
if (workspace && !isLocalWorkspaceRoute(request.method, url.pathname) && !url.pathname.startsWith("/console") && !envWorkspaceID) {
const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type))
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace)))
if (target.type === "remote") return yield* proxyRemote(request, workspace, target, url)
const ctx = yield* Effect.promise(() =>
Instance.provide({
directory: target.directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
return yield* effect.pipe(
Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, workspace.id),
)
}
const raw = url.searchParams.get("directory") || headers.get("x-opencode-directory") || currentDirectory()
const ctx = yield* Effect.promise(() =>
Instance.provide({
directory: Filesystem.resolve(decode(raw)),
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
return yield* effect.pipe(
Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, envWorkspaceID ?? workspaceID),
)
})
}
function provideInstanceContext(effect: HandlerEffect) {
return Effect.gen(function* () {
const request = yield* requestContext()
const sessionID = getWorkspaceRouteSessionID(new URL(request.url, "http://localhost"))
const session = sessionID
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.catchDefect(() => Effect.succeed(undefined)),
)
: undefined
return yield* provideRequestContext(effect, request, session?.workspaceID)
})
}
export const instanceContextLayer = Layer.succeed(
InstanceContextMiddleware,
InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)),
)
export const instanceRouterLayer = HttpRouter.middleware()(Effect.succeed((effect) =>
requestContext().pipe(Effect.flatMap((request) => provideRequestContext(effect, request))),
)).layer
@@ -1,14 +1,15 @@
import { Agent } from "@/agent/agent"
import { Command } from "@/command"
import { Format } from "@/format"
import { Global } from "@opencode-ai/core/global"
import { LSP } from "@/lsp/lsp"
import { Vcs } from "@/project/vcs"
import { Skill } from "@/skill"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
import * as InstanceState from "@/effect/instance-state"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForDisposal } from "./lifecycle"
const PathInfo = Schema.Struct({
home: Schema.String,
@@ -18,7 +19,7 @@ const PathInfo = Schema.Struct({
directory: Schema.String,
}).annotate({ identifier: "Path" })
export const VcsDiffQuery = Schema.Struct({
const VcsDiffQuery = Schema.Struct({
mode: Vcs.Mode,
})
@@ -39,7 +40,7 @@ export const InstanceApi = HttpApi.make("instance")
HttpApiGroup.make("instance")
.add(
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
success: described(Schema.Boolean, "Instance disposed"),
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "instance.dispose",
@@ -58,7 +59,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
success: described(Vcs.Info, "VCS info"),
success: Vcs.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "vcs.get",
@@ -69,7 +70,7 @@ export const InstanceApi = HttpApi.make("instance")
),
HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
query: VcsDiffQuery,
success: described(Schema.Array(Vcs.FileDiff), "VCS diff"),
success: Schema.Array(Vcs.FileDiff),
}).annotateMerge(
OpenApi.annotations({
identifier: "vcs.diff",
@@ -78,7 +79,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("command", InstancePaths.command, {
success: described(Schema.Array(Command.Info), "List of commands"),
success: Schema.Array(Command.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "command.list",
@@ -87,7 +88,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("agent", InstancePaths.agent, {
success: described(Schema.Array(Agent.Info), "List of agents"),
success: Schema.Array(Agent.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "app.agents",
@@ -96,7 +97,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("skill", InstancePaths.skill, {
success: described(Schema.Array(Skill.Info), "List of skills"),
success: Schema.Array(Skill.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "app.skills",
@@ -105,7 +106,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
success: described(Schema.Array(LSP.Status), "LSP server status"),
success: Schema.Array(LSP.Status),
}).annotateMerge(
OpenApi.annotations({
identifier: "lsp.status",
@@ -114,7 +115,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
success: described(Schema.Array(Format.Status), "Formatter status"),
success: Schema.Array(Format.Status),
}).annotateMerge(
OpenApi.annotations({
identifier: "formatter.status",
@@ -129,7 +130,6 @@ export const InstanceApi = HttpApi.make("instance")
description: "Experimental HttpApi instance read routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -139,3 +139,79 @@ export const InstanceApi = HttpApi.make("instance")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const instanceHandlers = Layer.unwrap(
Effect.gen(function* () {
const agent = yield* Agent.Service
const command = yield* Command.Service
const format = yield* Format.Service
const lsp = yield* LSP.Service
const skill = yield* Skill.Service
const vcs = yield* Vcs.Service
const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () {
yield* markInstanceForDisposal(yield* InstanceState.context)
return true
})
const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
const ctx = yield* InstanceState.context
return {
home: Global.Path.home,
state: Global.Path.state,
config: Global.Path.config,
worktree: ctx.worktree,
directory: ctx.directory,
}
})
const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () {
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
return { branch, default_branch }
})
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
return yield* vcs.diff(ctx.query.mode)
})
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
return yield* command.list()
})
const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () {
return yield* agent.list()
})
const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () {
return yield* skill.all()
})
const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () {
return yield* lsp.status()
})
const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () {
return yield* format.status()
})
return HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
handlers
.handle("dispose", dispose)
.handle("path", getPath)
.handle("vcs", getVcs)
.handle("vcsDiff", getVcsDiff)
.handle("command", getCommand)
.handle("agent", getAgent)
.handle("skill", getSkill)
.handle("lsp", getLsp)
.handle("formatter", getFormatter),
)
}),
).pipe(
Layer.provide(Agent.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(Vcs.defaultLayer),
)
@@ -3,6 +3,7 @@ import { Effect } from "effect"
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
const disposeAfterResponse = new WeakMap<object, InstanceContext>()
const reloadAfterResponse = new WeakMap<object, InstanceContext & { next: Parameters<typeof Instance.reload>[0] }>()
export const markInstanceForDisposal = (ctx: InstanceContext) =>
HttpEffect.appendPreResponseHandler((request, response) =>
@@ -13,17 +14,27 @@ export const markInstanceForDisposal = (ctx: InstanceContext) =>
)
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.as(Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))), response),
HttpEffect.appendPreResponseHandler((request, response) =>
Effect.sync(() => {
reloadAfterResponse.set(request.source, { ...ctx, next })
return response
}),
)
export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
Effect.gen(function* () {
const response = yield* effect
const request = yield* HttpServerRequest.HttpServerRequest
const reload = reloadAfterResponse.get(request.source)
if (reload) {
reloadAfterResponse.delete(request.source)
yield* Effect.promise(() => Instance.restore(reload, () => Instance.reload(reload.next)))
return response
}
const ctx = disposeAfterResponse.get(request.source)
if (!ctx) return response
disposeAfterResponse.delete(request.source)
yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())))
yield* Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))
return response
})
@@ -1,27 +1,26 @@
import { MCP } from "@/mcp"
import { ConfigMCP } from "@/config/mcp"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
export const AddPayload = Schema.Struct({
const AddPayload = Schema.Struct({
name: Schema.String,
config: ConfigMCP.Info,
})
}).annotate({ identifier: "McpAddInput" })
export const StatusMap = Schema.Record(Schema.String, MCP.Status)
export const AuthStartResponse = Schema.Struct({
const StatusMap = Schema.Record(Schema.String, MCP.Status)
const AuthStartResponse = Schema.Struct({
authorizationUrl: Schema.String,
})
export const AuthCallbackPayload = Schema.Struct({
oauthState: Schema.String,
}).annotate({ identifier: "McpAuthStartResponse" })
const AuthCallbackPayload = Schema.Struct({
code: Schema.String,
})
export const AuthRemoveResponse = Schema.Struct({
}).annotate({ identifier: "McpAuthCallbackInput" })
const AuthRemoveResponse = Schema.Struct({
success: Schema.Literal(true),
})
export class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthError>("McpUnsupportedOAuthError")(
}).annotate({ identifier: "McpAuthRemoveResponse" })
class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthError>("McpUnsupportedOAuthError")(
{ error: Schema.String },
{ httpApiStatus: 400 },
) {}
@@ -40,7 +39,7 @@ export const McpApi = HttpApi.make("mcp")
HttpApiGroup.make("mcp")
.add(
HttpApiEndpoint.get("status", McpPaths.status, {
success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
success: Schema.Record(Schema.String, MCP.Status),
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.status",
@@ -50,7 +49,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("add", McpPaths.status, {
payload: AddPayload,
success: described(StatusMap, "MCP server added successfully"),
success: StatusMap,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
@@ -61,8 +60,8 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("authStart", McpPaths.auth, {
params: { name: Schema.String },
success: described(AuthStartResponse, "OAuth flow started"),
error: [UnsupportedOAuthError, HttpApiError.NotFound],
success: AuthStartResponse,
error: UnsupportedOAuthError,
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.start",
@@ -73,8 +72,7 @@ export const McpApi = HttpApi.make("mcp")
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
params: { name: Schema.String },
payload: AuthCallbackPayload,
success: described(MCP.Status, "OAuth authentication completed"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
success: MCP.Status,
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.callback",
@@ -85,8 +83,8 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
params: { name: Schema.String },
success: described(MCP.Status, "OAuth authentication completed"),
error: [UnsupportedOAuthError, HttpApiError.NotFound],
success: MCP.Status,
error: UnsupportedOAuthError,
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.authenticate",
@@ -96,8 +94,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
params: { name: Schema.String },
success: described(AuthRemoveResponse, "OAuth credentials removed"),
error: HttpApiError.NotFound,
success: AuthRemoveResponse,
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.remove",
@@ -107,7 +104,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("connect", McpPaths.connect, {
params: { name: Schema.String },
success: described(Schema.Boolean, "MCP server connected successfully"),
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.connect",
@@ -116,7 +113,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
params: { name: Schema.String },
success: described(Schema.Boolean, "MCP server disconnected successfully"),
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.disconnect",
@@ -130,7 +127,6 @@ export const McpApi = HttpApi.make("mcp")
description: "Experimental HttpApi MCP routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -140,3 +136,68 @@ export const McpApi = HttpApi.make("mcp")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const mcpHandlers = Layer.unwrap(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const status = Effect.fn("McpHttpApi.status")(function* () {
return yield* mcp.status()
})
const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) {
const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status
return yield* Schema.decodeUnknownEffect(StatusMap)(
"status" in result ? { [ctx.payload.name]: result } : result,
).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
})
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
}
return yield* mcp.startAuth(ctx.params.name)
})
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
params: { name: string }
payload: typeof AuthCallbackPayload.Type
}) {
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
})
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
}
return yield* mcp.authenticate(ctx.params.name)
})
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
yield* mcp.removeAuth(ctx.params.name)
return { success: true as const }
})
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
yield* mcp.connect(ctx.params.name)
return true
})
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
yield* mcp.disconnect(ctx.params.name)
return true
})
return HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
handlers
.handle("status", status)
.handle("add", add)
.handle("authStart", authStart)
.handle("authCallback", authCallback)
.handle("authAuthenticate", authAuthenticate)
.handle("authRemove", authRemove)
.handle("connect", connect)
.handle("disconnect", disconnect),
)
}),
).pipe(Layer.provide(MCP.defaultLayer))
@@ -1,23 +1,17 @@
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const root = "/permission"
const ReplyPayload = Schema.Struct({
reply: Permission.Reply,
message: Schema.optional(Schema.String),
})
export const PermissionApi = HttpApi.make("permission")
.add(
HttpApiGroup.make("permission")
.add(
HttpApiEndpoint.get("list", root, {
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
success: Schema.Array(Permission.Request),
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.list",
@@ -27,9 +21,8 @@ export const PermissionApi = HttpApi.make("permission")
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: PermissionID },
payload: ReplyPayload,
success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
payload: Permission.ReplyBody,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.reply",
@@ -44,7 +37,6 @@ export const PermissionApi = HttpApi.make("permission")
description: "Experimental HttpApi permission routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -54,3 +46,29 @@ export const PermissionApi = HttpApi.make("permission")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const permissionHandlers = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* Permission.Service
const list = Effect.fn("PermissionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
params: { requestID: PermissionID }
payload: Permission.ReplyBody
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
reply: ctx.payload.reply,
message: ctx.payload.message,
})
return true
})
return HttpApiBuilder.group(PermissionApi, "permission", (handlers) =>
handlers.handle("list", list).handle("reply", reply),
)
}),
).pipe(Layer.provide(Permission.defaultLayer))
@@ -0,0 +1,109 @@
import * as InstanceState from "@/effect/instance-state"
import { AppRuntime } from "@/effect/app-runtime"
import { Project } from "@/project/project"
import { InstanceBootstrap } from "@/project/bootstrap"
import { ProjectID } from "@/project/schema"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForReload } from "./lifecycle"
const root = "/project"
export const ProjectApi = HttpApi.make("project")
.add(
HttpApiGroup.make("project")
.add(
HttpApiEndpoint.get("list", root, {
success: Schema.Array(Project.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "project.list",
summary: "List all projects",
description: "Get a list of projects that have been opened with OpenCode.",
}),
),
HttpApiEndpoint.get("current", `${root}/current`, {
success: Project.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "project.current",
summary: "Get current project",
description: "Retrieve the currently active project that OpenCode is working with.",
}),
),
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
success: Project.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "project.initGit",
summary: "Initialize git repository",
description: "Create a git repository for the current project and return the refreshed project info.",
}),
),
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
params: { projectID: ProjectID },
payload: Project.UpdatePayload,
success: Project.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "project.update",
summary: "Update project",
description: "Update project properties such as name, icon, and commands.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "project",
description: "Experimental HttpApi project routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const projectHandlers = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* Project.Service
const list = Effect.fn("ProjectHttpApi.list")(function* () {
return yield* svc.list()
})
const current = Effect.fn("ProjectHttpApi.current")(function* () {
return (yield* InstanceState.context).project
})
const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () {
const ctx = yield* InstanceState.context
const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project })
if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree)
return next
yield* markInstanceForReload(ctx, {
directory: ctx.directory,
worktree: ctx.directory,
project: next,
init: () => AppRuntime.runPromise(InstanceBootstrap),
})
return next
})
const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: {
params: { projectID: ProjectID }
payload: Project.UpdatePayload
}) {
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
})
return HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update),
)
}),
).pipe(Layer.provide(Project.defaultLayer))
@@ -0,0 +1,163 @@
import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const root = "/provider"
export const ProviderApi = HttpApi.make("provider")
.add(
HttpApiGroup.make("provider")
.add(
HttpApiEndpoint.get("list", root, {
success: Provider.ListResult,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.list",
summary: "List providers",
description: "Get a list of all available AI providers, including both available and connected ones.",
}),
),
HttpApiEndpoint.get("auth", `${root}/auth`, {
success: ProviderAuth.Methods,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.auth",
summary: "Get provider auth methods",
description: "Retrieve available authentication methods for all AI providers.",
}),
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
payload: ProviderAuth.AuthorizeInput,
success: Schema.UndefinedOr(ProviderAuth.Authorization),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.authorize",
summary: "Start OAuth authorization",
description: "Start the OAuth authorization flow for a provider.",
}),
),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID },
payload: ProviderAuth.CallbackInput,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.callback",
summary: "Handle OAuth callback",
description: "Handle the OAuth callback from a provider after user authorization.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "provider",
description: "Experimental HttpApi provider routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const providerHandlers = Layer.unwrap(
Effect.gen(function* () {
const cfg = yield* Config.Service
const provider = yield* Provider.Service
const svc = yield* ProviderAuth.Service
const list = Effect.fn("ProviderHttpApi.list")(function* () {
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
for (const [key, value] of Object.entries(all)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
const connected = yield* provider.list()
const providers = Object.assign(
mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)),
connected,
)
return {
all: Object.values(providers),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
})
const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
return yield* svc.methods()
})
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.AuthorizeInput
}) {
const result = yield* svc
.authorize({
providerID: ctx.params.providerID,
method: ctx.payload.method,
inputs: ctx.payload.inputs,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return result
})
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
params: { providerID: ProviderID }
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
const result = yield* authorize({ params: ctx.params, payload })
if (result === undefined) return HttpServerResponse.empty({ status: 200 })
return HttpServerResponse.jsonUnsafe(result)
})
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.CallbackInput
}) {
yield* svc
.callback({
providerID: ctx.params.providerID,
method: ctx.payload.method,
code: ctx.payload.code,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
return HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
handlers
.handle("list", list)
.handle("auth", auth)
.handleRaw("authorize", authorizeRaw)
.handle("callback", callback),
)
}),
).pipe(
Layer.provide(ProviderAuth.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Config.defaultLayer),
)
@@ -0,0 +1,245 @@
import { EffectBridge } from "@/effect/bridge"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Shell } from "@/shell/shell"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { Authorization } from "./auth"
const root = "/pty"
const Params = Schema.Struct({
ptyID: PtyID,
})
const CursorQuery = Schema.Struct({
cursor: Schema.optional(Schema.String),
})
const ShellItem = Schema.Struct({
path: Schema.String,
name: Schema.String,
acceptable: Schema.Boolean,
})
export const PtyPaths = {
shells: `${root}/shells`,
list: root,
create: root,
get: `${root}/:ptyID`,
update: `${root}/:ptyID`,
remove: `${root}/:ptyID`,
connect: `${root}/:ptyID/connect`,
} as const
export const PtyApi = HttpApi.make("pty")
.add(
HttpApiGroup.make("pty")
.add(
HttpApiEndpoint.get("shells", PtyPaths.shells, {
success: Schema.Array(ShellItem),
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.shells",
summary: "List available shells",
description: "Get a list of available shells on the system.",
}),
),
HttpApiEndpoint.get("list", PtyPaths.list, {
success: Schema.Array(Pty.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.list",
summary: "List PTY sessions",
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
}),
),
HttpApiEndpoint.post("create", PtyPaths.create, {
payload: Pty.CreateInput,
success: Pty.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.create",
summary: "Create PTY session",
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
}),
),
HttpApiEndpoint.get("get", PtyPaths.get, {
params: { ptyID: PtyID },
success: Pty.Info,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.get",
summary: "Get PTY session",
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.put("update", PtyPaths.update, {
params: { ptyID: PtyID },
payload: Pty.UpdateInput,
success: Pty.Info,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.update",
summary: "Update PTY session",
description: "Update properties of an existing pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
params: { ptyID: PtyID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.remove",
summary: "Remove PTY session",
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "pty",
description: "Experimental HttpApi PTY routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const PtyConnectApi = HttpApi.make("pty-connect").add(
HttpApiGroup.make("pty-connect")
.add(
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
query: CursorQuery,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connect",
summary: "Connect to PTY session",
description:
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
)
export const ptyHandlers = Layer.unwrap(
Effect.gen(function* () {
const pty = yield* Pty.Service
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list())
})
const list = Effect.fn("PtyHttpApi.list")(function* () {
return yield* pty.list()
})
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
const bridge = yield* EffectBridge.make()
return yield* Effect.promise(() =>
bridge.promise(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
),
)
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
const info = yield* pty.get(ctx.params.ptyID)
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
params: { ptyID: PtyID }
payload: typeof Pty.UpdateInput.Type
}) {
const info = yield* pty.update(ctx.params.ptyID, {
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
})
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
yield* pty.remove(ctx.params.ptyID)
return true
})
return HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
handlers
.handle("shells", shells)
.handle("list", list)
.handle("create", create)
.handle("get", get)
.handle("update", update)
.handle("remove", remove),
)
}),
)
export const ptyConnectRoute = HttpRouter.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const pty = yield* Pty.Service
const params = yield* HttpRouter.schemaPathParams(Params)
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
Effect.runFork(
write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)),
)
},
close: (code?: number, reason?: string) => {
if (closed) return
closed = true
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => {
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
})
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
closed = true
handler.onClose()
}),
),
Effect.orDie,
)
return HttpServerResponse.empty()
}).pipe(Effect.provide(Pty.defaultLayer)),
)
@@ -1,497 +1,45 @@
import { OpenApi } from "effect/unstable/httpapi"
import { OpenCodeHttpApi } from "./api"
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { ConfigApi } from "./config"
import { ControlApi } from "./control"
import { EventApi } from "./event"
import { ExperimentalApi } from "./experimental"
import { FileApi } from "./file"
import { GlobalApi } from "./global"
import { InstanceApi } from "./instance"
import { McpApi } from "./mcp"
import { PermissionApi } from "./permission"
import { ProjectApi } from "./project"
import { ProviderApi } from "./provider"
import { PtyApi, PtyConnectApi } from "./pty"
import { QuestionApi } from "./question"
import { SessionApi } from "./session"
import { SyncApi } from "./sync"
import { TuiApi } from "./tui"
import { WorkspaceApi } from "./workspace"
type OpenApiParameter = {
name: string
in: string
required?: boolean
schema?: OpenApiSchema
}
type OpenApiOperation = {
parameters?: OpenApiParameter[]
responses?: Record<string, OpenApiResponse>
requestBody?: {
required?: boolean
content?: Record<string, { schema?: OpenApiSchema }>
}
security?: unknown
}
type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
type OpenApiSpec = {
components?: {
schemas?: Record<string, OpenApiSchema>
securitySchemes?: Record<string, unknown>
}
paths?: Record<string, OpenApiPathItem>
}
type OpenApiSchema = {
$ref?: string
additionalProperties?: OpenApiSchema | boolean
allOf?: OpenApiSchema[]
anyOf?: OpenApiSchema[]
description?: string
enum?: Array<string | boolean>
items?: OpenApiSchema
maximum?: number
minimum?: number
oneOf?: OpenApiSchema[]
prefixItems?: OpenApiSchema[]
properties?: Record<string, OpenApiSchema>
required?: string[]
type?: string
}
type OpenApiResponse = {
description?: string
content?: Record<string, { schema?: OpenApiSchema }>
}
// Instance routes use middleware for directory/workspace resolution, but HttpApi
// doesn't surface middleware query params in the spec. Inject them explicitly.
const InstanceQueryParameters = [
{
name: "directory",
in: "query",
required: false,
schema: { type: "string" },
},
{
name: "workspace",
in: "query",
required: false,
schema: { type: "string" },
},
] satisfies OpenApiParameter[]
// Query schemas describe decoded Effect values, but the generated SDK needs the
// public call shape. These keep SDK callers passing numbers/booleans while the
// server still decodes string query params at runtime.
const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
const QueryBooleanParameters = new Set(["roots", "archived"])
const QueryParameterSchemas = {
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
} satisfies Record<string, OpenApiSchema>
const LegacyComponentDescriptions = {
LogLevel: "Log level",
ServerConfig: "Server configuration for opencode serve and web commands",
LayoutConfig: "@deprecated Always uses stretch layout.",
} satisfies Record<string, string>
function matchLegacyOpenApi(input: Record<string, unknown>) {
const spec = input as OpenApiSpec
// Effect's multi-document JSON Schema deduplicator can produce self-referencing
// component schemas (e.g. `{"$ref":"#/components/schemas/X"}` as the definition
// of X itself) when the same AST node appears both as a standalone endpoint
// payload and inside an annotated union arm. Resolve these by inlining the
// actual schema from any parent union that references them.
fixSelfReferencingComponents(spec)
// Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI,
// but the legacy SDK expected plain `T` for optional fields. Strip null
// from all component schemas so both request and response types match.
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
spec.components!.schemas![name] = stripOptionalNull(structuredClone(schema))
}
normalizeComponentNames(spec)
collapseDuplicateComponents(spec)
applyLegacySchemaOverrides(spec)
normalizeComponentDescriptions(spec)
addLegacyErrorSchemas(spec)
delete spec.components?.schemas?.Unauthorized
delete spec.components?.schemas?.EffectHttpApiErrorBadRequest
delete spec.components?.schemas?.EffectHttpApiErrorNotFound
delete spec.components?.schemas?.effect_HttpApiError_BadRequest
delete spec.components?.schemas?.effect_HttpApiError_NotFound
delete spec.components?.securitySchemes
for (const [path, item] of Object.entries(spec.paths ?? {})) {
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method]
if (!operation) continue
if (operation.requestBody) {
// Hono's generated OpenAPI never marked request bodies as required. Keep
// that SDK surface stable during the HttpApi migration.
delete operation.requestBody.required
const body = operation.requestBody.content?.["application/json"]
if (body?.schema) body.schema = stripOptionalNull(structuredClone(body.schema))
if (path === "/experimental/workspace" && method === "post") {
// Workspace creation fields `branch` and `extra` are Schema.NullOr —
// genuinely nullable, not just optional. Re-add the null that the
// component-level strip above removed.
const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace("#/components/schemas/", "")
const properties = ref ? spec.components?.schemas?.[ref]?.properties : operation.requestBody.content?.["application/json"]?.schema?.properties
if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] }
if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] }
}
}
for (const response of Object.values(operation.responses ?? {})) {
for (const content of Object.values(response.content ?? {})) {
if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema))
}
}
// Hono applied auth as runtime middleware outside OpenAPI metadata, so the
// legacy SDK did not expose auth schemes or generated 401 error unions.
delete operation.security
delete operation.responses?.["401"]
normalizeLegacyErrorResponses(operation)
normalizeLegacyOperation(operation, path, method)
if ((path === "/event" || path === "/global/event") && method === "get") {
// HttpApi has no first-class SSE response schema, and these handlers are
// raw/streaming routes. Document the actual wire protocol explicitly.
operation.responses!["200"] = {
description: "Event stream",
content: {
"text/event-stream": {
schema: path === "/event" ? { $ref: "#/components/schemas/Event" } : { $ref: "#/components/schemas/GlobalEvent" },
},
},
}
}
if (!isInstanceRoute) continue
operation.parameters = [
...InstanceQueryParameters,
...(operation.parameters ?? []).filter(
(param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"),
),
]
for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
}
}
return input
}
function addLegacyErrorSchemas(spec: OpenApiSpec) {
if (!spec.components?.schemas) return
spec.components.schemas.BadRequestError = {
type: "object",
required: ["data", "errors", "success"],
properties: {
data: {},
errors: {
type: "array",
items: {
type: "object",
additionalProperties: {},
},
},
success: { type: "boolean", enum: [false] },
},
}
spec.components.schemas.NotFoundError = {
type: "object",
required: ["name", "data"],
properties: {
name: { type: "string", enum: ["NotFoundError"] },
data: {
type: "object",
required: ["message"],
properties: {
message: { type: "string" },
},
},
},
}
}
function collapseDuplicateComponents(spec: OpenApiSpec) {
const schemas = spec.components?.schemas
if (!schemas) return
for (const name of Object.keys(schemas)) {
const base = name.replace(/\d+$/, "")
if (base === name || !schemas[base]) continue
if (stableSchema(schemas[name], schemas) !== stableSchema(schemas[base], schemas)) continue
rewriteRefs(spec, name, base)
delete schemas[name]
}
}
function normalizeComponentNames(spec: OpenApiSpec) {
const schemas = spec.components?.schemas
if (!schemas) return
for (const name of Object.keys(schemas)) {
const next = componentTypeName(name)
if (next === name) continue
if (schemas[next]) {
if (stableSchema(schemas[name], schemas) === stableSchema(schemas[next], schemas)) {
rewriteRefs(spec, name, next)
delete schemas[name]
}
continue
}
schemas[next] = schemas[name]
rewriteRefs(spec, name, next)
delete schemas[name]
}
}
function componentTypeName(name: string) {
if (!name.includes(".")) return name
return name
.split(".")
.filter((part) => !/^\d+$/.test(part))
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
.join("")
}
function applyLegacySchemaOverrides(spec: OpenApiSpec) {
const schemas = spec.components?.schemas
if (!schemas) return
if (schemas.AgentConfig) schemas.AgentConfig.additionalProperties = {}
if (schemas.Command?.properties?.template) schemas.Command.properties.template = { type: "string" }
if (schemas.Workspace?.properties) {
schemas.Workspace.properties.branch = nullable(schemas.Workspace.properties.branch)
schemas.Workspace.properties.directory = nullable(schemas.Workspace.properties.directory)
schemas.Workspace.properties.extra = nullable(schemas.Workspace.properties.extra)
}
if (schemas.GlobalSession?.properties?.project) schemas.GlobalSession.properties.project = nullable(schemas.GlobalSession.properties.project)
const providerOptions = schemas.ProviderConfig?.properties?.options
if (providerOptions) providerOptions.additionalProperties = {}
const model = schemas.ProviderConfig?.properties?.models?.additionalProperties
const variants = typeof model === "object" ? model.properties?.variants?.additionalProperties : undefined
if (variants && typeof variants === "object") variants.additionalProperties = {}
const syncInfo = schemas.SyncEventSessionUpdated?.properties?.data?.properties?.info
if (syncInfo?.properties) makePropertiesNullable(syncInfo.properties)
}
function normalizeComponentDescriptions(spec: OpenApiSpec) {
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions]
if (description) {
schema.description = description
continue
}
delete schema.description
}
}
function makePropertiesNullable(properties: Record<string, OpenApiSchema>) {
for (const [key, value] of Object.entries(properties)) {
if (key === "share" && value.properties?.url) {
value.properties.url = nullable(value.properties.url)
continue
}
if (key === "time" && value.properties) {
makePropertiesNullable(value.properties)
continue
}
properties[key] = nullable(value)
}
}
function nullable(schema: OpenApiSchema): OpenApiSchema {
if (flattenOptions(schema.anyOf ?? schema.oneOf)?.some((item) => item.type === "null")) return schema
return { anyOf: [schema, { type: "null" }] }
}
function stableSchema(input: unknown, schemas: Record<string, OpenApiSchema>): string {
return JSON.stringify(canonicalizeSchema(input, schemas))
}
function canonicalizeSchema(input: unknown, schemas: Record<string, OpenApiSchema>): unknown {
if (Array.isArray(input)) return input.map((item) => canonicalizeSchema(item, schemas))
if (!input || typeof input !== "object") return input
const schema = input as OpenApiSchema
if (schema.$ref) return { $ref: canonicalRef(schema.$ref, schemas) }
return Object.fromEntries(
Object.entries(input)
.filter(([key]) => key !== "description")
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => [key, canonicalizeSchema(value, schemas)]),
)
}
function canonicalRef(ref: string, schemas: Record<string, OpenApiSchema>) {
const name = ref.replace("#/components/schemas/", "")
const base = name.replace(/\d+$/, "")
if (base !== name && schemas[base]) return `#/components/schemas/${base}`
return ref
}
function rewriteRefs(input: unknown, from: string, to: string): void {
if (Array.isArray(input)) {
for (const item of input) rewriteRefs(item, from, to)
return
}
if (!input || typeof input !== "object") return
const schema = input as OpenApiSchema
if (schema.$ref === `#/components/schemas/${from}`) schema.$ref = `#/components/schemas/${to}`
for (const value of Object.values(input)) rewriteRefs(value, from, to)
}
function normalizeLegacyErrorResponses(operation: OpenApiOperation) {
if (operation.responses?.["400"] && isBuiltInErrorResponse(operation.responses["400"], "BadRequest")) {
operation.responses["400"] = legacyErrorResponse("Bad request", "BadRequestError")
}
if (operation.responses?.["404"] && isBuiltInErrorResponse(operation.responses["404"], "NotFound")) {
operation.responses["404"] = legacyErrorResponse("Not found", "NotFoundError")
}
}
function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) {
if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"]
if (path === "/pty/{ptyID}" && method === "put") delete operation.responses?.["404"]
if ((path !== "/session/{sessionID}/message" && path !== "/session/{sessionID}/command") || method !== "post") return
const response = operation.responses?.["200"]?.content?.["application/json"]
if (!response) return
response.schema = {
type: "object",
required: ["info", "parts"],
properties: {
info: { $ref: "#/components/schemas/AssistantMessage" },
parts: {
type: "array",
items: { $ref: "#/components/schemas/Part" },
},
},
}
}
function isRefResponse(response: OpenApiResponse, name: string) {
return response.content?.["application/json"]?.schema?.$ref === `#/components/schemas/${name}`
}
function isBuiltInErrorResponse(response: OpenApiResponse, name: "BadRequest" | "NotFound") {
return response.description === name || isRefResponse(response, `EffectHttpApiError${name}`)
}
function legacyErrorResponse(description: string, name: "BadRequestError" | "NotFoundError"): OpenApiResponse {
return {
description,
content: {
"application/json": {
schema: { $ref: `#/components/schemas/${name}` },
},
},
}
}
/**
* Fix component schemas that are self-referencing `$ref`s — an Effect OpenAPI
* generation bug where annotated union arms that share AST nodes with other
* endpoints produce `{"$ref":"#/components/schemas/X"}` as the definition of X.
*
* Resolves by finding the actual schema from a parent union's `anyOf`/`oneOf`
* that references the broken component, then inlining that schema.
*/
function fixSelfReferencingComponents(spec: OpenApiSpec) {
const schemas = spec.components?.schemas
if (!schemas) return
const selfRefs = new Set<string>()
for (const [name, schema] of Object.entries(schemas)) {
if (schema.$ref === `#/components/schemas/${name}`) selfRefs.add(name)
}
if (selfRefs.size === 0) return
// Find a parent union component whose anyOf/oneOf contains a $ref to the
// broken component — that parent was generated correctly and holds the inline
// schema we need.
for (const [, schema] of Object.entries(schemas)) {
for (const member of schema.anyOf ?? schema.oneOf ?? []) {
const ref = member.$ref?.replace("#/components/schemas/", "")
if (!ref || !selfRefs.has(ref)) continue
// This member's $ref points to a self-referencing component. The member
// itself is just {$ref:...}, so the actual schema must be resolved from
// the union. Since the union component was generated before the
// deduplicator broke things, the inline version lives elsewhere. Generate
// a fresh spec without the transform to get the correct schema.
// Simpler approach: look through all paths for an endpoint that uses this
// schema as a payload (it would have been expanded by the ref-expansion
// logic above if we ran after that, but we run before). Instead, just
// delete the broken component — if it's referenced via $ref elsewhere,
// the ref expansion in the request body loop will inline it anyway.
}
}
// Simplest fix: generate the raw spec (without transform) to get correct schemas
const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec
const rawSchemas = raw.components?.schemas
if (!rawSchemas) return
for (const name of selfRefs) {
if (rawSchemas[name]) schemas[name] = rawSchemas[name]
}
}
/** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */
function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema {
if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} }
const options = flattenOptions(schema.anyOf ?? schema.oneOf)
if (options) {
const withoutNull = options.filter((item) => item.type !== "null")
if (withoutNull.length === 1) return stripOptionalNull(withoutNull[0])
if (schema.anyOf) schema.anyOf = withoutNull.map(stripOptionalNull)
if (schema.oneOf) schema.oneOf = withoutNull.map(stripOptionalNull)
}
if (schema.allOf) {
const allOf = schema.allOf.map(stripOptionalNull)
if (schema.type) {
delete schema.allOf
for (const item of allOf) Object.assign(schema, item)
} else {
schema.allOf = allOf
}
}
if (schema.prefixItems && schema.items) delete schema.prefixItems
if (schema.items) schema.items = stripOptionalNull(schema.items)
if (schema.properties) {
for (const [key, value] of Object.entries(schema.properties)) {
schema.properties[key] = stripOptionalNull(value)
}
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
schema.additionalProperties = stripOptionalNull(schema.additionalProperties)
}
return schema
}
function isEmptyObjectUnion(schema: OpenApiSchema) {
const options = schema.anyOf ?? schema.oneOf
return options?.length === 2 && options.some(isBareObjectSchema) && options.some(isBareArraySchema)
}
function isBareObjectSchema(schema: OpenApiSchema) {
return schema.type === "object" && !schema.properties && !schema.additionalProperties
}
function isBareArraySchema(schema: OpenApiSchema) {
return schema.type === "array" && !schema.items && !schema.prefixItems
}
function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined {
return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item])
}
function normalizeParameter(param: OpenApiParameter, route: string) {
if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
if (override) {
param.schema = override
return
}
if (QueryNumberParameters.has(param.name)) {
param.schema = { type: "number" }
return
}
if (QueryBooleanParameters.has(param.name)) {
param.schema = {
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
}
return
}
param.schema = stripOptionalNull(param.schema)
}
export const PublicApi = OpenCodeHttpApi
export const PublicApi = HttpApi.make("opencode")
.addHttpApi(ControlApi)
.addHttpApi(GlobalApi)
.addHttpApi(EventApi)
.addHttpApi(ConfigApi)
.addHttpApi(ExperimentalApi)
.addHttpApi(FileApi)
.addHttpApi(InstanceApi)
.addHttpApi(McpApi)
.addHttpApi(PermissionApi)
.addHttpApi(ProjectApi)
.addHttpApi(ProviderApi)
.addHttpApi(PtyApi)
.addHttpApi(PtyConnectApi)
.addHttpApi(QuestionApi)
.addHttpApi(SessionApi)
.addHttpApi(SyncApi)
.addHttpApi(TuiApi)
.addHttpApi(WorkspaceApi)
.annotateMerge(
OpenApi.annotations({
title: "opencode",
version: "1.0.0",
description: "opencode api",
transform: matchLegacyOpenApi,
}),
)
@@ -1,24 +1,17 @@
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
import { described } from "./metadata"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const root = "/question"
const ReplyPayload = Schema.Struct({
answers: Schema.Array(Question.Answer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
})
export const QuestionApi = HttpApi.make("question")
.add(
HttpApiGroup.make("question")
.add(
HttpApiEndpoint.get("list", root, {
success: described(Schema.Array(Question.Request), "List of pending questions"),
success: Schema.Array(Question.Request),
}).annotateMerge(
OpenApi.annotations({
identifier: "question.list",
@@ -28,9 +21,8 @@ export const QuestionApi = HttpApi.make("question")
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: QuestionID },
payload: ReplyPayload,
success: described(Schema.Boolean, "Question answered successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
payload: Question.Reply,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "question.reply",
@@ -40,8 +32,7 @@ export const QuestionApi = HttpApi.make("question")
),
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
params: { requestID: QuestionID },
success: described(Schema.Boolean, "Question rejected successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "question.reject",
@@ -56,7 +47,6 @@ export const QuestionApi = HttpApi.make("question")
description: "Question routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -66,3 +56,33 @@ export const QuestionApi = HttpApi.make("question")
description: "Effect HttpApi surface for instance routes.",
}),
)
export const questionHandlers = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* Question.Service
const list = Effect.fn("QuestionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
params: { requestID: QuestionID }
payload: Question.Reply
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
answers: ctx.payload.answers,
})
return true
})
const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) {
yield* svc.reject(ctx.params.requestID)
return true
})
return HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
handlers.handle("list", list).handle("reply", reply).handle("reject", reject),
)
}),
).pipe(Layer.provide(Question.defaultLayer))
@@ -1,128 +1,101 @@
import { Context, Effect, Layer } from "effect"
import { Effect, Layer, Schema } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { Command } from "@/command"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import * as Observability from "@opencode-ai/core/effect/observability"
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import { Format } from "@/format"
import { LSP } from "@/lsp/lsp"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { Installation } from "@/installation"
import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth"
import { Provider } from "@/provider/provider"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import { Pty } from "@/pty"
import { Question } from "@/question"
import { Session } from "@/session/session"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { Skill } from "@/skill"
import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { Filesystem } from "@/util/filesystem"
import { authorizationLayer } from "./auth"
import { ConfigApi, configHandlers } from "./config"
import { eventRoute } from "./event"
import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control"
import { experimentalHandlers } from "./handlers/experimental"
import { fileHandlers } from "./handlers/file"
import { globalHandlers } from "./handlers/global"
import { instanceHandlers } from "./handlers/instance"
import { mcpHandlers } from "./handlers/mcp"
import { permissionHandlers } from "./handlers/permission"
import { projectHandlers } from "./handlers/project"
import { providerHandlers } from "./handlers/provider"
import { ptyConnectRoute, ptyHandlers } from "./handlers/pty"
import { questionHandlers } from "./handlers/question"
import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer, instanceRouterLayer } from "./instance-context"
import { FileApi, fileHandlers } from "./file"
import { ExperimentalApi, experimentalHandlers } from "./experimental"
import { InstanceApi, instanceHandlers } from "./instance"
import { McpApi, mcpHandlers } from "./mcp"
import { PermissionApi, permissionHandlers } from "./permission"
import { ProjectApi, projectHandlers } from "./project"
import { PtyApi, ptyConnectRoute, ptyHandlers } from "./pty"
import { ProviderApi, providerHandlers } from "./provider"
import { QuestionApi, questionHandlers } from "./question"
import { SessionApi, sessionHandlers } from "./session"
import { SyncApi, syncHandlers } from "./sync"
import { TuiApi, tuiHandlers } from "./tui"
import { WorkspaceApi, workspaceHandlers } from "./workspace"
import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import * as ServerBackend from "@/server/backend"
export const context = Context.empty() as Context.Context<unknown>
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
auth_token: Schema.optional(Schema.String),
})
const runtime = HttpRouter.middleware()(
Effect.succeed((effect) =>
Effect.gen(function* () {
const selected = ServerBackend.select()
yield* Effect.annotateCurrentSpan(ServerBackend.attributes(ServerBackend.force(selected, "effect-httpapi")))
return yield* effect
}),
),
const Headers = Schema.Struct({
authorization: Schema.optional(Schema.String),
"x-opencode-directory": Schema.optional(Schema.String),
})
function decode(input: string) {
try {
return decodeURIComponent(input)
} catch {
return input
}
}
const instance = HttpRouter.middleware()(
Effect.gen(function* () {
return (effect) =>
Effect.gen(function* () {
const query = yield* HttpServerRequest.schemaSearchParams(Query)
const headers = yield* HttpServerRequest.schemaHeaders(Headers)
const raw = query.directory || headers["x-opencode-directory"] || process.cwd()
const workspace = query.workspace || undefined
const ctx = yield* Effect.promise(() =>
Instance.provide({
directory: Filesystem.resolve(decode(raw)),
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect
return yield* next.pipe(Effect.provideService(InstanceRef, ctx))
})
}),
).layer
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
Layer.provide([
configHandlers,
experimentalHandlers,
fileHandlers,
instanceHandlers,
mcpHandlers,
projectHandlers,
ptyHandlers,
questionHandlers,
permissionHandlers,
providerHandlers,
sessionHandlers,
syncHandlers,
tuiHandlers,
workspaceHandlers,
]),
)
const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
Layer.provide([authorizationLayer, instanceContextLayer]),
)
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
Layer.provide([
runtime,
Account.defaultLayer,
Agent.defaultLayer,
Auth.defaultLayer,
Command.defaultLayer,
Config.defaultLayer,
File.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
Installation.defaultLayer,
MCP.defaultLayer,
Permission.defaultLayer,
Project.defaultLayer,
ProviderAuth.defaultLayer,
Provider.defaultLayer,
Pty.defaultLayer,
Question.defaultLayer,
Ripgrep.defaultLayer,
Session.defaultLayer,
SessionRunState.defaultLayer,
SessionStatus.defaultLayer,
SessionSummary.defaultLayer,
Skill.defaultLayer,
Todo.defaultLayer,
ToolRegistry.defaultLayer,
Vcs.defaultLayer,
Worktree.defaultLayer,
Bus.layer,
HttpServer.layerServices,
]),
export const routes = Layer.mergeAll(
eventRoute,
ptyConnectRoute,
HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)),
HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)),
HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)),
HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)),
HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)),
HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)),
HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers), Layer.provide(Pty.defaultLayer)),
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
HttpApiBuilder.layer(TuiApi).pipe(
Layer.provide(tuiHandlers),
Layer.provide(Session.defaultLayer),
Layer.provide(Bus.layer),
),
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
).pipe(
Layer.provide(authorizationLayer),
Layer.provide(instance),
Layer.provide(HttpServer.layerServices),
Layer.provideMerge(Observability.layer),
)
@@ -0,0 +1,948 @@
import * as InstanceState from "@/effect/instance-state"
import { AppRuntime } from "@/effect/app-runtime"
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { Command } from "@/command"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Instance } from "@/project/instance"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
import { MessageV2 } from "@/session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { Effect, Layer, Schema, SchemaGetter, Struct } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import {
HttpApi,
HttpApiBuilder,
HttpApiEndpoint,
HttpApiError,
HttpApiGroup,
HttpApiSchema,
OpenApi,
} from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const log = Log.create({ service: "server" })
const root = "/session"
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
const ListQuery = Schema.Struct({
directory: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
search: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
})
const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
const MessagesQuery = Schema.Struct({
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
before: Schema.optional(Schema.String),
})
const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String),
permission: Schema.optional(Permission.Ruleset),
time: Schema.optional(
Schema.Struct({
archived: Schema.optional(Schema.Number),
}),
),
}).annotate({ identifier: "SessionUpdateInput" })
const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({
identifier: "SessionForkInput",
})
const InitPayload = Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
messageID: MessageID,
}).annotate({ identifier: "SessionInitInput" })
const SummarizePayload = Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
auto: Schema.optional(Schema.Boolean),
}).annotate({ identifier: "SessionSummarizeInput" })
const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])).annotate({
identifier: "SessionPromptInput",
})
const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])).annotate({
identifier: "SessionCommandInput",
})
const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])).annotate({
identifier: "SessionShellInput",
})
const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])).annotate({
identifier: "SessionRevertInput",
})
const PermissionResponsePayload = Schema.Struct({
response: Permission.Reply,
}).annotate({ identifier: "SessionPermissionResponseInput" })
export const SessionPaths = {
list: root,
status: `${root}/status`,
get: `${root}/:sessionID`,
children: `${root}/:sessionID/children`,
todo: `${root}/:sessionID/todo`,
diff: `${root}/:sessionID/diff`,
messages: `${root}/:sessionID/message`,
message: `${root}/:sessionID/message/:messageID`,
create: root,
remove: `${root}/:sessionID`,
update: `${root}/:sessionID`,
fork: `${root}/:sessionID/fork`,
abort: `${root}/:sessionID/abort`,
share: `${root}/:sessionID/share`,
init: `${root}/:sessionID/init`,
summarize: `${root}/:sessionID/summarize`,
prompt: `${root}/:sessionID/message`,
promptAsync: `${root}/:sessionID/prompt_async`,
command: `${root}/:sessionID/command`,
shell: `${root}/:sessionID/shell`,
revert: `${root}/:sessionID/revert`,
unrevert: `${root}/:sessionID/unrevert`,
permissions: `${root}/:sessionID/permissions/:permissionID`,
deleteMessage: `${root}/:sessionID/message/:messageID`,
deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,
updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,
} as const
export const SessionApi = HttpApi.make("session")
.add(
HttpApiGroup.make("session")
.add(
HttpApiEndpoint.get("list", SessionPaths.list, {
query: ListQuery,
success: Schema.Array(Session.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.list",
summary: "List sessions",
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
}),
),
HttpApiEndpoint.get("status", SessionPaths.status, {
success: StatusMap,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.status",
summary: "Get session status",
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
}),
),
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.get",
summary: "Get session",
description: "Retrieve detailed information about a specific OpenCode session.",
}),
),
HttpApiEndpoint.get("children", SessionPaths.children, {
params: { sessionID: SessionID },
success: Schema.Array(Session.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.children",
summary: "Get session children",
description: "Retrieve all child sessions that were forked from the specified parent session.",
}),
),
HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID },
success: Schema.Array(Todo.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.todo",
summary: "Get session todos",
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
}),
),
HttpApiEndpoint.get("diff", SessionPaths.diff, {
params: { sessionID: SessionID },
query: DiffQuery,
success: Schema.Array(Snapshot.FileDiff),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.diff",
summary: "Get message diff",
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
}),
),
HttpApiEndpoint.get("messages", SessionPaths.messages, {
params: { sessionID: SessionID },
query: MessagesQuery,
success: Schema.Array(MessageV2.WithParts),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.messages",
summary: "Get session messages",
description: "Retrieve all messages in a session, including user prompts and AI responses.",
}),
),
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.message",
summary: "Get message",
description: "Retrieve a specific message from a session by its message ID.",
}),
),
HttpApiEndpoint.post("create", SessionPaths.create, {
payload: [HttpApiSchema.NoContent, Session.CreateInput],
success: Session.Info,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.create",
summary: "Create session",
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
}),
),
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.delete",
summary: "Delete session",
description: "Delete a session and permanently remove all associated data, including messages and history.",
}),
),
HttpApiEndpoint.patch("update", SessionPaths.update, {
params: { sessionID: SessionID },
payload: UpdatePayload,
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.update",
summary: "Update session",
description: "Update properties of an existing session, such as title or other metadata.",
}),
),
HttpApiEndpoint.post("fork", SessionPaths.fork, {
params: { sessionID: SessionID },
payload: ForkPayload,
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
summary: "Fork session",
description: "Create a new session by forking an existing session at a specific message point.",
}),
),
HttpApiEndpoint.post("abort", SessionPaths.abort, {
params: { sessionID: SessionID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.abort",
summary: "Abort session",
description: "Abort an active session and stop any ongoing AI processing or command execution.",
}),
),
HttpApiEndpoint.post("init", SessionPaths.init, {
params: { sessionID: SessionID },
payload: InitPayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.init",
summary: "Initialize session",
description:
"Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
}),
),
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.share",
summary: "Share session",
description: "Create a shareable link for a session, allowing others to view the conversation.",
}),
),
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unshare",
summary: "Unshare session",
description: "Remove the shareable link for a session, making it private again.",
}),
),
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
params: { sessionID: SessionID },
payload: SummarizePayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.summarize",
summary: "Summarize session",
description: "Generate a concise summary of the session using AI compaction to preserve key information.",
}),
),
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
params: { sessionID: SessionID },
payload: PromptPayload,
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt",
summary: "Send message",
description: "Create and send a new message to a session, streaming the AI response.",
}),
),
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
params: { sessionID: SessionID },
payload: PromptPayload,
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt_async",
summary: "Send async message",
description:
"Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
}),
),
HttpApiEndpoint.post("command", SessionPaths.command, {
params: { sessionID: SessionID },
payload: CommandPayload,
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.command",
summary: "Send command",
description: "Send a new command to a session for execution by the AI assistant.",
}),
),
HttpApiEndpoint.post("shell", SessionPaths.shell, {
params: { sessionID: SessionID },
payload: ShellPayload,
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.shell",
summary: "Run shell command",
description: "Execute a shell command within the session context and return the AI's response.",
}),
),
HttpApiEndpoint.post("revert", SessionPaths.revert, {
params: { sessionID: SessionID },
payload: RevertPayload,
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.revert",
summary: "Revert message",
description:
"Revert a specific message in a session, undoing its effects and restoring the previous state.",
}),
),
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unrevert",
summary: "Restore reverted messages",
description: "Restore all previously reverted messages in a session.",
}),
),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID },
payload: PermissionResponsePayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.respond",
summary: "Respond to permission",
description: "Approve or deny a permission request from the AI assistant.",
deprecated: true,
}),
),
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.deleteMessage",
summary: "Delete message",
description:
"Permanently delete a specific message and all of its parts from a session without reverting file changes.",
}),
),
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "part.delete",
description: "Delete a part from a message.",
}),
),
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
payload: MessageV2.Part,
success: MessageV2.Part,
}).annotateMerge(
OpenApi.annotations({
identifier: "part.update",
description: "Update a part in a message.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
description: "Experimental HttpApi session routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const sessionHandlers = Layer.unwrap(
Effect.gen(function* () {
const session = yield* Session.Service
const statusSvc = yield* SessionStatus.Service
const todoSvc = yield* Todo.Service
const summary = yield* SessionSummary.Service
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
const instance = yield* InstanceState.context
return Instance.restore(instance, () =>
Array.from(
Session.list({
directory: ctx.query.directory,
roots: ctx.query.roots,
start: ctx.query.start,
search: ctx.query.search,
limit: ctx.query.limit,
}),
),
)
})
const status = Effect.fn("SessionHttpApi.status")(function* () {
return Object.fromEntries(yield* statusSvc.list())
})
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* session.get(ctx.params.sessionID)
})
const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* session.children(ctx.params.sessionID)
})
const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* todoSvc.get(ctx.params.sessionID)
})
const diff = Effect.fn("SessionHttpApi.diff")(function* (ctx: {
params: { sessionID: SessionID }
query: typeof DiffQuery.Type
}) {
return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID })
})
const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: {
params: { sessionID: SessionID }
query: typeof MessagesQuery.Type
}) {
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
if (ctx.query.before) {
const before = ctx.query.before
yield* Effect.try({
try: () => MessageV2.cursor.decode(before),
catch: () => new HttpApiError.BadRequest({}),
})
}
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
yield* session.get(ctx.params.sessionID)
return yield* session.messages({ sessionID: ctx.params.sessionID })
}
const page = MessageV2.page({
sessionID: ctx.params.sessionID,
limit: ctx.query.limit,
before: ctx.query.before,
})
if (!page.cursor) return page.items
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
url.searchParams.set("limit", ctx.query.limit.toString())
url.searchParams.set("before", page.cursor)
return HttpServerResponse.jsonUnsafe(page.items, {
headers: {
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
Link: `<${url.toString()}>; rel="next"`,
"X-Next-Cursor": page.cursor,
},
})
})
const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
}) {
return yield* Effect.sync(() =>
MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
)
})
const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload?: Session.CreateInput }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionShare.Service.use((svc) => svc.create(ctx.payload)).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
})
const createRaw = Effect.fn("SessionHttpApi.createRaw")(function* (ctx: {
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
if (body.trim().length === 0) return yield* create({})
const json = yield* Effect.try({
try: () => JSON.parse(body) as unknown,
catch: () => new HttpApiError.BadRequest({}),
})
const payload = yield* Schema.decodeUnknownEffect(Session.CreateInput)(json).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
return yield* create({ payload })
})
const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.remove(ctx.params.sessionID)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
return true
})
const update = Effect.fn("SessionHttpApi.update")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof UpdatePayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) =>
Effect.gen(function* () {
const current = yield* svc.get(ctx.params.sessionID)
if (ctx.payload.title !== undefined) {
yield* svc.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
}
if (ctx.payload.permission !== undefined) {
yield* svc.setPermission({
sessionID: ctx.params.sessionID,
permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
})
}
if (ctx.payload.time?.archived !== undefined) {
yield* svc.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
}
return yield* svc.get(ctx.params.sessionID)
}),
).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
})
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ForkPayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }),
).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
})
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) => svc.cancel(ctx.params.sessionID)).pipe(
Effect.provide(SessionPrompt.defaultLayer),
),
),
),
)
return true
})
const init = Effect.fn("SessionHttpApi.init")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof InitPayload.Type
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({
sessionID: ctx.params.sessionID,
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
}),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
return true
})
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.share(ctx.params.sessionID)
return yield* session.get(ctx.params.sessionID)
}).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
})
const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.unshare(ctx.params.sessionID)
return yield* session.get(ctx.params.sessionID)
}).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
})
const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof SummarizePayload.Type
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const compact = yield* SessionCompaction.Service
const prompt = yield* SessionPrompt.Service
const agent = yield* Agent.Service
yield* revert.cleanup(yield* session.get(ctx.params.sessionID))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agent.defaultAgent()
const currentAgent =
messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
yield* compact.create({
sessionID: ctx.params.sessionID,
agent: currentAgent,
model: {
providerID: ctx.payload.providerID,
modelID: ctx.payload.modelID,
},
auto: ctx.payload.auto ?? false,
})
yield* prompt.loop({ sessionID: ctx.params.sessionID })
}).pipe(
Effect.provide(SessionRevert.defaultLayer),
Effect.provide(SessionCompaction.defaultLayer),
Effect.provide(SessionPrompt.defaultLayer),
Effect.provide(Agent.defaultLayer),
Effect.provide(Session.defaultLayer),
),
),
),
)
return true
})
const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof PromptPayload.Type
}) {
const instance = yield* InstanceState.context
return HttpServerResponse.stream(
Stream.fromEffect(
Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.prompt({
...ctx.payload,
sessionID: ctx.params.sessionID,
} as unknown as SessionPrompt.PromptInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
),
).pipe(
Stream.map((message) => JSON.stringify(message)),
Stream.encodeText,
),
{ contentType: "application/json" },
)
})
const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof PromptPayload.Type
}) {
const instance = yield* InstanceState.context
yield* Effect.sync(() => {
Instance.restore(instance, () => {
void AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
).catch((error) => {
log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error })
void Bus.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({
message: error instanceof Error ? error.message : String(error),
}).toObject(),
})
})
})
})
return HttpApiSchema.NoContent.make()
})
const command = Effect.fn("SessionHttpApi.command")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof CommandPayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
})
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ShellPayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
})
const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof RevertPayload.Type
}) {
const instance = yield* InstanceState.context
log.info("revert", ctx.payload)
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionRevert.Service.use((svc) => svc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })).pipe(
Effect.provide(SessionRevert.defaultLayer),
),
),
),
)
})
const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionRevert.Service.use((svc) => svc.unrevert({ sessionID: ctx.params.sessionID })).pipe(
Effect.provide(SessionRevert.defaultLayer),
),
),
),
)
})
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
params: { permissionID: PermissionID }
payload: typeof PermissionResponsePayload.Type
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }),
).pipe(Effect.provide(Permission.defaultLayer)),
),
),
)
return true
})
const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(ctx.params.sessionID)
yield* session.removeMessage(ctx.params)
}).pipe(Effect.provide(SessionRunState.defaultLayer), Effect.provide(Session.defaultLayer)),
),
),
)
return true
})
const deletePart = Effect.fn("SessionHttpApi.deletePart")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
}) {
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.removePart(ctx.params)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
return true
})
const updatePart = Effect.fn("SessionHttpApi.updatePart")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
payload: typeof MessageV2.Part.Type
}) {
const payload = ctx.payload as MessageV2.Part
if (
payload.id !== ctx.params.partID ||
payload.messageID !== ctx.params.messageID ||
payload.sessionID !== ctx.params.sessionID
) {
throw new Error(
`Part mismatch: body.id='${payload.id}' vs partID='${ctx.params.partID}', body.messageID='${payload.messageID}' vs messageID='${ctx.params.messageID}', body.sessionID='${payload.sessionID}' vs sessionID='${ctx.params.sessionID}'`,
)
}
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.updatePart(payload)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
})
return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
handlers
.handle("list", list)
.handle("status", status)
.handle("get", get)
.handle("children", children)
.handle("todo", todo)
.handle("diff", diff)
.handle("messages", messages)
.handle("message", message)
.handleRaw("create", createRaw)
.handle("remove", remove)
.handle("update", update)
.handle("fork", fork)
.handle("abort", abort)
.handle("init", init)
.handle("share", share)
.handle("unshare", unshare)
.handle("summarize", summarize)
.handle("prompt", prompt)
.handle("promptAsync", promptAsync)
.handle("command", command)
.handle("shell", shell)
.handle("revert", revert)
.handle("unrevert", unrevert)
.handle("permissionRespond", permissionRespond)
.handle("deleteMessage", deleteMessage)
.handle("deletePart", deletePart)
.handle("updatePart", updatePart),
)
}),
).pipe(
Layer.provide(Session.defaultLayer),
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(Todo.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
)
@@ -0,0 +1,136 @@
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Database } from "@/storage/db"
import { asc } from "drizzle-orm"
import { and } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { lte } from "drizzle-orm"
import { not } from "drizzle-orm"
import { or } from "drizzle-orm"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const root = "/sync"
const ReplayEvent = Schema.Struct({
id: Schema.String,
aggregateID: Schema.String,
seq: Schema.Number,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "SyncReplayEvent" })
const ReplayPayload = Schema.Struct({
directory: Schema.String,
events: Schema.NonEmptyArray(ReplayEvent),
}).annotate({ identifier: "SyncReplayInput" })
const ReplayResponse = Schema.Struct({
sessionID: Schema.String,
}).annotate({ identifier: "SyncReplayResponse" })
const HistoryPayload = Schema.Record(Schema.String, Schema.Number)
const HistoryEvent = Schema.Struct({
id: Schema.String,
aggregate_id: Schema.String,
seq: Schema.Number,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "SyncHistoryEvent" })
export const SyncPaths = {
start: `${root}/start`,
replay: `${root}/replay`,
history: `${root}/history`,
} as const
export const SyncApi = HttpApi.make("sync")
.add(
HttpApiGroup.make("sync")
.add(
HttpApiEndpoint.post("start", SyncPaths.start, {
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.start",
summary: "Start workspace sync",
description: "Start sync loops for workspaces in the current project that have active sessions.",
}),
),
HttpApiEndpoint.post("replay", SyncPaths.replay, {
payload: ReplayPayload,
success: ReplayResponse,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.replay",
summary: "Replay sync events",
description: "Validate and replay a complete sync event history.",
}),
),
HttpApiEndpoint.post("history", SyncPaths.history, {
payload: HistoryPayload,
success: Schema.Array(HistoryEvent),
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.history.list",
summary: "List sync events",
description:
"List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "sync",
description: "Experimental HttpApi sync routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const syncHandlers = Layer.unwrap(
Effect.gen(function* () {
const start = Effect.fn("SyncHttpApi.start")(function* () {
startWorkspaceSyncing((yield* InstanceState.context).project.id)
return true
})
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: event.id,
aggregateID: event.aggregateID,
seq: event.seq,
type: event.type,
data: { ...event.data },
}))
SyncEvent.replayAll(events)
return { sessionID: events[0].aggregateID }
})
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
const exclude = Object.entries(ctx.payload)
return Database.use((db) =>
db
.select()
.from(EventTable)
.where(
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined,
)
.orderBy(asc(EventTable.seq))
.all(),
)
})
return HttpApiBuilder.group(SyncApi, "sync", (handlers) =>
handlers.handle("start", start).handle("replay", replay).handle("history", history),
)
}),
)
@@ -0,0 +1,290 @@
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "../tui"
import { Authorization } from "./auth"
const root = "/tui"
const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" })
const TuiRequestPayload = Schema.Struct({
path: Schema.String,
body: Schema.Unknown,
}).annotate({ identifier: "TuiRequest" })
const TuiPublishPayload = Schema.Union([
Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }),
Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }),
Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }),
Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }),
]).annotate({ identifier: "TuiEventInput" })
const commandAliases = {
session_new: "session.new",
session_share: "session.share",
session_interrupt: "session.interrupt",
session_compact: "session.compact",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_line_up: "session.line.up",
messages_line_down: "session.line.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",
messages_last: "session.last",
agent_cycle: "agent.cycle",
} as const
export const TuiPaths = {
appendPrompt: `${root}/append-prompt`,
openHelp: `${root}/open-help`,
openSessions: `${root}/open-sessions`,
openThemes: `${root}/open-themes`,
openModels: `${root}/open-models`,
submitPrompt: `${root}/submit-prompt`,
clearPrompt: `${root}/clear-prompt`,
executeCommand: `${root}/execute-command`,
showToast: `${root}/show-toast`,
publish: `${root}/publish`,
selectSession: `${root}/select-session`,
controlNext: `${root}/control/next`,
controlResponse: `${root}/control/response`,
} as const
export const TuiApi = HttpApi.make("tui")
.add(
HttpApiGroup.make("tui")
.add(
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
payload: TuiEvent.PromptAppend.properties,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.appendPrompt",
summary: "Append TUI prompt",
description: "Append prompt to the TUI.",
}),
),
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openHelp",
summary: "Open help dialog",
description: "Open the help dialog in the TUI to display user assistance information.",
}),
),
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openSessions",
summary: "Open sessions dialog",
description: "Open the session dialog.",
}),
),
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openThemes",
summary: "Open themes dialog",
description: "Open the theme dialog.",
}),
),
HttpApiEndpoint.post("openModels", TuiPaths.openModels, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.openModels",
summary: "Open models dialog",
description: "Open the model dialog.",
}),
),
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.submitPrompt",
summary: "Submit TUI prompt",
description: "Submit the prompt.",
}),
),
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "tui.clearPrompt",
summary: "Clear TUI prompt",
description: "Clear the prompt.",
}),
),
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
payload: CommandPayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.executeCommand",
summary: "Execute TUI command",
description: "Execute a TUI command.",
}),
),
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
payload: TuiEvent.ToastShow.properties,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.showToast",
summary: "Show TUI toast",
description: "Show a toast notification in the TUI.",
}),
),
HttpApiEndpoint.post("publish", TuiPaths.publish, {
payload: TuiPublishPayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.publish",
summary: "Publish TUI event",
description: "Publish a TUI event.",
}),
),
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
payload: TuiEvent.SessionSelect.properties,
success: Schema.Boolean,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.selectSession",
summary: "Select session",
description: "Navigate the TUI to display the specified session.",
}),
),
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { success: TuiRequestPayload }).annotateMerge(
OpenApi.annotations({
identifier: "tui.control.next",
summary: "Get next TUI request",
description: "Retrieve the next TUI request from the queue for processing.",
}),
),
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
payload: Schema.Unknown,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "tui.control.response",
summary: "Submit TUI response",
description: "Submit a response to the TUI request queue to complete a pending request.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." }))
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const tuiHandlers = Layer.unwrap(
Effect.gen(function* () {
const bus = yield* Bus.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
bus.publish(TuiEvent.CommandExecute, { command })
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
payload: typeof TuiEvent.PromptAppend.properties.Type
}) {
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
return true
})
const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () {
yield* publishCommand("help.show")
return true
})
const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () {
yield* publishCommand("session.list")
return true
})
const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () {
yield* publishCommand("session.list")
return true
})
const openModels = Effect.fn("TuiHttpApi.openModels")(function* () {
yield* publishCommand("model.list")
return true
})
const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () {
yield* publishCommand("prompt.submit")
return true
})
const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () {
yield* publishCommand("prompt.clear")
return true
})
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
payload: typeof CommandPayload.Type
}) {
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
return true
})
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
payload: typeof TuiEvent.ToastShow.properties.Type
}) {
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
return true
})
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
if (ctx.payload.type === TuiEvent.PromptAppend.type)
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.CommandExecute.type)
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.SessionSelect.type)
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
return true
})
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
payload: typeof TuiEvent.SessionSelect.properties.Type
}) {
const row = yield* Effect.sync(() =>
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(),
),
)
if (!row) return yield* new HttpApiError.NotFound({})
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
return true
})
const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () {
return yield* Effect.promise(() => nextTuiRequest())
})
const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) {
submitTuiResponse(ctx.payload)
return true
})
return HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
handlers
.handle("appendPrompt", appendPrompt)
.handle("openHelp", openHelp)
.handle("openSessions", openSessions)
.handle("openThemes", openThemes)
.handle("openModels", openModels)
.handle("submitPrompt", submitPrompt)
.handle("clearPrompt", clearPrompt)
.handle("executeCommand", executeCommand)
.handle("showToast", showToast)
.handle("publish", publish)
.handle("selectSession", selectSession)
.handle("controlNext", controlNext)
.handle("controlResponse", controlResponse),
)
}),
)

Some files were not shown because too many files have changed in this diff Show More