Compare commits

..

35 Commits

Author SHA1 Message Date
Aiden Cline f0bd70b2e9 test: deflake 'cancel interrupts....' test 2026-04-29 17:23:36 -05:00
Aiden Cline a740d2c667 fix: adjust azure defaults to closer match openai to prevent Item .. of type 'reasoning' was provided without its required following item (#25007) 2026-04-29 16:49:28 -05:00
Aiden Cline 588261076a fix: make deepseek string check a bit looser (#25012) 2026-04-29 16:47:45 -05:00
Ruben De Smet 639e27c3ce feat: add Mistral Medium 3.5 with reasoning support (#24996) 2026-04-29 16:26:24 -05:00
opencode-agent[bot] 1124ae17b4 chore: generate 2026-04-29 20:52:19 +00:00
Kit Langton 9db5890ce5 Refactor HttpApi workspace routing and proxy boundaries (#25006) 2026-04-29 16:50:54 -04:00
James Long 293877cb7e fix(core): reconnect editor context for session directory (#24984) 2026-04-29 15:11:44 -04:00
opencode-agent[bot] c480006554 chore: generate 2026-04-29 18:17:10 +00:00
Aiden Cline 6aa8e894b1 chore: rm broken codesearch tool (#24992) 2026-04-29 13:15:44 -05:00
Aiden Cline 00bb9836a6 tweak: adjust order of system prompt instructions: Global, Project, Skills (#24974) 2026-04-29 10:55:53 -05:00
github-actions[bot] 71f9189607 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/24964#issuecomment-4345349260
2026-04-29 15:54:34 +00:00
opencode-agent[bot] a3f7ea2555 chore: generate 2026-04-29 13:47:48 +00:00
Kit Langton d3df8e1180 test(httpapi): clean up SDK parity tests 2026-04-29 09:46:17 -04:00
opencode-agent[bot] df147b65fd chore: generate 2026-04-29 13:36:05 +00:00
Kit Langton 6015084fa2 Prepare Effect HttpApi backend parity (#24853) 2026-04-29 09:34:50 -04:00
Brendan Allan 65ba1f6c13 fix(download): update beta asset names from electron to desktop (#24908) 2026-04-29 14:50:06 +08:00
Brendan Allan d37e5af57d Disable Windows update code signature verification (#24905) 2026-04-29 14:40:37 +08:00
spark4862 d71b827d8c fix(session): remap compaction tail_start_id when forking (#24898)
Co-authored-by: spark4862 <spark4862@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-29 00:23:56 -05:00
Aiden Cline 504ca3d3d8 feat: make it easier to toggle on/off paste summary in the tui (#24869) 2026-04-28 23:55:14 -05:00
Mike a8c74c04de docs: add Atomic Chat provider section (#23069) 2026-04-28 23:10:41 -05:00
Brendan Allan f6b4f54216 refactor(app): convert getProjectAvatarSource to early returns (#24896) 2026-04-29 12:00:13 +08:00
Dax Raad fc0e3c65b3 ignore 2026-04-28 23:07:46 -04:00
Dax Raad 23b8ed788e ignore 2026-04-28 23:03:48 -04:00
Dax Raad 3bd890f46b ignore: ideas 2026-04-28 23:00:07 -04:00
Luke Parker 9fbeafb63e fix: clear timeout after promise rejection (#24864) 2026-04-28 23:37:12 +00:00
opencode-agent[bot] 91bd295209 chore: generate 2026-04-28 23:11:59 +00:00
Luke Parker d4bf70be06 fix(bash): memory leak - release parsed syntax trees (#24861)
Co-authored-by: jiwenshang <jiwenshang@xiaohongshu.com>
2026-04-28 23:10:48 +00:00
opencode-agent[bot] ae8904c4ff chore: generate 2026-04-28 20:50:56 +00:00
James Long 9209c04370 feat(core): filter sessions by path and add setting to disable (#24849) 2026-04-28 16:49:13 -04:00
Kit Langton 379e7f3f20 test(httpapi): cover sdk effect routes (#24836) 2026-04-28 16:34:06 -04:00
opencode-agent[bot] 366d11e1f8 chore: generate 2026-04-28 20:33:13 +00:00
Kit Langton 58836e75f0 fix(httpapi): wire global and control handlers (#24835) 2026-04-28 16:31:45 -04:00
Aiden Cline 0acac216ae fix(copilot): ensure available variants sync from api (#24734)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-28 14:58:51 -05:00
opencode 276d162044 sync release versions for v1.14.29 2026-04-28 18:34:48 +00:00
opencode-agent[bot] 1b0ed983c5 chore: generate 2026-04-28 18:25:23 +00:00
241 changed files with 8015 additions and 3831 deletions
+1
View File
@@ -17,6 +17,7 @@ ariane-emory
-danieljoshuanazareth
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
edemaine
fahreddinozcan
-florianleibert
fwang
iamdavidhill
+16 -16
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.28",
"version": "1.14.29",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.28",
"version": "1.14.29",
"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.28",
"version": "1.14.29",
"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.28",
"version": "1.14.29",
"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.28",
"version": "1.14.29",
"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.28",
"version": "1.14.29",
"bin": {
"opencode": "./bin/opencode",
},
@@ -226,7 +226,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.28",
"version": "1.14.29",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -259,7 +259,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.28",
"version": "1.14.29",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -303,7 +303,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.28",
"version": "1.14.29",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -332,7 +332,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.28",
"version": "1.14.29",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -348,7 +348,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.28",
"version": "1.14.29",
"bin": {
"opencode": "./bin/opencode",
},
@@ -491,7 +491,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.28",
"version": "1.14.29",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -526,7 +526,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.28",
"version": "1.14.29",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -541,7 +541,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.28",
"version": "1.14.29",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -576,7 +576,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.28",
"version": "1.14.29",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -625,7 +625,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.28",
"version": "1.14.29",
"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.28",
"version": "1.14.29",
"description": "",
"type": "module",
"exports": {
-2
View File
@@ -721,8 +721,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "جلب محتوى من عنوان URL",
"settings.permissions.tool.websearch.title": "بحث الويب",
"settings.permissions.tool.websearch.description": "البحث في الويب",
"settings.permissions.tool.codesearch.title": "بحث الكود",
"settings.permissions.tool.codesearch.description": "البحث عن كود على الويب",
"settings.permissions.tool.external_directory.title": "دليل خارجي",
"settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع",
"settings.permissions.tool.doom_loop.title": "حلقة الموت",
-2
View File
@@ -732,8 +732,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Buscar conteúdo de uma URL",
"settings.permissions.tool.websearch.title": "Pesquisa Web",
"settings.permissions.tool.websearch.description": "Pesquisar na web",
"settings.permissions.tool.codesearch.title": "Pesquisa de Código",
"settings.permissions.tool.codesearch.description": "Pesquisar código na web",
"settings.permissions.tool.external_directory.title": "Diretório Externo",
"settings.permissions.tool.external_directory.description": "Acessar arquivos fora do diretório do projeto",
"settings.permissions.tool.doom_loop.title": "Loop Infinito",
-2
View File
@@ -806,8 +806,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Preuzmi sadržaj sa URL-a",
"settings.permissions.tool.websearch.title": "Web pretraga",
"settings.permissions.tool.websearch.description": "Pretražuj web",
"settings.permissions.tool.codesearch.title": "Pretraga koda",
"settings.permissions.tool.codesearch.description": "Pretraži kod na webu",
"settings.permissions.tool.external_directory.title": "Vanjski direktorij",
"settings.permissions.tool.external_directory.description": "Pristup datotekama izvan direktorija projekta",
"settings.permissions.tool.doom_loop.title": "Beskonačna petlja",
-2
View File
@@ -800,8 +800,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Hent indhold fra en URL",
"settings.permissions.tool.websearch.title": "Websøgning",
"settings.permissions.tool.websearch.description": "Søg på nettet",
"settings.permissions.tool.codesearch.title": "Kodesøgning",
"settings.permissions.tool.codesearch.description": "Søg kode på nettet",
"settings.permissions.tool.external_directory.title": "Ekstern mappe",
"settings.permissions.tool.external_directory.description": "Få adgang til filer uden for projektmappen",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
-2
View File
@@ -743,8 +743,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Inhalt von einer URL abrufen",
"settings.permissions.tool.websearch.title": "Web-Suche",
"settings.permissions.tool.websearch.description": "Das Web durchsuchen",
"settings.permissions.tool.codesearch.title": "Code-Suche",
"settings.permissions.tool.codesearch.description": "Code im Web durchsuchen",
"settings.permissions.tool.external_directory.title": "Externes Verzeichnis",
"settings.permissions.tool.external_directory.description": "Zugriff auf Dateien außerhalb des Projektverzeichnisses",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
-2
View File
@@ -920,8 +920,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Fetch content from a URL",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Search the web",
"settings.permissions.tool.codesearch.title": "Code Search",
"settings.permissions.tool.codesearch.description": "Search code on the web",
"settings.permissions.tool.external_directory.title": "External Directory",
"settings.permissions.tool.external_directory.description": "Access files outside the project directory",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
-2
View File
@@ -813,8 +813,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Obtener contenido de una URL",
"settings.permissions.tool.websearch.title": "Búsqueda Web",
"settings.permissions.tool.websearch.description": "Buscar en la web",
"settings.permissions.tool.codesearch.title": "Búsqueda de Código",
"settings.permissions.tool.codesearch.description": "Buscar código en la web",
"settings.permissions.tool.external_directory.title": "Directorio Externo",
"settings.permissions.tool.external_directory.description": "Acceder a archivos fuera del directorio del proyecto",
"settings.permissions.tool.doom_loop.title": "Bucle Infinito",
-2
View File
@@ -741,8 +741,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Récupérer le contenu d'une URL",
"settings.permissions.tool.websearch.title": "Recherche Web",
"settings.permissions.tool.websearch.description": "Rechercher sur le web",
"settings.permissions.tool.codesearch.title": "Recherche de code",
"settings.permissions.tool.codesearch.description": "Rechercher du code sur le web",
"settings.permissions.tool.external_directory.title": "Répertoire externe",
"settings.permissions.tool.external_directory.description": "Accéder aux fichiers en dehors du répertoire du projet",
"settings.permissions.tool.doom_loop.title": "Boucle infernale",
-2
View File
@@ -727,8 +727,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "URLからコンテンツを取得",
"settings.permissions.tool.websearch.title": "Web検索",
"settings.permissions.tool.websearch.description": "ウェブを検索",
"settings.permissions.tool.codesearch.title": "コード検索",
"settings.permissions.tool.codesearch.description": "ウェブ上のコードを検索",
"settings.permissions.tool.external_directory.title": "外部ディレクトリ",
"settings.permissions.tool.external_directory.description": "プロジェクトディレクトリ外のファイルへのアクセス",
"settings.permissions.tool.doom_loop.title": "無限ループ",
-2
View File
@@ -722,8 +722,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "URL에서 콘텐츠 가져오기",
"settings.permissions.tool.websearch.title": "웹 검색",
"settings.permissions.tool.websearch.description": "웹 검색",
"settings.permissions.tool.codesearch.title": "코드 검색",
"settings.permissions.tool.codesearch.description": "웹에서 코드 검색",
"settings.permissions.tool.external_directory.title": "외부 디렉터리",
"settings.permissions.tool.external_directory.description": "프로젝트 디렉터리 외부의 파일에 액세스",
"settings.permissions.tool.doom_loop.title": "무한 반복",
-2
View File
@@ -807,8 +807,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Hent innhold fra en URL",
"settings.permissions.tool.websearch.title": "Websøk",
"settings.permissions.tool.websearch.description": "Søk på nettet",
"settings.permissions.tool.codesearch.title": "Kodesøk",
"settings.permissions.tool.codesearch.description": "Søk etter kode på nettet",
"settings.permissions.tool.external_directory.title": "Ekstern mappe",
"settings.permissions.tool.external_directory.description": "Få tilgang til filer utenfor prosjektmappen",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
-2
View File
@@ -729,8 +729,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Pobieranie zawartości z adresu URL",
"settings.permissions.tool.websearch.title": "Wyszukiwanie w sieci",
"settings.permissions.tool.websearch.description": "Przeszukiwanie sieci",
"settings.permissions.tool.codesearch.title": "Wyszukiwanie kodu",
"settings.permissions.tool.codesearch.description": "Przeszukiwanie kodu w sieci",
"settings.permissions.tool.external_directory.title": "Katalog zewnętrzny",
"settings.permissions.tool.external_directory.description": "Dostęp do plików poza katalogiem projektu",
"settings.permissions.tool.doom_loop.title": "Zapętlenie",
-2
View File
@@ -808,8 +808,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Получение контента по URL",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Поиск в интернете",
"settings.permissions.tool.codesearch.title": "Code Search",
"settings.permissions.tool.codesearch.description": "Поиск кода в интернете",
"settings.permissions.tool.external_directory.title": "Внешняя директория",
"settings.permissions.tool.external_directory.description": "Доступ к файлам вне директории проекта",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
-2
View File
@@ -796,8 +796,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "ดึงเนื้อหาจาก URL",
"settings.permissions.tool.websearch.title": "ค้นหาเว็บ",
"settings.permissions.tool.websearch.description": "ค้นหาบนเว็บ",
"settings.permissions.tool.codesearch.title": "ค้นหาโค้ด",
"settings.permissions.tool.codesearch.description": "ค้นหาโค้ดบนเว็บ",
"settings.permissions.tool.external_directory.title": "ไดเรกทอรีภายนอก",
"settings.permissions.tool.external_directory.description": "เข้าถึงไฟล์นอกไดเรกทอรีโปรเจกต์",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
-2
View File
@@ -816,8 +816,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Bir URL'den içerik getir",
"settings.permissions.tool.websearch.title": "Web Ara",
"settings.permissions.tool.websearch.description": "Web'de ara",
"settings.permissions.tool.codesearch.title": "Kod Ara",
"settings.permissions.tool.codesearch.description": "Web'de kod ara",
"settings.permissions.tool.external_directory.title": "Harici Dizin",
"settings.permissions.tool.external_directory.description": "Proje dizini dışındaki dosyalara eriş",
"settings.permissions.tool.doom_loop.title": "Sonsuz Döngü",
-2
View File
@@ -793,8 +793,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "从 URL 获取内容",
"settings.permissions.tool.websearch.title": "网页搜索",
"settings.permissions.tool.websearch.description": "搜索网页",
"settings.permissions.tool.codesearch.title": "代码搜索",
"settings.permissions.tool.codesearch.description": "在网上搜索代码",
"settings.permissions.tool.external_directory.title": "外部目录",
"settings.permissions.tool.external_directory.description": "访问项目目录之外的文件",
"settings.permissions.tool.doom_loop.title": "死循环",
-2
View File
@@ -789,8 +789,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "從 URL 取得內容",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "搜尋網頁",
"settings.permissions.tool.codesearch.title": "Code Search",
"settings.permissions.tool.codesearch.description": "在網路上搜尋程式碼",
"settings.permissions.tool.external_directory.title": "外部目錄",
"settings.permissions.tool.external_directory.description": "存取專案目錄之外的檔案",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
@@ -20,9 +20,10 @@ import { childSessionOnPath, hasProjectPermissions } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
return id === OPENCODE_PROJECT_ID
? "https://opencode.ai/favicon.svg"
: (icon?.override ?? (icon?.color ? undefined : icon?.url))
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
if (icon?.override) return icon?.override
if (icon?.color) return undefined
return icon?.url
}
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.28",
"version": "1.14.29",
"type": "module",
"license": "MIT",
"scripts": {
@@ -11,12 +11,12 @@ const prodAssetNames: Record<string, string> = {
} satisfies Record<DownloadPlatform, string>
const betaAssetNames: Record<string, string> = {
"darwin-aarch64-dmg": "opencode-electron-mac-arm64.dmg",
"darwin-x64-dmg": "opencode-electron-mac-x64.dmg",
"windows-x64-nsis": "opencode-electron-win-x64.exe",
"linux-x64-deb": "opencode-electron-linux-amd64.deb",
"linux-x64-appimage": "opencode-electron-linux-x86_64.AppImage",
"linux-x64-rpm": "opencode-electron-linux-x86_64.rpm",
"darwin-aarch64-dmg": "opencode-desktop-mac-arm64.dmg",
"darwin-x64-dmg": "opencode-desktop-mac-x64.dmg",
"windows-x64-nsis": "opencode-desktop-win-x64.exe",
"linux-x64-deb": "opencode-desktop-linux-amd64.deb",
"linux-x64-appimage": "opencode-desktop-linux-x86_64.AppImage",
"linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
} satisfies Record<DownloadPlatform, string>
// Doing this on the server lets us preserve the original name for platforms we don't care to rename for
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.28",
"version": "1.14.29",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.28",
"version": "1.14.29",
"$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.28",
"version": "1.14.29",
"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.28",
"version": "1.14.29",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -63,6 +63,7 @@ const getBase = (): Configuration => ({
sign: signWindows,
},
target: ["nsis"],
verifyUpdateCodeSignature: false,
},
nsis: {
oneClick: false,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.28",
"version": "1.14.29",
"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.28",
"version": "1.14.29",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.28",
"version": "1.14.29",
"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.28"
version = "1.14.29"
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.28/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/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.28/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.28/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/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.28/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/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.28/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.29/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.28",
"version": "1.14.29",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1
View File
@@ -78,6 +78,7 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers.
- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers.
- Use `Effect.callback` for callback-based APIs.
- Use `Effect.void` instead of `Effect.succeed(undefined)` or `Effect.succeed(void 0)`.
- Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`.
## Module conventions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.28",
"version": "1.14.29",
"name": "opencode",
"type": "module",
"license": "MIT",
+52
View File
@@ -0,0 +1,52 @@
#!/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,6 +129,14 @@ 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:
-1
View File
@@ -286,7 +286,6 @@ emitted JSON Schema must stay byte-identical.
- [x] `src/tool/apply_patch.ts`
- [x] `src/tool/bash.ts`
- [x] `src/tool/codesearch.ts`
- [x] `src/tool/edit.ts`
- [x] `src/tool/glob.ts`
- [x] `src/tool/grep.ts`
-2
View File
@@ -40,7 +40,6 @@ These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `apply_patch.ts`
- [x] `bash.ts`
- [x] `codesearch.ts`
- [x] `edit.ts`
- [x] `glob.ts`
- [x] `grep.ts`
@@ -79,7 +78,6 @@ Notable items that are already effectively on the target path and do not need se
- `apply_patch.ts`
- `grep.ts`
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
- `edit.ts`
+67
View File
@@ -0,0 +1,67 @@
// @ts-nocheck
import { OpenCode } from "@opencode-ai/core"
import { ReadTool } from "@opencode-ai/core/tools"
const opencode = OpenCode.make({})
opencode.tool.add(ReadTool)
opencode.tool.add({
name: "bash",
schema: {
type: "object",
properties: {
command: {
type: "string",
description: "The command to run.",
},
},
required: ["command"],
},
execute(input, ctx) {},
})
opencode.auth.add({
provider: "openai",
type: "api",
value: process.env.OPENAI_API_KEY,
})
opencode.agent.add({
name: "build",
permissions: [],
model: {
id: "gpt-5-5",
provider: "openai",
variant: "xhigh",
},
})
const sessionID = await opencode.session.create({
agent: "build",
})
opencode.subscribe((event) => {
console.log(event)
})
await opencode.session.prompt({
sessionID,
text: "hey what is up",
})
await opencode.session.prompt({
sessionID,
text: "what is up with this",
files: [
{
mime: "image/png",
uri: "data:image/png;base64,xxxx",
},
],
})
await opencode.session.wait()
console.log(await opencode.session.messages(sessionID))
+3 -4
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.Number),
temperature: Schema.optional(Schema.Number),
topP: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Finite),
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.Number),
steps: Schema.optional(Schema.Finite),
})
.annotate({ identifier: "Agent" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -169,7 +169,6 @@ export const layer = Layer.effect(
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
+2 -1
View File
@@ -1,6 +1,7 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt } from "@/util/schema"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -14,7 +15,7 @@ export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
expires: NonNegativeInt,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
+12
View File
@@ -34,4 +34,16 @@ 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"
-1
View File
@@ -28,7 +28,6 @@ const AVAILABLE_PERMISSIONS = [
"task",
"todowrite",
"websearch",
"codesearch",
"lsp",
"skill",
]
-9
View File
@@ -19,7 +19,6 @@ import { ReadTool } from "../../tool/read"
import { WebFetchTool } from "../../tool/webfetch"
import { EditTool } from "../../tool/edit"
import { WriteTool } from "../../tool/write"
import { CodeSearchTool } from "../../tool/codesearch"
import { WebSearchTool } from "../../tool/websearch"
import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
@@ -145,13 +144,6 @@ function edit(info: ToolProps<typeof EditTool>) {
)
}
function codesearch(info: ToolProps<typeof CodeSearchTool>) {
inline({
icon: "◇",
title: `Exa Code Search "${info.input.query}"`,
})
}
function websearch(info: ToolProps<typeof WebSearchTool>) {
inline({
icon: "◈",
@@ -415,7 +407,6 @@ export const RunCommand = cmd({
if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
if (part.tool === "task") return task(props<typeof TaskTool>(part))
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
+28
View File
@@ -301,6 +301,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
)
// Update terminal window title based on current route and session
createEffect(() => {
@@ -736,6 +739,31 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
{
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
value: "app.toggle.paste_summary",
category: "System",
onSelect: (dialog) => {
setPasteSummaryEnabled((prev) => {
const next = !prev
kv.set("paste_summary_enabled", next)
return next
})
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,11 +32,14 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
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 [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 currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
@@ -12,7 +12,7 @@ import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { useEditorContext } from "@tui/context/editor"
import { useEditorContext, type EditorSelection } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
@@ -84,6 +84,18 @@ function fadeColor(color: RGBA, alpha: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
}
function getEditorSelectionKey(selection: EditorSelection) {
return [
selection.filePath,
selection.text,
selection.source ?? "",
selection.selection.start.line,
selection.selection.start.character,
selection.selection.end.line,
selection.selection.end.character,
].join("-")
}
let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) {
@@ -135,6 +147,7 @@ export function Prompt(props: PromptProps) {
if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
})
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
@@ -748,36 +761,38 @@ export function Prompt(props: PromptProps) {
const currentMode = store.mode
const variant = local.model.variant.current()
const editorSelection = fileContextEnabled() ? editor.selection() : undefined
const editorParts = editorSelection
? [
{
id: PartID.ascending(),
type: "text" as const,
text: (() => {
const start = editorSelection.selection.start
const end = editorSelection.selection.end
const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
const editorParts =
editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey
? [
{
id: PartID.ascending(),
type: "text" as const,
text: (() => {
const start = editorSelection.selection.start
const end = editorSelection.selection.end
let text = ""
if (start.line === end.line && start.character === end.character) {
text = `Note: The user opened the file "${editorSelection.filePath}".`
} else if (start.line === end.line) {
text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
} else {
text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
}
let text = ""
if (start.line === end.line && start.character === end.character) {
text = `Note: The user opened the file "${editorSelection.filePath}".`
} else if (start.line === end.line) {
text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
} else {
text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
}
return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n`
})(),
synthetic: true,
metadata: {
kind: "editor_context",
source: editorSelection.source ?? "editor",
filePath: editorSelection.filePath,
selection: editorSelection.selection,
return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n`
})(),
synthetic: true,
metadata: {
kind: "editor_context",
source: editorSelection.source ?? "editor",
filePath: editorSelection.filePath,
selection: editorSelection.selection,
},
},
},
]
: []
]
: []
if (store.mode === "shell") {
void sdk.client.session.shell({
@@ -840,7 +855,7 @@ export function Prompt(props: PromptProps) {
],
})
.catch(() => {})
editor.clearSelection()
lastSubmittedEditorSelectionKey = editorSelectionKey
}
history.append({
...store.prompt,
@@ -1209,7 +1224,7 @@ export function Prompt(props: PromptProps) {
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
!sync.data.config.experimental?.disable_paste_summary
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
@@ -75,8 +75,9 @@ type EditorLockFile = {
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext",
init: () => {
init: (props: { WebSocketImpl?: typeof WebSocket }) => {
const mentionListeners = new Set<(mention: EditorMention) => void>()
const WebSocketImpl = props.WebSocketImpl ?? WebSocket
const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined
@@ -87,138 +88,160 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server: undefined,
})
onMount(() => {
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
const pending = new Map<number, string>()
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
let directory = process.cwd()
const pending = new Map<number, string>()
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== 1) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
}
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
const connect = () => {
if (closed) return
const connection = resolveEditorConnection(directory)
if (!connection) {
const dbPath = resolveZedDbPath()
if (!dbPath) {
setStore("status", "disabled")
scheduleReconnect()
return
}
zedSelection ??= resolveZedSelection(dbPath, directory)
.then((result) => {
if (closed || socket) return
if (result.type === "unavailable") return
const selection = result.type === "selection" ? result.selection : undefined
const key = editorSelectionKey(selection)
if (key !== lastZedSelectionKey) {
lastZedSelectionKey = key
setStore("selection", selection)
setStore("status", selection ? "connected" : "disabled")
}
})
.catch(() => {
// Keep the last known Zed selection for transient polling failures.
})
.finally(() => {
zedSelection = undefined
})
scheduleZedPoll()
return
}
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
setStore("status", "connecting")
const current = openEditorSocket(connection, WebSocketImpl)
socket = current
const scheduleReconnect = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
reconnect = setTimeout(connect, delay)
}
const scheduleZedPoll = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, 1000)
}
const connect = () => {
if (closed) return
const connection = resolveEditorConnection()
if (!connection) {
const dbPath = resolveZedDbPath()
if (!dbPath) {
setStore("status", "disabled")
scheduleReconnect()
return
}
zedSelection ??= resolveZedSelection(dbPath)
.then((result) => {
if (closed || socket) return
if (result.type === "unavailable") return
const selection = result.type === "selection" ? result.selection : undefined
const key = editorSelectionKey(selection)
if (key !== lastZedSelectionKey) {
lastZedSelectionKey = key
setStore("selection", selection)
setStore("status", selection ? "connected" : "disabled")
}
})
.catch(() => {
// Keep the last known Zed selection for transient polling failures.
})
.finally(() => {
zedSelection = undefined
})
scheduleZedPoll()
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", { ...selection.data, source: "websocket" })
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
if (closed) return
setStore("status", "connecting")
const current = openEditorSocket(connection)
socket = current
scheduleReconnect()
})
}
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
const scheduleReconnect = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
reconnect = setTimeout(connect, delay)
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
const scheduleZedPoll = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, 1000)
}
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
const reconnectWithDirectory = (nextDirectory?: string) => {
const resolved = nextDirectory || process.cwd()
if (directory === resolved) return
const selection =
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
if (selection?.success) {
setStore("selection", { ...selection.data, source: "websocket" })
return
}
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
if (mention?.success) {
mentionListeners.forEach((listener) => listener(mention.data))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
if (initialize?.success) {
setStore("server", initialize.data)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
if (closed) return
setStore("status", "connecting")
scheduleReconnect()
})
directory = resolved
attempt = 0
pending.clear()
lastZedSelectionKey = undefined
if (reconnect) clearTimeout(reconnect)
reconnect = undefined
if (socket) {
const current = socket
socket = undefined
current.close()
}
setStore("status", "disabled")
setStore("selection", undefined)
setStore("server", undefined)
connect()
}
onMount(() => {
connect()
onCleanup(() => {
@@ -230,7 +253,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return {
enabled() {
return Boolean(resolveEditorConnection() || resolveZedDbPath())
return Boolean(resolveEditorConnection(directory) || resolveZedDbPath())
},
connected() {
return store.status === "connected"
@@ -248,6 +271,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
server() {
return store.server
},
reconnect(directory?: string) {
setStore("selection", undefined)
reconnectWithDirectory(directory)
},
}
},
})
@@ -260,8 +287,16 @@ function parsePort(value: string | undefined) {
return parsed
}
function resolveEditorConnection(): EditorConnection | undefined {
const lock = resolveEditorLockFile()
function resolveEditorConnection(directory: string): EditorConnection | undefined {
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
if (port) {
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
const lock = resolveEditorLockFile(directory)
if (lock) {
return {
url: `ws://127.0.0.1:${lock.port}`,
@@ -269,16 +304,9 @@ function resolveEditorConnection(): EditorConnection | undefined {
source: `lock:${lock.port}`,
}
}
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT)
if (!port) return
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
function resolveEditorLockFile() {
function resolveEditorLockFile(activeDirectory: string) {
const directory = path.join(os.homedir(), ".claude", "ide")
let entries: string[]
@@ -288,10 +316,9 @@ function resolveEditorLockFile() {
return
}
const cwd = process.cwd()
// longest workspace folder that contains cwd; 0 if none match
// longest workspace folder that contains the active session directory; 0 if none match
const bestMatchLength = (lock: EditorLockFile) =>
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, cwd)))
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, activeDirectory)))
const locks = entries
.filter((entry) => entry.endsWith(".lock"))
.map((entry) => readEditorLockFile(path.join(directory, entry)))
@@ -343,10 +370,10 @@ function pathContainsLength(parent: string, child: string) {
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
}
function openEditorSocket(connection: EditorConnection) {
if (!connection.authToken) return new WebSocket(connection.url)
function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
if (!connection.authToken) return new WebSocketImpl(connection.url)
return new WebSocket(connection.url, {
return new WebSocketImpl(connection.url, {
headers: {
"x-claude-code-ide-authorization": connection.authToken,
},
@@ -30,6 +30,8 @@ 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",
@@ -107,10 +109,27 @@ 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":
@@ -360,10 +379,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.clear()
syncedWorkspace = workspace
}
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)))
const projectPromise = project.sync()
const sessionListPromise = projectPromise.then(() => listSessions())
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
@@ -374,7 +391,6 @@ 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,
@@ -479,11 +495,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index]
return undefined
},
query() {
return sessionListQuery()
},
async refresh() {
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)))
const list = await listSessions()
setStore("session", reconcile(list))
},
status(sessionID: string) {
+2 -1
View File
@@ -1,5 +1,6 @@
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
@@ -38,7 +39,7 @@ export const TuiEvent = {
title: Schema.optional(Schema.String),
message: Schema.String,
variant: Schema.Literals(["info", "success", "warning", "error"]),
duration: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
description: "Duration in milliseconds",
}),
}),
@@ -44,13 +44,13 @@ import type { GrepTool } from "@/tool/grep"
import type { EditTool } from "@/tool/edit"
import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { CodeSearchTool } from "@/tool/codesearch"
import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
@@ -180,6 +180,7 @@ export function Session() {
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast()
const sdk = useSDK()
const editor = useEditorContext()
createEffect(() => {
const sessionID = route.sessionID
@@ -207,6 +208,7 @@ export function Session() {
await sync.bootstrap({ fatal: false })
} catch {}
}
editor.reconnect(result.data.directory)
await sync.session.sync(sessionID)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => {
@@ -1565,9 +1567,6 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "webfetch"}>
<WebFetch {...toolprops} />
</Match>
<Match when={props.part.tool === "codesearch"}>
<CodeSearch {...toolprops} />
</Match>
<Match when={props.part.tool === "websearch"}>
<WebSearch {...toolprops} />
</Match>
@@ -1948,15 +1947,6 @@ function WebFetch(props: ToolProps<typeof WebFetchTool>) {
)
}
function CodeSearch(props: ToolProps<typeof CodeSearchTool>) {
const metadata = props.metadata as { results?: number }
return (
<InlineTool icon="◇" pending="Searching code..." complete={props.input.query} part={props.part}>
Exa Code Search "{props.input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
</InlineTool>
)
}
function WebSearch(props: ToolProps<typeof WebSearchTool>) {
const metadata = props.metadata as { numResults?: number }
return (
@@ -350,21 +350,6 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
}
}
if (permission === "codesearch") {
const query = typeof data.query === "string" ? data.query : ""
return {
icon: "◇",
title: `Exa Code Search "${query}"`,
body: (
<Show when={query}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Query: " + query}</text>
</box>
</Show>
),
}
}
if (permission === "external_directory") {
const meta = props.request.metadata ?? {}
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
+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.Number),
top_p: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Finite),
top_p: Schema.optional(Schema.Finite),
prompt: Schema.optional(Schema.String),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "@deprecated Use 'permission' field instead",
@@ -1,10 +1,11 @@
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: Schema.Number,
switchableOrgCount: NonNegativeInt,
}) {
static readonly zod = zod(this)
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { PositiveInt, 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(Schema.Number).annotate({
timeout: Schema.optional(PositiveInt).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(Schema.Number).annotate({
timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
})
@@ -35,7 +35,6 @@ const InputObject = Schema.StructWithRest(
question: Schema.optional(Action),
webfetch: Schema.optional(Action),
websearch: Schema.optional(Action),
codesearch: Schema.optional(Action),
lsp: Schema.optional(Rule),
doom_loop: Schema.optional(Action),
skill: Schema.optional(Rule),
+11 -11
View File
@@ -21,25 +21,25 @@ export const Model = Schema.Struct({
),
cost: Schema.optional(
Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
context_over_200k: Schema.optional(
Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
}),
),
}),
),
limit: Schema.optional(
Schema.Struct({
context: Schema.Number,
input: Schema.optional(Schema.Number),
output: Schema.Number,
context: Schema.Finite,
input: Schema.optional(Schema.Finite),
output: Schema.Finite,
}),
),
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 { type DeepMutable, withStatics } from "@/util/schema"
import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema"
export const Info = Schema.Struct({
path: Schema.String,
added: Schema.Int,
removed: Schema.Int,
added: NonNegativeInt,
removed: NonNegativeInt,
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: Schema.Number,
oldLines: Schema.Number,
newStart: Schema.Number,
newLines: Schema.Number,
oldStart: NonNegativeInt,
oldLines: NonNegativeInt,
newStart: NonNegativeInt,
newLines: NonNegativeInt,
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 { withStatics } from "@/util/schema"
import { NonNegativeInt, 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: Schema.Number,
nanos: Schema.Number,
secs: NonNegativeInt,
nanos: NonNegativeInt,
human: Schema.String,
})
const Stats = Schema.Struct({
elapsed: TimeStats,
searches: Schema.Number,
searches_with_match: Schema.Number,
bytes_searched: Schema.Number,
bytes_printed: Schema.Number,
matched_lines: Schema.Number,
matches: Schema.Number,
searches: NonNegativeInt,
searches_with_match: NonNegativeInt,
bytes_searched: NonNegativeInt,
bytes_printed: NonNegativeInt,
matched_lines: NonNegativeInt,
matches: NonNegativeInt,
})
const PathText = Schema.Struct({
@@ -58,15 +58,15 @@ export const SearchMatch = Schema.Struct({
lines: Schema.Struct({
text: Schema.String,
}),
line_number: Schema.Number,
absolute_offset: Schema.Number,
line_number: NonNegativeInt,
absolute_offset: NonNegativeInt,
submatches: Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
start: Schema.Number,
end: Schema.Number,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}).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(Schema.Number),
binary_offset: Schema.NullOr(NonNegativeInt),
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 { withStatics } from "@/util/schema"
import { NonNegativeInt, 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: Schema.Number,
character: Schema.Number,
line: NonNegativeInt,
character: NonNegativeInt,
})
export const Range = Schema.Struct({
@@ -37,7 +37,7 @@ export type Range = typeof Range.Type
export const Symbol = Schema.Struct({
name: Schema.String,
kind: Schema.Number,
kind: NonNegativeInt,
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: Schema.Number,
kind: NonNegativeInt,
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")
return {
const model: Model = {
id: key,
providerID: "github-copilot",
api: {
@@ -107,8 +107,50 @@ 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(
+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 { withStatics } from "@/util/schema"
import { NonNegativeInt, withStatics } from "@/util/schema"
const log = Log.create({ service: "project" })
@@ -35,9 +35,9 @@ const ProjectCommands = Schema.Struct({
})
const ProjectTime = Schema.Struct({
created: Schema.Number,
updated: Schema.Number,
initialized: Schema.optional(Schema.Number),
created: NonNegativeInt,
updated: NonNegativeInt,
initialized: Schema.optional(NonNegativeInt),
})
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 { withStatics } from "@/util/schema"
import { NonNegativeInt, 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: Schema.Number,
deletions: Schema.Number,
additions: NonNegativeInt,
deletions: NonNegativeInt,
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.Number.annotate({ description: "Auth method index" }),
method: Schema.Finite.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.Number.annotate({ description: "Auth method index" }),
method: Schema.Finite.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.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
context_over_200k: Schema.optional(
Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
}),
),
})
@@ -55,9 +55,9 @@ export const Model = Schema.Struct({
),
cost: Schema.optional(Cost),
limit: Schema.Struct({
context: Schema.Number,
input: Schema.optional(Schema.Number),
output: Schema.Number,
context: Schema.Finite,
input: Schema.optional(Schema.Finite),
output: Schema.Finite,
}),
modalities: Schema.optional(
Schema.Struct({
+18 -13
View File
@@ -848,27 +848,27 @@ const ProviderCapabilities = Schema.Struct({
})
const ProviderCacheCost = Schema.Struct({
read: Schema.Number,
write: Schema.Number,
read: Schema.Finite,
write: Schema.Finite,
})
const ProviderCost = Schema.Struct({
input: Schema.Number,
output: Schema.Number,
input: Schema.Finite,
output: Schema.Finite,
cache: ProviderCacheCost,
experimentalOver200K: Schema.optional(
Schema.Struct({
input: Schema.Number,
output: Schema.Number,
input: Schema.Finite,
output: Schema.Finite,
cache: ProviderCacheCost,
}),
),
})
const ProviderLimit = Schema.Struct({
context: Schema.Number,
input: Schema.optional(Schema.Number),
output: Schema.Number,
context: Schema.Finite,
input: Schema.optional(Schema.Finite),
output: Schema.Finite,
})
export const Model = Schema.Struct({
@@ -1358,7 +1358,9 @@ const layer: Layer.Layer<
)
delete provider.models[modelID]
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
if (!model.variants || Object.keys(model.variants).length === 0) {
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
}
const configVariants = configProvider?.models?.[modelID]?.variants
if (configVariants && model.variants) {
@@ -1464,10 +1466,13 @@ const layer: Layer.Layer<
if (combined) opts.signal = combined
// Strip openai itemId metadata following what codex does
if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
if (
(model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") &&
opts.body &&
opts.method === "POST"
) {
const body = JSON.parse(opts.body as string)
const isAzure = model.providerID.includes("azure")
const keepIds = isAzure && body.store === true
const keepIds = body.store === true
if (!keepIds && Array.isArray(body.input)) {
for (const item of body.input) {
if ("id" in item) {
+15 -13
View File
@@ -197,7 +197,7 @@ function normalizeMessages(
}
// Deepseek requires all assistant messages to have reasoning on them
if (model.api.id.includes("deepseek")) {
if (model.api.id.toLowerCase().includes("deepseek")) {
msgs = msgs.map((msg) => {
if (msg.role !== "assistant") return msg
if (Array.isArray(msg.content)) {
@@ -573,7 +573,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
// https://docs.venice.ai/overview/guides/reasoning-models#reasoning-effort
case "@ai-sdk/openai-compatible":
const efforts = [...WIDELY_SUPPORTED_EFFORTS]
if (model.api.id.includes("deepseek-v4")) {
if (model.api.id.toLowerCase().includes("deepseek-v4")) {
efforts.push("max")
}
return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }]))
@@ -630,16 +630,17 @@ 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 (model.providerID === "github-copilot") {
if (model.api.id.includes("opus-4.7")) {
return Object.fromEntries(["medium"].map((effort) => [effort, { reasoningEffort: effort }]))
}
}
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")
}
return Object.fromEntries(
adaptiveEfforts.map((effort) => [
efforts.map((effort) => [
effort,
{
thinking: {
@@ -759,9 +760,10 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/mistral
// https://docs.mistral.ai/capabilities/reasoning/adjustable
if (!model.capabilities.reasoning) return {}
// Only Mistral Small 4 supports reasoning (mistral-small-2603, mistral-small-latest)
// Only Mistral Small 4 and Medium 3.5 support reasoning
const MISTRAL_REASONING_IDS = ["mistral-small-2603", "mistral-small-latest", "mistral-medium-3.5"]
const mistralId = model.api.id.toLowerCase()
if (!mistralId.includes("mistral-small-2603") && !mistralId.includes("mistral-small-latest")) return {}
if (!MISTRAL_REASONING_IDS.some((id) => mistralId.includes(id))) return {}
return {
high: { reasoningEffort: "high" },
}
@@ -864,7 +866,7 @@ export function options(input: {
}
if (input.model.api.npm === "@ai-sdk/azure") {
result["store"] = true
result["store"] = false
result["promptCacheKey"] = input.sessionID
}
+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 { withStatics } from "@/util/schema"
import { NonNegativeInt, PositiveInt, 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: Schema.Number,
pid: PositiveInt,
})
.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: Schema.Number,
cols: Schema.Number,
rows: PositiveInt,
cols: PositiveInt,
}),
),
}).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: Schema.Number })),
Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: NonNegativeInt })),
Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })),
}
+32
View File
@@ -0,0 +1,32 @@
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",
}
}
+12 -11
View File
@@ -10,6 +10,7 @@ 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" })
@@ -49,20 +50,20 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {
return basicAuth({ username, password })(c, next)
}
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
const skip = c.req.path === "/log"
if (!skip) {
log.info("request", {
export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler {
return async (c, next) => {
const skip = c.req.path === "/log"
if (skip) return next()
const attributes = {
method: c.req.method,
path: c.req.path,
})
...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 {
@@ -0,0 +1,48 @@
const hop = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"host",
])
function sanitize(out: Headers) {
for (const key of hop) out.delete(key)
out.delete("accept-encoding")
out.delete("x-opencode-directory")
out.delete("x-opencode-workspace")
}
export function headers(input: Request | HeadersInit | Record<string, string>, extra?: HeadersInit) {
const raw = input instanceof Request ? input.headers : input
const out = new Headers(raw instanceof Headers ? raw : Object.entries(raw as Record<string, string>))
sanitize(out)
if (!extra) return out
for (const [key, value] of new Headers(extra).entries()) {
out.set(key, value)
}
return out
}
export function websocketProtocols(input: Request | Record<string, string | undefined>) {
const value = input instanceof Request ? input.headers.get("sec-websocket-protocol") : input["sec-websocket-protocol"]
if (!value) return []
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
}
export function websocketTargetURL(url: string | URL) {
const next = new URL(url)
if (next.protocol === "http:") next.protocol = "ws:"
if (next.protocol === "https:") next.protocol = "wss:"
return next.toString()
}
export * as ProxyUtil from "./proxy-util"
+49 -71
View File
@@ -4,51 +4,12 @@ import * as Log from "@opencode-ai/core/util/log"
import * as Fence from "./fence"
import type { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace"
const hop = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"host",
])
import { ProxyUtil } from "./proxy-util"
import { Effect, Stream } from "effect"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
type Msg = string | ArrayBuffer | Uint8Array
function headers(req: Request, extra?: HeadersInit) {
const out = new Headers(req.headers)
for (const key of hop) out.delete(key)
out.delete("accept-encoding")
out.delete("x-opencode-directory")
out.delete("x-opencode-workspace")
if (!extra) return out
for (const [key, value] of new Headers(extra).entries()) {
out.set(key, value)
}
return out
}
function protocols(req: Request) {
const value = req.headers.get("sec-websocket-protocol")
if (!value) return []
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
}
function socket(url: string | URL) {
const next = new URL(url)
if (next.protocol === "http:") next.protocol = "ws:"
if (next.protocol === "https:") next.protocol = "wss:"
return next.toString()
}
function send(ws: { send(data: string | ArrayBuffer | Uint8Array): void }, data: any) {
if (data instanceof Blob) {
return data.arrayBuffer().then((x) => ws.send(x))
@@ -69,7 +30,7 @@ const app = (upgrade: UpgradeWebSocket) =>
ws.close(1011, "missing proxy target")
return
}
remote = new WebSocket(url, protocols(c.req.raw))
remote = new WebSocket(url, ProxyUtil.websocketProtocols(c.req.raw))
remote.binaryType = "arraybuffer"
remote.onopen = () => {
for (const item of queue) remote?.send(item)
@@ -103,40 +64,57 @@ const app = (upgrade: UpgradeWebSocket) =>
const log = Log.create({ service: "server-proxy" })
export async function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
function statusText(response: unknown) {
return (response as { source?: Response }).source?.statusText
}
export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
if (!Workspace.isSyncing(workspaceID)) {
return new Response(`broken sync connection for workspace: ${workspaceID}`, {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
},
})
return Effect.succeed(
new Response(`broken sync connection for workspace: ${workspaceID}`, {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
},
}),
)
}
return fetch(
new Request(url, {
method: req.method,
headers: headers(req, extra),
body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,
redirect: "manual",
signal: req.signal,
}),
).then((res) => {
const sync = Fence.parse(res.headers)
const next = new Headers(res.headers)
return Effect.gen(function* () {
const response = yield* HttpClient.execute(
HttpClientRequest.make(req.method as never)(url, {
headers: ProxyUtil.headers(req, extra),
body:
req.method === "GET" || req.method === "HEAD"
? HttpBody.empty
: HttpBody.raw(req.body, {
contentType: req.headers.get("content-type") ?? undefined,
contentLength: req.headers.get("content-length")
? Number(req.headers.get("content-length"))
: undefined,
}),
}),
)
const next = new Headers(response.headers as HeadersInit)
const sync = Fence.parse(next)
next.delete("content-encoding")
next.delete("content-length")
const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve()
return done.then(async () => {
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: next,
})
if (sync) yield* Effect.promise(() => Fence.wait(workspaceID, sync, req.signal))
const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty)))
return new Response(body, {
status: response.status,
statusText: statusText(response),
headers: next,
})
})
}).pipe(
Effect.provide(FetchHttpClient.layer),
Effect.catch(() => Effect.succeed(new Response(null, { status: 500 }))),
)
}
export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
return Effect.runPromise(httpEffect(url, extra, req, workspaceID))
}
export function websocket(
@@ -150,7 +128,7 @@ export function websocket(
proxy.pathname = "/__workspace_ws"
proxy.search = ""
const next = new Headers(req.headers)
next.set("x-opencode-proxy-url", socket(target))
next.set("x-opencode-proxy-url", ProxyUtil.websocketTargetURL(target))
for (const [key, value] of new Headers(extra).entries()) {
next.set(key, value)
}
@@ -0,0 +1,54 @@
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
@@ -4,6 +4,7 @@ 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" })
@@ -27,8 +28,13 @@ export const EventApi = HttpApi.make("event").add(
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
)
function eventData(data: unknown) {
return `data: ${JSON.stringify(data)}\n\n`
function eventData(data: unknown): Sse.Event {
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
}
}
export const eventRoute = HttpRouter.add(
@@ -47,6 +53,7 @@ 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,10 +1,10 @@
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
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"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/config"
@@ -13,7 +13,7 @@ export const ConfigApi = HttpApi.make("config")
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("get", root, {
success: Config.Info,
success: described(Config.Info, "Get config info"),
}).annotateMerge(
OpenApi.annotations({
identifier: "config.get",
@@ -23,7 +23,8 @@ export const ConfigApi = HttpApi.make("config")
),
HttpApiEndpoint.patch("update", root, {
payload: Config.Info,
success: Config.Info,
success: described(Config.Info, "Successfully updated config"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "config.update",
@@ -32,7 +33,7 @@ export const ConfigApi = HttpApi.make("config")
}),
),
HttpApiEndpoint.get("providers", `${root}/providers`, {
success: Provider.ConfigProvidersResult,
success: described(Provider.ConfigProvidersResult, "List of providers"),
}).annotateMerge(
OpenApi.annotations({
identifier: "config.providers",
@@ -47,6 +48,8 @@ export const ConfigApi = HttpApi.make("config")
description: "Experimental HttpApi config routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -56,32 +59,3 @@ 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,7 +1,8 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"
const AuthParams = Schema.Struct({
providerID: ProviderID,
@@ -12,7 +13,7 @@ const LogQuery = Schema.Struct({
workspace: Schema.optional(Schema.String),
})
const LogInput = Schema.Struct({
export const LogInput = Schema.Struct({
service: Schema.String.annotate({ description: "Service name for the log entry" }),
level: Schema.Union([
Schema.Literal("debug"),
@@ -24,7 +25,7 @@ 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",
@@ -37,7 +38,8 @@ export const ControlApi = HttpApi.make("control").add(
HttpApiEndpoint.put("authSet", ControlPaths.auth, {
params: AuthParams,
payload: Auth.Info,
success: Schema.Boolean,
success: described(Schema.Boolean, "Successfully set authentication credentials"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "auth.set",
@@ -47,7 +49,8 @@ export const ControlApi = HttpApi.make("control").add(
),
HttpApiEndpoint.delete("authRemove", ControlPaths.auth, {
params: AuthParams,
success: Schema.Boolean,
success: described(Schema.Boolean, "Successfully removed authentication credentials"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "auth.remove",
@@ -58,7 +61,8 @@ export const ControlApi = HttpApi.make("control").add(
HttpApiEndpoint.post("log", ControlPaths.log, {
query: LogQuery,
payload: LogInput,
success: Schema.Boolean,
success: described(Schema.Boolean, "Log entry written successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "app.log",
@@ -1,24 +1,20 @@
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 { 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"
import { NonNegativeInt } from "@/util/schema"
import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optionalKey(Schema.String),
switchableOrgCount: Schema.Number,
switchableOrgCount: NonNegativeInt,
}).annotate({ identifier: "ConsoleState" })
const ConsoleOrgOption = Schema.Struct({
@@ -28,25 +24,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" })
})
const ConsoleSwitchPayload = Schema.Struct({
export 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.Record(Schema.String, Schema.Any),
parameters: Schema.Unknown,
}).annotate({ identifier: "ToolListItem" })
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
const ToolListQuery = Schema.Struct({
export const ToolListQuery = Schema.Struct({
provider: ProviderID,
model: ModelID,
})
@@ -57,8 +53,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
const SessionListQuery = Schema.Struct({
const WorktreeList = Schema.Array(Schema.String)
export const SessionListQuery = Schema.Struct({
directory: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
@@ -85,7 +81,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
HttpApiGroup.make("experimental")
.add(
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
success: ConsoleStateResponse,
success: described(ConsoleStateResponse, "Active Console provider metadata"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.console.get",
@@ -94,7 +90,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
success: ConsoleOrgList,
success: described(ConsoleOrgList, "Switchable Console orgs"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.console.listOrgs",
@@ -104,7 +100,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
payload: ConsoleSwitchPayload,
success: Schema.Boolean,
success: described(Schema.Boolean, "Switch success"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
@@ -115,7 +111,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
query: ToolListQuery,
success: ToolList,
success: described(ToolList, "Tools"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tool.list",
@@ -125,7 +122,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
success: ToolIDs,
success: described(ToolIDs, "Tool IDs"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "tool.ids",
@@ -135,7 +133,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
success: WorktreeList,
success: described(WorktreeList, "List of worktree directories"),
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.list",
@@ -145,7 +143,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
payload: Schema.optional(Worktree.CreateInput),
success: Worktree.Info,
success: described(Worktree.Info, "Worktree created"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.create",
@@ -155,7 +154,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
payload: Worktree.RemoveInput,
success: Schema.Boolean,
success: described(Schema.Boolean, "Worktree removed"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.remove",
@@ -165,7 +165,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
payload: Worktree.ResetInput,
success: Schema.Boolean,
success: described(Schema.Boolean, "Worktree reset"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.reset",
@@ -175,7 +176,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
),
HttpApiEndpoint.get("session", ExperimentalPaths.session, {
query: SessionListQuery,
success: Schema.Array(Session.GlobalInfo),
success: described(Schema.Array(Session.GlobalInfo), "List of sessions"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.session.list",
@@ -185,7 +186,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
success: Schema.Record(Schema.String, MCP.Resource),
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.resource.list",
@@ -200,6 +201,8 @@ export const ExperimentalApi = HttpApi.make("experimental")
description: "Experimental HttpApi read-only routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -209,153 +212,3 @@ 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,20 +1,22 @@
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import * as InstanceState from "@/effect/instance-state"
import { LSP } from "@/lsp/lsp"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const FileQuery = Schema.Struct({
export const FileQuery = Schema.Struct({
path: Schema.String,
})
const FindTextQuery = Schema.Struct({
export const FindTextQuery = Schema.Struct({
pattern: Schema.String,
})
const FindFileQuery = Schema.Struct({
export const FindFileQuery = Schema.Struct({
query: Schema.String,
dirs: Schema.optional(Schema.Literals(["true", "false"])),
type: Schema.optional(Schema.Literals(["file", "directory"])),
@@ -23,7 +25,7 @@ const FindFileQuery = Schema.Struct({
),
})
const FindSymbolQuery = Schema.Struct({
export const FindSymbolQuery = Schema.Struct({
query: Schema.String,
})
@@ -42,7 +44,7 @@ export const FileApi = HttpApi.make("file")
.add(
HttpApiEndpoint.get("findText", FilePaths.findText, {
query: FindTextQuery,
success: Schema.Array(Ripgrep.SearchMatch),
success: described(Schema.Array(Ripgrep.SearchMatch), "Matches"),
}).annotateMerge(
OpenApi.annotations({
identifier: "find.text",
@@ -52,7 +54,7 @@ export const FileApi = HttpApi.make("file")
),
HttpApiEndpoint.get("findFile", FilePaths.findFile, {
query: FindFileQuery,
success: Schema.Array(Schema.String),
success: described(Schema.Array(Schema.String), "File paths"),
}).annotateMerge(
OpenApi.annotations({
identifier: "find.files",
@@ -62,7 +64,7 @@ export const FileApi = HttpApi.make("file")
),
HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, {
query: FindSymbolQuery,
success: Schema.Array(LSP.Symbol),
success: described(Schema.Array(LSP.Symbol), "Symbols"),
}).annotateMerge(
OpenApi.annotations({
identifier: "find.symbols",
@@ -72,7 +74,7 @@ export const FileApi = HttpApi.make("file")
),
HttpApiEndpoint.get("list", FilePaths.list, {
query: FileQuery,
success: Schema.Array(File.Node),
success: described(Schema.Array(File.Node), "Files and directories"),
}).annotateMerge(
OpenApi.annotations({
identifier: "file.list",
@@ -82,7 +84,7 @@ export const FileApi = HttpApi.make("file")
),
HttpApiEndpoint.get("content", FilePaths.content, {
query: FileQuery,
success: File.Content,
success: described(File.Content, "File content"),
}).annotateMerge(
OpenApi.annotations({
identifier: "file.read",
@@ -91,7 +93,7 @@ export const FileApi = HttpApi.make("file")
}),
),
HttpApiEndpoint.get("status", FilePaths.status, {
success: Schema.Array(File.Info),
success: described(Schema.Array(File.Info), "File status"),
}).annotateMerge(
OpenApi.annotations({
identifier: "file.status",
@@ -106,6 +108,8 @@ export const FileApi = HttpApi.make("file")
description: "Experimental HttpApi file routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -115,53 +119,3 @@ 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,22 +1,25 @@
import { Config } from "@/config/config"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"
const GlobalHealth = Schema.Struct({
healthy: Schema.Literal(true),
version: Schema.String,
}).annotate({ identifier: "GlobalHealth" })
})
const GlobalEvent = Schema.Struct({
const GlobalEventSchema = Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
payload: Schema.Unknown,
payload: Schema.Union([...BusEvent.effectPayloads(), ...SyncEvent.effectPayloads()]),
}).annotate({ identifier: "GlobalEvent" })
const GlobalUpgradeInput = Schema.Struct({
export const GlobalUpgradeInput = Schema.Struct({
target: Schema.optional(Schema.String),
}).annotate({ identifier: "GlobalUpgradeInput" })
})
const GlobalUpgradeResult = Schema.Union([
Schema.Struct({
@@ -27,7 +30,7 @@ const GlobalUpgradeResult = Schema.Union([
success: Schema.Literal(false),
error: Schema.String,
}),
]).annotate({ identifier: "GlobalUpgradeResult" })
])
export const GlobalPaths = {
health: "/global/health",
@@ -41,7 +44,7 @@ export const GlobalApi = HttpApi.make("global").add(
HttpApiGroup.make("global")
.add(
HttpApiEndpoint.get("health", GlobalPaths.health, {
success: GlobalHealth,
success: described(GlobalHealth, "Health information"),
}).annotateMerge(
OpenApi.annotations({
identifier: "global.health",
@@ -50,7 +53,7 @@ export const GlobalApi = HttpApi.make("global").add(
}),
),
HttpApiEndpoint.get("event", GlobalPaths.event, {
success: GlobalEvent,
success: GlobalEventSchema,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.event",
@@ -59,7 +62,7 @@ export const GlobalApi = HttpApi.make("global").add(
}),
),
HttpApiEndpoint.get("configGet", GlobalPaths.config, {
success: Config.Info,
success: described(Config.Info, "Get global config info"),
}).annotateMerge(
OpenApi.annotations({
identifier: "global.config.get",
@@ -69,7 +72,8 @@ export const GlobalApi = HttpApi.make("global").add(
),
HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, {
payload: Config.Info,
success: Config.Info,
success: described(Config.Info, "Successfully updated global config"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.config.update",
@@ -78,7 +82,7 @@ export const GlobalApi = HttpApi.make("global").add(
}),
),
HttpApiEndpoint.post("dispose", GlobalPaths.dispose, {
success: Schema.Boolean,
success: described(Schema.Boolean, "Global disposed"),
}).annotateMerge(
OpenApi.annotations({
identifier: "global.dispose",
@@ -88,7 +92,8 @@ export const GlobalApi = HttpApi.make("global").add(
),
HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
payload: GlobalUpgradeInput,
success: GlobalUpgradeResult,
success: described(GlobalUpgradeResult, "Upgrade result"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.upgrade",
@@ -1,15 +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 * 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"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const PathInfo = Schema.Struct({
home: Schema.String,
@@ -19,7 +19,7 @@ const PathInfo = Schema.Struct({
directory: Schema.String,
}).annotate({ identifier: "Path" })
const VcsDiffQuery = Schema.Struct({
export const VcsDiffQuery = Schema.Struct({
mode: Vcs.Mode,
})
@@ -40,7 +40,7 @@ export const InstanceApi = HttpApi.make("instance")
HttpApiGroup.make("instance")
.add(
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
success: Schema.Boolean,
success: described(Schema.Boolean, "Instance disposed"),
}).annotateMerge(
OpenApi.annotations({
identifier: "instance.dispose",
@@ -59,7 +59,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
success: Vcs.Info,
success: described(Vcs.Info, "VCS info"),
}).annotateMerge(
OpenApi.annotations({
identifier: "vcs.get",
@@ -70,7 +70,7 @@ export const InstanceApi = HttpApi.make("instance")
),
HttpApiEndpoint.get("vcsDiff", InstancePaths.vcsDiff, {
query: VcsDiffQuery,
success: Schema.Array(Vcs.FileDiff),
success: described(Schema.Array(Vcs.FileDiff), "VCS diff"),
}).annotateMerge(
OpenApi.annotations({
identifier: "vcs.diff",
@@ -79,7 +79,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("command", InstancePaths.command, {
success: Schema.Array(Command.Info),
success: described(Schema.Array(Command.Info), "List of commands"),
}).annotateMerge(
OpenApi.annotations({
identifier: "command.list",
@@ -88,7 +88,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("agent", InstancePaths.agent, {
success: Schema.Array(Agent.Info),
success: described(Schema.Array(Agent.Info), "List of agents"),
}).annotateMerge(
OpenApi.annotations({
identifier: "app.agents",
@@ -97,7 +97,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("skill", InstancePaths.skill, {
success: Schema.Array(Skill.Info),
success: described(Schema.Array(Skill.Info), "List of skills"),
}).annotateMerge(
OpenApi.annotations({
identifier: "app.skills",
@@ -106,7 +106,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
success: Schema.Array(LSP.Status),
success: described(Schema.Array(LSP.Status), "LSP server status"),
}).annotateMerge(
OpenApi.annotations({
identifier: "lsp.status",
@@ -115,7 +115,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
success: Schema.Array(Format.Status),
success: described(Schema.Array(Format.Status), "Formatter status"),
}).annotateMerge(
OpenApi.annotations({
identifier: "formatter.status",
@@ -130,6 +130,8 @@ export const InstanceApi = HttpApi.make("instance")
description: "Experimental HttpApi instance read routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -139,79 +141,3 @@ 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),
)
@@ -1,26 +1,28 @@
import { MCP } from "@/mcp"
import { ConfigMCP } from "@/config/mcp"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const AddPayload = Schema.Struct({
export const AddPayload = Schema.Struct({
name: Schema.String,
config: ConfigMCP.Info,
}).annotate({ identifier: "McpAddInput" })
})
const StatusMap = Schema.Record(Schema.String, MCP.Status)
const AuthStartResponse = Schema.Struct({
export const StatusMap = Schema.Record(Schema.String, MCP.Status)
export const AuthStartResponse = Schema.Struct({
authorizationUrl: Schema.String,
oauthState: Schema.String,
}).annotate({ identifier: "McpAuthStartResponse" })
const AuthCallbackPayload = Schema.Struct({
})
export const AuthCallbackPayload = Schema.Struct({
code: Schema.String,
}).annotate({ identifier: "McpAuthCallbackInput" })
const AuthRemoveResponse = Schema.Struct({
})
export const AuthRemoveResponse = Schema.Struct({
success: Schema.Literal(true),
}).annotate({ identifier: "McpAuthRemoveResponse" })
class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthError>("McpUnsupportedOAuthError")(
})
export class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthError>("McpUnsupportedOAuthError")(
{ error: Schema.String },
{ httpApiStatus: 400 },
) {}
@@ -39,7 +41,7 @@ export const McpApi = HttpApi.make("mcp")
HttpApiGroup.make("mcp")
.add(
HttpApiEndpoint.get("status", McpPaths.status, {
success: Schema.Record(Schema.String, MCP.Status),
success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.status",
@@ -49,7 +51,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("add", McpPaths.status, {
payload: AddPayload,
success: StatusMap,
success: described(StatusMap, "MCP server added successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
@@ -60,8 +62,8 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("authStart", McpPaths.auth, {
params: { name: Schema.String },
success: AuthStartResponse,
error: UnsupportedOAuthError,
success: described(AuthStartResponse, "OAuth flow started"),
error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.start",
@@ -72,7 +74,8 @@ export const McpApi = HttpApi.make("mcp")
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
params: { name: Schema.String },
payload: AuthCallbackPayload,
success: MCP.Status,
success: described(MCP.Status, "OAuth authentication completed"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.callback",
@@ -83,8 +86,8 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
params: { name: Schema.String },
success: MCP.Status,
error: UnsupportedOAuthError,
success: described(MCP.Status, "OAuth authentication completed"),
error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.authenticate",
@@ -94,7 +97,8 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
params: { name: Schema.String },
success: AuthRemoveResponse,
success: described(AuthRemoveResponse, "OAuth credentials removed"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.auth.remove",
@@ -104,7 +108,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("connect", McpPaths.connect, {
params: { name: Schema.String },
success: Schema.Boolean,
success: described(Schema.Boolean, "MCP server connected successfully"),
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.connect",
@@ -113,7 +117,7 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
params: { name: Schema.String },
success: Schema.Boolean,
success: described(Schema.Boolean, "MCP server disconnected successfully"),
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.disconnect",
@@ -127,6 +131,8 @@ export const McpApi = HttpApi.make("mcp")
description: "Experimental HttpApi MCP routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -136,68 +142,3 @@ 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))
@@ -0,0 +1,18 @@
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,17 +1,24 @@
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
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: Schema.Array(Permission.Request),
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.list",
@@ -21,8 +28,9 @@ export const PermissionApi = HttpApi.make("permission")
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: PermissionID },
payload: Permission.ReplyBody,
success: Schema.Boolean,
payload: ReplyPayload,
success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.reply",
@@ -37,6 +45,8 @@ export const PermissionApi = HttpApi.make("permission")
description: "Experimental HttpApi permission routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -46,29 +56,3 @@ 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,77 @@
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 "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
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(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -0,0 +1,76 @@
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 "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
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(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -0,0 +1,127 @@
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 "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
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(WorkspaceRoutingMiddleware)
.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,17 +1,25 @@
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
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: Schema.Array(Question.Request),
success: described(Schema.Array(Question.Request), "List of pending questions"),
}).annotateMerge(
OpenApi.annotations({
identifier: "question.list",
@@ -21,8 +29,9 @@ export const QuestionApi = HttpApi.make("question")
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: QuestionID },
payload: Question.Reply,
success: Schema.Boolean,
payload: ReplyPayload,
success: described(Schema.Boolean, "Question answered successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "question.reply",
@@ -32,7 +41,8 @@ export const QuestionApi = HttpApi.make("question")
),
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
params: { requestID: QuestionID },
success: Schema.Boolean,
success: described(Schema.Boolean, "Question rejected successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "question.reject",
@@ -47,6 +57,8 @@ export const QuestionApi = HttpApi.make("question")
description: "Question routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -56,33 +68,3 @@ 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))
@@ -0,0 +1,430 @@
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 "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
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(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -0,0 +1,92 @@
import { NonNegativeInt } from "@/util/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { 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(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -0,0 +1,197 @@
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { 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(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -0,0 +1,103 @@
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 "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
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(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -0,0 +1,34 @@
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)
}),
)
@@ -0,0 +1,34 @@
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)
}),
)
@@ -0,0 +1,155 @@
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)
}),
)
@@ -0,0 +1,54 @@
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)
}),
)
@@ -0,0 +1,156 @@
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)
}),
)
@@ -0,0 +1,79 @@
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)
}),
)
@@ -0,0 +1,68 @@
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)
}),
)
@@ -0,0 +1,29 @@
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)
}),
)

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