Compare commits

..

15 Commits

Author SHA1 Message Date
Kit Langton 4b9c5d2fe9 refactor: share HttpApi parity helpers 2026-04-29 23:31:58 -04:00
Kit Langton e0339227d9 test: assert SDK path directory parity 2026-04-29 23:27:54 -04:00
Kit Langton 52c912237b test: cover SSE first frame parity 2026-04-29 23:26:33 -04:00
Kit Langton b30415b18f test: cover message pagination headers 2026-04-29 23:25:29 -04:00
Kit Langton fe7db4f40e fix: match legacy session list path precedence 2026-04-29 23:24:09 -04:00
Kit Langton 71e27e0871 fix: match legacy session archive timestamp handling 2026-04-29 23:21:06 -04:00
Kit Langton edf0cbbcdc fix: filter PTY websocket binary input 2026-04-29 23:18:16 -04:00
Kit Langton c6b42d1fac fix: match legacy TUI unknown command handling 2026-04-29 22:46:22 -04:00
Kit Langton 0fb9603e8b fix: preserve MCP OAuth state in HttpApi response 2026-04-29 22:44:01 -04:00
Kit Langton 60f3eadef9 fix: support HttpApi CORS preflight requests 2026-04-29 22:36:30 -04:00
Kit Langton 95bf01d839 docs: explain HttpApi lifecycle context handoff 2026-04-29 22:28:10 -04:00
Kit Langton 9f3112748e refactor: simplify HttpApi lifecycle context restore 2026-04-29 22:26:13 -04:00
Kit Langton 48d4175461 fix: preserve workspace on HttpApi instance lifecycle events 2026-04-29 22:24:43 -04:00
Kit Langton 2b3e3573b2 fix: keep workspace control routes local 2026-04-29 22:20:42 -04:00
Kit Langton 7a7979ea9b test: port instance HttpApi path/vcs read coverage to Effect
Mounts the production ExperimentalHttpApiServer.routes layer on NodeHttpServer.layerTest and drives it via HttpClient instead of Server.Default()/Hono. The remaining four tests stay in httpapi-instance.legacy.test.ts until ported one by one.
2026-04-29 22:04:54 -04:00
176 changed files with 10157 additions and 7119 deletions
-6
View File
@@ -36,9 +36,3 @@ jobs:
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
SENTRY_RELEASE: web@${{ github.sha }}
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_RELEASE: web@${{ github.sha }}
+1 -8
View File
@@ -88,7 +88,7 @@ jobs:
- name: Build
id: build
run: |
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
./packages/opencode/script/build.ts
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -494,13 +494,6 @@ jobs:
working-directory: packages/desktop-electron
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
- name: Package and publish
if: needs.version.outputs.release
+31 -156
View File
@@ -35,7 +35,6 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
@@ -70,7 +69,6 @@
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
@@ -232,7 +230,6 @@
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
@@ -253,7 +250,6 @@
},
"devDependencies": {
"@actions/artifact": "4.0.0",
"@sentry/vite-plugin": "catalog:",
"@tauri-apps/cli": "^2",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -279,8 +275,6 @@
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
@@ -462,6 +456,7 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.84.2",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/script": "workspace:*",
@@ -511,8 +506,8 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.0",
"@opentui/solid": ">=0.2.0",
"@opentui/core": ">=0.1.105",
"@opentui/solid": ">=0.1.105",
},
"optionalPeers": [
"@opentui/core",
@@ -690,12 +685,10 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.0",
"@opentui/solid": "0.2.0",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
@@ -1076,6 +1069,8 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.57", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.57" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-gdjZPEP0QQg4qmI1vd+443kheeQZKytrjJIzCJncy6ZEpyk/SfrqeStLqLXdTRcms3IB0ls0vOV7KNq7YmBRVA=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.57", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.57", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.57", "ioredis": "^5.7.0" } }, "sha512-la0xxPSAYOsY0d+uVxEBxok3jYB31iPQmIaZZRUj2SNWqcGGHJc6KorKtI8guqSLuv9FGZ255kBWXRbG6hMeeg=="],
@@ -1618,21 +1613,21 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.0", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.2.0", "@opentui/core-darwin-x64": "0.2.0", "@opentui/core-linux-arm64": "0.2.0", "@opentui/core-linux-x64": "0.2.0", "@opentui/core-win32-arm64": "0.2.0", "@opentui/core-win32-x64": "0.2.0", "bun-webgpu": "0.1.7", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-7YOEqPUQmsgrOb9nmLEBlX8RVHPFy4HquK1C489DwfvvPTiws8nTbZ+webNQDWha7shgnYQK4Zo1EcOlpQ5+1Q=="],
"@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VVmKwth3hzsQPjAZ7WGJxmzuzx0uCtynd79JJDg26D7QRM9V5beVGbKwwU5SKsDlK74EyQoY85Mv9xFY5E4jrA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eX+WNdbSNr7Bozdq/MH6p1vXIALGt0SqBHR4YtWyTh6X7KDz9FTtJT3ylxMPqiVRUGBNAiWOxoqKGXW7JLQ0TA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ARZa+ywbN/OV7esT5ZdJMlQW3a4Pr56qLlEI/X65ik88C2sgmDze4Kf2FmqtvJ1hbv1YsMfLHH9MfhLl5twyHQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjNxrD45P51cdbABoivVQLBakVYwDqAridJbHhkK6T/+EU7YsTrmAu9ae19N9ZGnrlKzLViQF8GOavNUNjAbhw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ImMjFPOWE8wcZQ2lUz1D418xonS/5EwnItUF1g5dbp1q9+A0vv2P3bxTenLwMqcYvG4wjO6gKT3n2QLnRd6qKg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6yfYHTtJ4yzbl8kXCW3Pc4eWbZDYVw21GumwdNgkjJJ2JqQAQ861em0riEoucYAa5qPYYTiMUEw7X4Fv8lGwuQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"@opentui/solid": ["@opentui/solid@0.2.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-kZR9i0FPAcVtomrPsKuSb+D9smooplo9zggFfU2vnnguNuQjGNbEmuJtxhCacy7ig9g3GomdNtQAzD4LiAY+3w=="],
"@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1964,44 +1959,6 @@
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-WILVR8HQBWOxbqLRuTxjzRCMIACGsDTo6jXvzA8rz6ezElElLmIrn3CFAswrESLqEEUa4CQHl5bLgSVJCRNweA=="],
"@sentry-internal/feedback": ["@sentry-internal/feedback@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-zPjz7AbcxEyx8AHj8xvp28fYtPTPWU1XcNtymhAHJLS9CXOblqSC7W02Jxz6eo3eR1/pLyOo6kJBUjvLe9EoFA=="],
"@sentry-internal/replay": ["@sentry-internal/replay@10.36.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-nLMkJgvHq+uCCrQKV2KgSdVHxTsmDk0r2hsAoTcKCbzUpXyW5UhCziMRS6ULjBlzt5sbxoIIplE25ZpmIEeNgg=="],
"@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.36.0", "", { "dependencies": { "@sentry-internal/replay": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-DLGIwmT2LX+O6TyYPtOQL5GiTm2rN0taJPDJ/Lzg2KEJZrdd5sKkzTckhh2x+vr4JQyeaLmnb8M40Ch1hvG/vQ=="],
"@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@4.6.0", "", {}, "sha512-3soTX50JPQQ51FSbb4qvNBf4z/yP7jTdn43vMTp9E4IxvJ9HKJR7OEuKkCMszrZmWsVABXl02msqO7QisePdiQ=="],
"@sentry/browser": ["@sentry/browser@10.36.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.36.0", "@sentry-internal/feedback": "10.36.0", "@sentry-internal/replay": "10.36.0", "@sentry-internal/replay-canvas": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-yHhXbgdGY1s+m8CdILC9U/II7gb6+s99S2Eh8VneEn/JG9wHc+UOzrQCeFN0phFP51QbLkjkiQbbanjT1HP8UQ=="],
"@sentry/bundler-plugin-core": ["@sentry/bundler-plugin-core@4.6.0", "", { "dependencies": { "@babel/core": "^7.18.5", "@sentry/babel-plugin-component-annotate": "4.6.0", "@sentry/cli": "^2.57.0", "dotenv": "^16.3.1", "find-up": "^5.0.0", "glob": "^9.3.2", "magic-string": "0.30.8", "unplugin": "1.0.1" } }, "sha512-Fub2XQqrS258jjS8qAxLLU1k1h5UCNJ76i8m4qZJJdogWWaF8t00KnnTyp9TEDJzrVD64tRXS8+HHENxmeUo3g=="],
"@sentry/cli": ["@sentry/cli@2.58.5", "", { "dependencies": { "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.7", "progress": "^2.0.3", "proxy-from-env": "^1.1.0", "which": "^2.0.2" }, "optionalDependencies": { "@sentry/cli-darwin": "2.58.5", "@sentry/cli-linux-arm": "2.58.5", "@sentry/cli-linux-arm64": "2.58.5", "@sentry/cli-linux-i686": "2.58.5", "@sentry/cli-linux-x64": "2.58.5", "@sentry/cli-win32-arm64": "2.58.5", "@sentry/cli-win32-i686": "2.58.5", "@sentry/cli-win32-x64": "2.58.5" }, "bin": { "sentry-cli": "bin/sentry-cli" } }, "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg=="],
"@sentry/cli-darwin": ["@sentry/cli-darwin@2.58.5", "", { "os": "darwin" }, "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ=="],
"@sentry/cli-linux-arm": ["@sentry/cli-linux-arm@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm" }, "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw=="],
"@sentry/cli-linux-arm64": ["@sentry/cli-linux-arm64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm64" }, "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ=="],
"@sentry/cli-linux-i686": ["@sentry/cli-linux-i686@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "ia32" }, "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw=="],
"@sentry/cli-linux-x64": ["@sentry/cli-linux-x64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "x64" }, "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g=="],
"@sentry/cli-win32-arm64": ["@sentry/cli-win32-arm64@2.58.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA=="],
"@sentry/cli-win32-i686": ["@sentry/cli-win32-i686@2.58.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g=="],
"@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.5", "", { "os": "win32", "cpu": "x64" }, "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg=="],
"@sentry/core": ["@sentry/core@10.36.0", "", {}, "sha512-EYJjZvofI+D93eUsPLDIUV0zQocYqiBRyXS6CCV6dHz64P/Hob5NJQOwPa8/v6nD+UvJXvwsFfvXOHhYZhZJOQ=="],
"@sentry/solid": ["@sentry/solid@10.36.0", "", { "dependencies": { "@sentry/browser": "10.36.0", "@sentry/core": "10.36.0" }, "peerDependencies": { "@solidjs/router": "^0.13.4 || ^0.14.0 || ^0.15.0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "optionalPeers": ["@solidjs/router", "@tanstack/solid-router"] }, "sha512-AaDqz3JGBrQCm2YVqODVyJHwg7LRTNSJig9mjfProFyvkC7eUXQ/HBJrrhAD1Dct9ufmDH3G+f3/Ut9LgpItSg=="],
"@sentry/vite-plugin": ["@sentry/vite-plugin@4.6.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.6.0", "unplugin": "1.0.1" } }, "sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw=="],
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
@@ -2774,15 +2731,15 @@
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bun-webgpu": ["bun-webgpu@0.1.7", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.7", "bun-webgpu-darwin-x64": "^0.1.7", "bun-webgpu-linux-x64": "^0.1.7", "bun-webgpu-win32-x64": "^0.1.7" } }, "sha512-KUxUp+oQIf7pPBMD4Hv1TUu7DWaOZ4ciKulTk9to9+Uc8yHoYrMW7L2SJCJ4FHHkywgf/7aLRgRx0b7i6DvGIQ=="],
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mRrFFyHzPWjsTRidAZBRcu808CPQBOUL0P6b4nxLhp+XHcV/mbUHERZMgW9s58tsojQfSdzschiQa8q+JCgRWA=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-g0NXGNgvaVCSH/jCWWlfdiquOHkbUN6vP4zqzSkIxWKQeLnqm3oADcok7SO3yIgI7v5mKpRc/ks7NDEKNH+jNQ=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-UEP7UZdEhx9otvkZczjsszL8ZVlrODANQvgl+C88/bNVmxDoFi7w1fWzGi1sZyakiETjmtFDq2/xCLhbSZxjqw=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-KZktiFkBz6sN7PEm1NVdeaLP5Q5X/PlSHZqefY4nNuWtf0LNvh54NhZe7yVv/Plz/nGbv92b0KHMBY3ki/pp6g=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -3286,7 +3243,7 @@
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="],
@@ -3780,7 +3737,7 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
@@ -4184,7 +4141,7 @@
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
@@ -4234,7 +4191,7 @@
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
@@ -4994,7 +4951,7 @@
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="],
@@ -5102,9 +5059,7 @@
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"webpack-sources": ["webpack-sources@3.4.0", "", {}, "sha512-gHwIe1cgBvvfLeu1Yz/dcFpmHfKDVxxyqI+kzqmuxZED81z2ChxpyqPaWcNqigPywhaEke7AjSGga+kxY55gjQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
@@ -5642,6 +5597,8 @@
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5656,16 +5613,6 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="],
"@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"@sentry/cli/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
@@ -5720,8 +5667,6 @@
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.48", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw=="],
"@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
@@ -5906,6 +5851,8 @@
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"find-up/path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
@@ -6004,10 +5951,6 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.1.105", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
"ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -6016,7 +5959,7 @@
"ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
@@ -6028,8 +5971,6 @@
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
@@ -6126,10 +6067,6 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -6630,16 +6567,6 @@
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="],
"@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
"@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
@@ -6662,8 +6589,6 @@
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
@@ -6786,36 +6711,14 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
"opentui-spinner/@opentui/core/bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"parse-bmfont-xml/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
"readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -6844,8 +6747,6 @@
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
@@ -7070,12 +6971,6 @@
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
"@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
@@ -7158,22 +7053,8 @@
"opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"opentui-spinner/@opentui/core/bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="],
"opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
"pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
@@ -7186,8 +7067,6 @@
"tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"unplugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"@astrojs/check/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@astrojs/check/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -7242,8 +7121,6 @@
"@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
@@ -7270,8 +7147,6 @@
"opencontrol/@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-OtyfKTBEHsJpjzAjN9vCR0PzGzdK6CDHdyU7eZ6Gl1s=",
"aarch64-linux": "sha256-3eHJs3S/+uDUPAouWPsdBOlEvAOhOYx5bJzahL0tAJk=",
"aarch64-darwin": "sha256-rFXzrkhPVb3yM20J8R8m7GqroNNk1vAEz+o/Ks+iAI4=",
"x86_64-darwin": "sha256-lb1IGgbpxg723Qxj2WVPkxKUUmyOIsFOAhA5LoZ8GwY="
"x86_64-linux": "sha256-h2T/LnUnISZZDn9ZQkZ/A59P+6+QdfOlrgl4RXK/vgM=",
"aarch64-linux": "sha256-+DRohG1ZEB/2LtZU90GWoqJkeyu/sW8A8oKT3f/TtQ0=",
"aarch64-darwin": "sha256-k4nsk/WduuxY8HgjRuqzGT9EjEo7V/2mAzBTYee0fZ0=",
"x86_64-darwin": "sha256-3dSvfN2+5lXwOx57x8NSIWbEZ1fp6+1T6bJpAuUNPyk="
}
}
+2 -4
View File
@@ -34,8 +34,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.0",
"@opentui/solid": "0.2.0",
"@opentui/core": "0.1.105",
"@opentui/solid": "0.1.105",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -77,8 +77,6 @@
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10"
-2
View File
@@ -27,7 +27,6 @@
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
@@ -41,7 +40,6 @@
},
"dependencies": {
"@kobalte/core": "catalog:",
"@sentry/solid": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/core": "workspace:*",
+6 -14
View File
@@ -1,5 +1,4 @@
import "@/index.css"
import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
@@ -149,19 +148,12 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
>
<LanguageProvider locale={props.locale}>
<UiI18nBridge>
<ErrorBoundary
fallback={(error) => {
Sentry.captureException(error)
return <ErrorPage error={error} />
}}
>
<QueryProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</QueryProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</UiI18nBridge>
</LanguageProvider>
@@ -329,7 +329,6 @@ export const SettingsGeneral: Component = () => {
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
globalSync.updateConfig({ shell: option.value })
}}
variant="secondary"
@@ -187,7 +187,7 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return !providerQuery.isLoading
return providerQuery.isLoading
},
provider: { all: [], connected: [], default: {} },
config: {},
@@ -207,13 +207,13 @@ export function createChildStoreManager(input: {
permission: {},
question: {},
get mcp_ready() {
return !mcpQuery.isLoading
return mcpQuery.isLoading
},
get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
},
get lsp_ready() {
return !lspQuery.isLoading
return lspQuery.isLoading
},
get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
+1 -1
View File
@@ -382,7 +382,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setSaved("session", session, {
agent: msg.agent,
model: msg.model,
variant: msg.model?.variant ?? null,
variant: msg.model.variant ?? null,
})
},
},
-20
View File
@@ -1,6 +1,5 @@
// @refresh reload
import * as Sentry from "@sentry/solid"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform"
@@ -126,25 +125,6 @@ const platform: Platform = {
setDefaultServer: writeDefaultServerUrl,
}
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
release: import.meta.env.VITE_SENTRY_RELEASE ?? `web@${pkg.version}`,
initialScope: {
tags: {
platform: "web",
},
},
integrations: (integrations) => {
return integrations.filter(
(i) =>
i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"),
)
},
})
}
if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
render(
-4
View File
@@ -2,10 +2,6 @@ interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
readonly VITE_SENTRY_DSN?: string
readonly VITE_SENTRY_ENVIRONMENT?: string
readonly VITE_SENTRY_RELEASE?: string
}
interface ImportMeta {
-2
View File
@@ -402,8 +402,6 @@ export const dict = {
"error.page.description": "حدث خطأ أثناء تحميل التطبيق.",
"error.page.details.label": "تفاصيل الخطأ",
"error.page.action.restart": "إعادة تشغيل",
"error.page.action.report": "الإبلاغ عن الخطأ",
"error.page.action.reported": "تم الإبلاغ عن الخطأ",
"error.page.action.checking": "جارٍ التحقق...",
"error.page.action.checkUpdates": "التحقق من وجود تحديثات",
"error.page.action.updateTo": "تحديث إلى {{version}}",
-2
View File
@@ -403,8 +403,6 @@ export const dict = {
"error.page.description": "Ocorreu um erro ao carregar a aplicação.",
"error.page.details.label": "Detalhes do Erro",
"error.page.action.restart": "Reiniciar",
"error.page.action.report": "Reportar erro",
"error.page.action.reported": "Erro reportado",
"error.page.action.checking": "Verificando...",
"error.page.action.checkUpdates": "Verificar atualizações",
"error.page.action.updateTo": "Atualizar para {{version}}",
-2
View File
@@ -449,8 +449,6 @@ export const dict = {
"error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.",
"error.page.details.label": "Detalji greške",
"error.page.action.restart": "Restartuj",
"error.page.action.report": "Prijavi grešku",
"error.page.action.reported": "Greška prijavljena",
"error.page.action.checking": "Provjera...",
"error.page.action.checkUpdates": "Provjeri ažuriranja",
"error.page.action.updateTo": "Ažuriraj na {{version}}",
-2
View File
@@ -446,8 +446,6 @@ export const dict = {
"error.page.description": "Der opstod en fejl under indlæsning af applikationen.",
"error.page.details.label": "Fejldetaljer",
"error.page.action.restart": "Genstart",
"error.page.action.report": "Rapportér fejl",
"error.page.action.reported": "Fejl rapporteret",
"error.page.action.checking": "Tjekker...",
"error.page.action.checkUpdates": "Tjek for opdateringer",
"error.page.action.updateTo": "Opdater til {{version}}",
-2
View File
@@ -410,8 +410,6 @@ export const dict = {
"error.page.description": "Beim Laden der Anwendung ist ein Fehler aufgetreten.",
"error.page.details.label": "Fehlerdetails",
"error.page.action.restart": "Neustart",
"error.page.action.report": "Fehler melden",
"error.page.action.reported": "Fehler gemeldet",
"error.page.action.checking": "Prüfen...",
"error.page.action.checkUpdates": "Nach Updates suchen",
"error.page.action.updateTo": "Auf {{version}} aktualisieren",
-2
View File
@@ -465,8 +465,6 @@ export const dict = {
"error.page.description": "An error occurred while loading the application.",
"error.page.details.label": "Error Details",
"error.page.action.restart": "Restart",
"error.page.action.report": "Report Error",
"error.page.action.reported": "Error Reported",
"error.page.action.checking": "Checking...",
"error.page.action.checkUpdates": "Check for updates",
"error.page.action.updateTo": "Update to {{version}}",
-2
View File
@@ -449,8 +449,6 @@ export const dict = {
"error.page.description": "Ocurrió un error al cargar la aplicación.",
"error.page.details.label": "Detalles del error",
"error.page.action.restart": "Reiniciar",
"error.page.action.report": "Informar error",
"error.page.action.reported": "Error informado",
"error.page.action.checking": "Comprobando...",
"error.page.action.checkUpdates": "Buscar actualizaciones",
"error.page.action.updateTo": "Actualizar a {{version}}",
-2
View File
@@ -406,8 +406,6 @@ export const dict = {
"error.page.description": "Une erreur s'est produite lors du chargement de l'application.",
"error.page.details.label": "Détails de l'erreur",
"error.page.action.restart": "Redémarrer",
"error.page.action.report": "Signaler l'erreur",
"error.page.action.reported": "Erreur signalée",
"error.page.action.checking": "Vérification...",
"error.page.action.checkUpdates": "Vérifier les mises à jour",
"error.page.action.updateTo": "Mettre à jour vers {{version}}",
-2
View File
@@ -402,8 +402,6 @@ export const dict = {
"error.page.description": "アプリケーションの読み込み中にエラーが発生しました。",
"error.page.details.label": "エラー詳細",
"error.page.action.restart": "再起動",
"error.page.action.report": "エラーを報告",
"error.page.action.reported": "エラーを報告しました",
"error.page.action.checking": "確認中...",
"error.page.action.checkUpdates": "アップデートを確認",
"error.page.action.updateTo": "{{version}}にアップデート",
-2
View File
@@ -401,8 +401,6 @@ export const dict = {
"error.page.description": "애플리케이션을 로드하는 동안 오류가 발생했습니다.",
"error.page.details.label": "오류 세부 정보",
"error.page.action.restart": "다시 시작",
"error.page.action.report": "오류 신고",
"error.page.action.reported": "오류가 신고됨",
"error.page.action.checking": "확인 중...",
"error.page.action.checkUpdates": "업데이트 확인",
"error.page.action.updateTo": "{{version}} 버전으로 업데이트",
-2
View File
@@ -450,8 +450,6 @@ export const dict = {
"error.page.description": "Det oppstod en feil under lasting av applikasjonen.",
"error.page.details.label": "Feildetaljer",
"error.page.action.restart": "Start på nytt",
"error.page.action.report": "Rapporter feil",
"error.page.action.reported": "Feil rapportert",
"error.page.action.checking": "Sjekker...",
"error.page.action.checkUpdates": "Se etter oppdateringer",
"error.page.action.updateTo": "Oppdater til {{version}}",
-2
View File
@@ -403,8 +403,6 @@ export const dict = {
"error.page.description": "Wystąpił błąd podczas ładowania aplikacji.",
"error.page.details.label": "Szczegóły błędu",
"error.page.action.restart": "Restartuj",
"error.page.action.report": "Zgłoś błąd",
"error.page.action.reported": "Błąd zgłoszony",
"error.page.action.checking": "Sprawdzanie...",
"error.page.action.checkUpdates": "Sprawdź aktualizacje",
"error.page.action.updateTo": "Zaktualizuj do {{version}}",
-2
View File
@@ -448,8 +448,6 @@ export const dict = {
"error.page.description": "Произошла ошибка при загрузке приложения.",
"error.page.details.label": "Детали ошибки",
"error.page.action.restart": "Перезапустить",
"error.page.action.report": "Сообщить об ошибке",
"error.page.action.reported": "Об ошибке сообщено",
"error.page.action.checking": "Проверка...",
"error.page.action.checkUpdates": "Проверить обновления",
"error.page.action.updateTo": "Обновить до {{version}}",
-2
View File
@@ -447,8 +447,6 @@ export const dict = {
"error.page.description": "เกิดข้อผิดพลาดระหว่างการโหลดแอปพลิเคชัน",
"error.page.details.label": "รายละเอียดข้อผิดพลาด",
"error.page.action.restart": "รีสตาร์ท",
"error.page.action.report": "รายงานข้อผิดพลาด",
"error.page.action.reported": "รายงานข้อผิดพลาดแล้ว",
"error.page.action.checking": "กำลังตรวจสอบ...",
"error.page.action.checkUpdates": "ตรวจสอบการอัปเดต",
"error.page.action.updateTo": "อัปเดตเป็น {{version}}",
-2
View File
@@ -452,8 +452,6 @@ export const dict = {
"error.page.description": "Uygulama yüklenirken bir hata oluştu.",
"error.page.details.label": "Hata Detayları",
"error.page.action.restart": "Yeniden Başlat",
"error.page.action.report": "Hatayı Bildir",
"error.page.action.reported": "Hata Bildirildi",
"error.page.action.checking": "Kontrol ediliyor...",
"error.page.action.checkUpdates": "Güncellemeleri kontrol et",
"error.page.action.updateTo": "{{version}} sürümüne güncelle",
-2
View File
@@ -452,8 +452,6 @@ export const dict = {
"error.page.description": "加载应用程序时发生错误。",
"error.page.details.label": "错误详情",
"error.page.action.restart": "重启",
"error.page.action.report": "上报错误",
"error.page.action.reported": "错误已上报",
"error.page.action.checking": "检查中...",
"error.page.action.checkUpdates": "检查更新",
"error.page.action.updateTo": "更新到 {{version}}",
-2
View File
@@ -445,8 +445,6 @@ export const dict = {
"error.page.description": "載入應用程式時發生錯誤。",
"error.page.details.label": "錯誤詳情",
"error.page.action.restart": "重新啟動",
"error.page.action.report": "回報錯誤",
"error.page.action.reported": "已回報錯誤",
"error.page.action.checking": "檢查中...",
"error.page.action.checkUpdates": "檢查更新",
"error.page.action.updateTo": "更新到 {{version}}",
+2 -20
View File
@@ -1,8 +1,7 @@
import { TextField } from "@opencode-ai/ui/text-field"
import * as Sentry from "@sentry/solid"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component, createSignal, Show } from "solid-js"
import { Component, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
@@ -271,27 +270,10 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
label={language.t("error.page.details.label")}
hideLabel
/>
<div class="flex flex-row items-center justify-center gap-3 flex-wrap max-w-64">
<div class="flex items-center gap-3">
<Button size="large" onClick={platform.restart}>
{language.t("error.page.action.restart")}
</Button>
<Show when={Sentry.isEnabled}>
{(_) => {
const [reported, setReported] = createSignal(false)
return (
<Button
size="large"
disabled={reported()}
onClick={() => {
Sentry.captureException(props.error)
setReported(true)
}}
>
{language.t(reported() ? "error.page.action.reported" : "error.page.action.report")}
</Button>
)
}}
</Show>
<Show when={platform.checkUpdate}>
<Show
when={store.version}
+2 -20
View File
@@ -1,26 +1,8 @@
import { sentryVitePlugin } from "@sentry/vite-plugin"
import { defineConfig } from "vite"
import desktopPlugin from "./vite"
const sentry =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
? sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
telemetry: false,
release: {
name: process.env.SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE,
},
sourcemaps: {
assets: "./dist/**",
filesToDeleteAfterUpload: "./dist/**/*.map",
},
})
: false
export default defineConfig({
plugins: [desktopPlugin, sentry] as any,
plugins: [desktopPlugin] as any,
server: {
host: "0.0.0.0",
allowedHosts: true,
@@ -28,6 +10,6 @@ export default defineConfig({
},
build: {
target: "esnext",
sourcemap: true,
// sourcemap: true,
},
})
+1 -1
View File
@@ -47,7 +47,7 @@ export const Flag = {
OPENCODE_DISABLE_CLAUDE_CODE,
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
OPENCODE_DISABLE_EXTERNAL_SKILLS: OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
+11 -26
View File
@@ -4,14 +4,12 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import os from "os"
import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock"
import { Flag } from "./flag/flag"
const app = "opencode"
const data = path.join(xdgData!, app)
const cache = path.join(xdgCache!, app)
const config = path.join(xdgConfig!, app)
const state = path.join(xdgState!, app)
const tmp = path.join(os.tmpdir(), app)
const paths = {
get home() {
@@ -23,7 +21,6 @@ const paths = {
cache,
config,
state,
tmp,
}
export const Path = paths
@@ -34,7 +31,6 @@ await Promise.all([
fs.mkdir(Path.data, { recursive: true }),
fs.mkdir(Path.config, { recursive: true }),
fs.mkdir(Path.state, { recursive: true }),
fs.mkdir(Path.tmp, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }),
])
@@ -47,34 +43,23 @@ export interface Interface {
readonly cache: string
readonly config: string
readonly state: string
readonly tmp: string
readonly bin: string
readonly log: string
}
export function make(input: Partial<Interface> = {}): Interface {
return {
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Flag.OPENCODE_CONFIG_DIR ?? Path.config,
state: Path.state,
tmp: Path.tmp,
bin: Path.bin,
log: Path.log,
...input,
}
}
export const layer = Layer.effect(
Service,
Effect.sync(() => Service.of(make())),
Effect.gen(function* () {
return Service.of({
home: Path.home,
data: Path.data,
cache: Path.cache,
config: Path.config,
state: Path.state,
bin: Path.bin,
log: Path.log,
})
}),
)
export const layerWith = (input: Partial<Interface>) =>
Layer.effect(
Service,
Effect.sync(() => Service.of(make(input))),
)
export * as Global from "./global"
@@ -18,17 +18,20 @@ function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
const msg: Msg = JSON.parse(process.argv[2])
const msg: Msg = JSON.parse(process.argv[2]!)
const testGlobal = Global.layerWith({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
})
const testGlobal = Layer.succeed(
Global.Service,
Global.Service.of({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
)
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+12 -9
View File
@@ -93,15 +93,18 @@ async function waitForFile(file: string, timeout = 3_000) {
// Test layer
// ---------------------------------------------------------------------------
const testGlobal = Global.layerWith({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
})
const testGlobal = Layer.succeed(
Global.Service,
Global.Service.of({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
)
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+8 -1
View File
@@ -2,6 +2,13 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"noUncheckedIndexedAccess": false
"noUncheckedIndexedAccess": false,
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
}
}
@@ -1,4 +1,3 @@
import { sentryVitePlugin } from "@sentry/vite-plugin"
import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite"
import * as fs from "node:fs/promises"
@@ -13,23 +12,6 @@ const OPENCODE_SERVER_DIST = "../opencode/dist/node"
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
const sentry =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
? sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
telemetry: false,
release: {
name: process.env.SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE,
},
sourcemaps: {
assets: "./out/renderer/**",
filesToDeleteAfterUpload: "./out/renderer/**/*.map",
},
})
: false
export default defineConfig({
main: {
define: {
@@ -79,14 +61,13 @@ export default defineConfig({
},
},
renderer: {
plugins: [appPlugin, sentry],
plugins: [appPlugin],
publicDir: "../../../app/public",
root: "src/renderer",
define: {
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},
build: {
sourcemap: true,
rollupOptions: {
input: {
main: "src/renderer/index.html",
-2
View File
@@ -38,8 +38,6 @@
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
@@ -14,7 +14,6 @@ import {
ServerConnection,
useCommand,
} from "@opencode-ai/app"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router"
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
@@ -30,25 +29,6 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(t("error.dev.rootNotFound"))
}
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop-electron@${pkg.version}`,
initialScope: {
tags: {
platform: "desktop-electron",
},
},
integrations: (integrations) => {
return integrations.filter(
(i) =>
i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"),
)
},
})
}
void initI18n()
const deepLinkEvent = "opencode:deep-link"
-2
View File
@@ -15,7 +15,6 @@
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
@@ -36,7 +35,6 @@
},
"devDependencies": {
"@actions/artifact": "4.0.0",
"@sentry/vite-plugin": "catalog:",
"@tauri-apps/cli": "^2",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
-9
View File
@@ -1,9 +0,0 @@
interface ImportMetaEnv {
readonly VITE_SENTRY_DSN?: string
readonly VITE_SENTRY_ENVIRONMENT?: string
readonly VITE_SENTRY_RELEASE?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
-1
View File
@@ -14,7 +14,6 @@ import {
ServerConnection,
useCommand,
} from "@opencode-ai/app"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { readImage } from "@tauri-apps/plugin-clipboard-manager"
+3 -3
View File
@@ -15,9 +15,9 @@ export default defineConfig({
// Improves production stack traces
keepNames: true,
},
build: {
sourcemap: true,
},
// build: {
// sourcemap: true,
// },
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
+2
View File
@@ -6,6 +6,7 @@
"license": "MIT",
"private": true,
"scripts": {
"prepare": "effect-language-service patch || true",
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
@@ -41,6 +42,7 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.84.2",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/core": "workspace:*",
-2
View File
@@ -50,7 +50,6 @@ console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const sourcemapsFlag = process.argv.includes("--sourcemaps")
const plugin = createSolidTransformPlugin()
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
@@ -200,7 +199,6 @@ for (const item of targets) {
external: ["node-gyp"],
format: "esm",
minify: true,
sourcemap: sourcemapsFlag ? "linked" : "none",
splitting: true,
compile: {
autoloadBunfig: false,
+16 -28
View File
@@ -12,16 +12,14 @@ Plan for replacing instance Hono route implementations with Effect `HttpApi` whi
## Current State
- `OPENCODE_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`.
- `server/backend.ts` picks one of `effect-httpapi` or `hono`; `server.ts` builds either a pure Effect `HttpApi` web handler or the legacy Hono app accordingly. The earlier in-Hono "bridge" model has been replaced by this fork-at-startup.
- Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation.
- An Effect `HttpApi` OpenAPI surface exists (`OpenApi.fromApi(PublicApi)` in `cli/cmd/generate.ts --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi` in `packages/sdk/js/script/build.ts`) but is opt-in. The default SDK generation is still Hono.
- `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4.
- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module.
- `OPENCODE_EXPERIMENTAL_HTTPAPI` gates the bridge. Default behavior still uses Hono.
- The bridge mounts selected paths in `server/routes/instance/index.ts` before legacy Hono routes.
- Legacy Hono routes remain for default behavior and for `hono-openapi` SDK generation.
- `HttpApi` auth is independent of Hono auth.
- `Authorization` is attached in each route module, not centrally wrapped in `server.ts`.
- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`.
- Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-opencode-directory`.
- `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`.
- CORS middleware is wired into both backends (`feat(httpapi): add CORS middleware to instance routes`).
## Migration Rules
@@ -124,19 +122,10 @@ Keep large or stateful groups for later:
Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation.
Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip:
- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing).
- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing).
- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names).
- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix.
- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output.
Required before route deletion:
- Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path.
- Generate the public OpenAPI surface from Effect `HttpApi` for ported routes.
- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional.
- Flip `packages/sdk/js/script/build.ts` default to `httpapi` and regenerate.
- Compare generated SDK output against `dev` for every route group deletion.
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
@@ -376,26 +365,25 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
10. [x] Bridge remaining session mutation and prompt routes.
11. [ ] Replace event SSE with non-Hono Effect HTTP. The Effect backend has a raw Effect HTTP `httpapi/event.ts`; the Hono backend still uses `hono/streaming` `streamSSE`. Either port Hono `/event` to raw Effect HTTP for the fallback window, or skip and delete it together with Hono in step 15.
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP for the Effect backend. Hono `pty.ts` remains in the Hono backend.
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer for the Effect backend. Hono `tui.ts` remains in the Hono backend.
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. Effect path is implemented and opt-in via `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. Close the schema-shape gaps in `public.ts` (branded `pattern`, per-property `description`, `Event.*` / `SyncEvent.*` naming, dedup collisions), then flip `packages/sdk/js/script/build.ts` default.
15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `OPENCODE_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files.
11. [ ] Replace event SSE with non-Hono Effect HTTP.
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP.
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output.
15. [ ] Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files.
## Checklist
- [x] Add first `HttpApi` JSON route slices.
- [x] Bridge selected `HttpApi` routes behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.)
- [x] Bridge selected `HttpApi` routes into Hono behind `OPENCODE_EXPERIMENTAL_HTTPAPI`.
- [x] Reuse existing Effect services in handlers.
- [x] Provide auth, instance lookup, and observability in the Effect route layer.
- [x] Centralize auth via Effect `Config` for the Effect backend.
- [x] Attach auth middleware in route modules.
- [x] Support `auth_token` as a query security scheme.
- [x] Add bridge-level auth and instance tests.
- [x] Complete exact Hono route inventory.
- [x] Resolve implemented-but-unmounted route groups.
- [x] Port remaining top-level JSON reads.
- [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`.
- [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default.
- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag.
- [ ] Generate SDK/OpenAPI from Effect routes.
- [ ] Flip ported JSON routes to default-on with fallback.
- [ ] Delete replaced Hono route implementations.
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono).
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations.
+1 -5
View File
@@ -81,11 +81,7 @@ export const layer = Layer.effect(
Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [
Truncate.GLOB,
path.join(Global.Path.tmp, "*"),
...skillDirs.map((dir) => path.join(dir, "*")),
]
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = Permission.fromConfig({
"*": "allow",
+4 -1
View File
@@ -245,7 +245,10 @@ export const ExportCommand = cmd({
output: process.stderr,
})
const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list()))
const sessions = []
for await (const session of Session.list()) {
sessions.push(session)
}
if (sessions.length === 0) {
prompts.log.error("No sessions found", {
+18 -28
View File
@@ -156,38 +156,28 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
if (method.type === "api") {
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
if (!method.authorize) {
await put(provider, {
type: "api",
key,
...metadata,
if (method.authorize) {
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
type: "api",
key: result.key ?? key,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return true
}
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
type: "api",
key: result.key ?? key,
...metadata,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return true
}
return false
+1 -3
View File
@@ -91,9 +91,7 @@ export const SessionListCommand = cmd({
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const sessions = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })),
)
const sessions = [...Session.list({ roots: true, limit: args.maxCount })]
if (sessions.length === 0) {
return
@@ -51,7 +51,7 @@ export function createDialogProviderOptions() {
}[provider.id],
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
gutter: connected && onboarded() ? () => <text fg={theme.success}></text> : undefined,
gutter: connected && onboarded() ? <text fg={theme.success}></text> : undefined,
async onSelect() {
if (consoleManaged) return
@@ -168,7 +168,7 @@ export function DialogSessionList() {
value: x.id,
category,
footer,
gutter: isWorking ? () => <Spinner /> : undefined,
gutter: isWorking ? <Spinner /> : undefined,
}
})
})
@@ -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 { editorSelectionKey, useEditorContext, type EditorSelection } 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,30 +84,16 @@ function fadeColor(color: RGBA, alpha: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
}
function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) {
return (
selection.selection.start.line !== selection.selection.end.line ||
selection.selection.start.character !== selection.selection.end.character
)
}
function getEditorRangeLabel(selection: EditorSelection["ranges"][number]) {
if (!hasEditorRangeSelection(selection)) return
if (selection.selection.start.line === selection.selection.end.line) return `#${selection.selection.start.line}`
return `#${selection.selection.start.line}-${selection.selection.end.line}`
}
function formatEditorContext(selection: EditorSelection) {
const selected = selection.ranges.filter(hasEditorRangeSelection)
if (selected.length === 0)
return `<system-reminder>Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.</system-reminder>\n`
const ranges = selected.map((range, index) => {
const prefix = selected.length > 1 ? `Selection ${index + 1}: ` : ""
return `Note: The user selected ${prefix}${getEditorRangeLabel(range)} from "${selection.filePath}". \`\`\`${range.text}\`\`\`\n\n`
})
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
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
@@ -139,21 +125,13 @@ export function Prompt(props: PromptProps) {
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true))
const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal<string>()
const editorContext = createMemo(() => {
const selection = fileContextEnabled() ? editor.selection() : undefined
if (!selection) return
return editorSelectionKey(selection) === dismissedEditorSelectionKey() ? undefined : selection
})
const editorPath = createMemo(() => editorContext()?.filePath)
const editorPath = createMemo(() => (fileContextEnabled() ? editor.selection()?.filePath : undefined))
const editorSelectionLabel = createMemo(() => {
const ranges = editorContext()?.ranges
if (!ranges) return
const first = ranges.find(hasEditorRangeSelection) ?? ranges[0]
if (!first) return
return [getEditorRangeLabel(first), ranges.length > 1 ? `+${ranges.length - 1}` : undefined]
.filter(Boolean)
.join(" ")
const selection = fileContextEnabled() ? editor.selection()?.selection : undefined
if (!selection) return
if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return
if (selection.start.line === selection.end.line) return `#${selection.start.line}`
return `#${selection.start.line}-${selection.end.line}`
})
const editorFileLabel = createMemo(() => {
const value = editorPath()
@@ -169,7 +147,6 @@ export function Prompt(props: PromptProps) {
if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
})
const [editorContextHover, setEditorContextHover] = createSignal(false)
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
@@ -186,11 +163,6 @@ export function Prompt(props: PromptProps) {
}
}
function dismissEditorContext() {
setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
editor.clearSelection()
}
const textareaKeybindings = useTextareaKeybindings()
const fileStyleId = syntax().getStyleId("extmark.file")!
@@ -320,16 +292,6 @@ export function Prompt(props: PromptProps) {
dialog.clear()
},
},
{
title: "Remove editor context",
value: "prompt.editor_context.clear",
category: "Prompt",
enabled: Boolean(editorContext()),
onSelect: (dialog) => {
dismissEditorContext()
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
@@ -798,21 +760,35 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const variant = local.model.variant.current()
const editorSelection = editorContext()
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
const editorSelection = fileContextEnabled() ? editor.selection() : undefined
const editorSelectionKey = editorSelection ? getEditorSelectionKey(editorSelection) : undefined
const editorParts =
editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey
editorSelection && editorSelectionKey !== lastSubmittedEditorSelectionKey
? [
{
id: PartID.ascending(),
type: "text" as const,
text: formatEditorContext(editorSelection),
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`
}
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,
ranges: editorSelection.ranges,
selection: editorSelection.selection,
},
},
]
@@ -879,7 +855,7 @@ export function Prompt(props: PromptProps) {
],
})
.catch(() => {})
lastSubmittedEditorSelectionKey = currentEditorSelectionKey
lastSubmittedEditorSelectionKey = editorSelectionKey
}
history.append({
...store.prompt,
@@ -1430,18 +1406,7 @@ export function Prompt(props: PromptProps) {
</Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}>
{(file) => (
<text
fg={theme.secondary}
onMouseOver={() => setEditorContextHover(true)}
onMouseOut={() => setEditorContextHover(false)}
onMouseUp={dismissEditorContext}
>
{editorContextHover() ? `x ${file()}` : file()}
</text>
)}
</Show>
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
@@ -12,9 +12,6 @@ const ZedEditorRowSchema = z.object({
workspace_paths: z.string().nullable(),
timestamp: z.string(),
buffer_path: z.string().nullable(),
})
const ZedSelectionRowSchema = z.object({
selection_start: z.number().nullable(),
selection_end: z.number().nullable(),
})
@@ -27,7 +24,6 @@ const utf8 = new TextEncoder()
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
type ZedSelectionRow = z.infer<typeof ZedSelectionRowSchema>
export type ZedSelectionResult =
| { type: "selection"; selection: EditorSelection }
@@ -40,21 +36,7 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
const row = active.row
if (!row.buffer_path) return { type: "empty" }
const selections = queryZedEditorSelections(dbPath, row)
if (selections.type !== "selections") return selections
const byteRanges = selections.selections
.flatMap((selection) => {
if (selection.selection_start == null || selection.selection_end == null) return []
return [
{
start: Math.min(selection.selection_start, selection.selection_end),
end: Math.max(selection.selection_start, selection.selection_end),
},
]
})
.sort((left, right) => left.start - right.start || left.end - right.end)
if (byteRanges.length === 0) return { type: "unavailable" }
if (row.selection_start == null || row.selection_end == null) return { type: "unavailable" }
const contents = queryZedEditorContents(dbPath, row)
const text =
@@ -65,21 +47,16 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
.catch(() => undefined)
if (text == null) return { type: "unavailable" }
const ranges = byteRanges.map((range) => {
const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
return {
text: text.slice(startOffset, endOffset),
selection: offsetsToSelection(text, startOffset, endOffset),
}
})
const startOffset = utf8ByteOffsetToStringIndex(text, Math.min(row.selection_start, row.selection_end))
const endOffset = utf8ByteOffsetToStringIndex(text, Math.max(row.selection_start, row.selection_end))
return {
type: "selection",
selection: {
text: text.slice(startOffset, endOffset),
filePath: row.buffer_path,
source: "zed",
ranges,
selection: offsetsToSelection(text, startOffset, endOffset),
},
}
}
@@ -96,11 +73,14 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
i.workspace_id as workspace_id,
w.paths as workspace_paths,
w.timestamp as timestamp,
e.buffer_path as buffer_path
e.buffer_path as buffer_path,
s.start as selection_start,
s.end as selection_end
from items i
join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
join workspaces w on w.workspace_id = i.workspace_id
left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
left join editor_selections s on s.editor_id = e.item_id and s.workspace_id = e.workspace_id
where i.active = 1 and p.active = 1
order by w.timestamp desc`,
)
@@ -128,34 +108,6 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
}
}
function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const raw = db
.query(
`select
start as selection_start,
end as selection_end
from editor_selections
where editor_id = $editorID and workspace_id = $workspaceID`,
)
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
const selections = raw.flatMap((selection) => {
const parsed = ZedSelectionRowSchema.safeParse(selection)
return parsed.success ? [parsed.data] : []
})
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
return { type: "selections" as const, selections }
} catch {
return { type: "unavailable" as const }
} finally {
db?.close()
}
}
function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
@@ -189,20 +141,13 @@ export function resolveZedDbPath() {
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
].filter((item): item is string => Boolean(item))
return candidates.find((item) => isFile(item))
}
function isFile(item: string) {
try {
return Filesystem.stat(item)?.isFile() === true
} catch {
return false
}
return candidates.find((item) => Filesystem.stat(item)?.isFile())
}
function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
if (pathContains(item, cwd)) return Math.max(score, 2)
if (pathContains(cwd, item)) return Math.max(score, 1)
return score
}, 0)
}
@@ -28,46 +28,16 @@ const PositionSchema = z.object({
character: z.number(),
})
const EditorSelectionRangeSchema = z.object({
const EditorSelectionSchema = z.object({
text: z.string(),
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
selection: z.object({
start: PositionSchema,
end: PositionSchema,
}),
})
const EditorSelectionSchema = z
.union([
z.object({
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
ranges: z.array(EditorSelectionRangeSchema).min(1),
}),
z.object({
text: z.string(),
filePath: z.string(),
source: z.enum(["websocket", "zed"]).optional(),
selection: z.object({
start: PositionSchema,
end: PositionSchema,
}),
}),
])
.transform((value) =>
"ranges" in value
? value
: {
filePath: value.filePath,
source: value.source,
ranges: [
{
text: value.text,
selection: value.selection,
},
],
},
)
const EditorMentionSchema = z.object({
filePath: z.string(),
lineStart: z.number(),
@@ -292,7 +262,6 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
return store.selection
},
clearSelection() {
lastZedSelectionKey = undefined
setStore("selection", undefined)
},
onMention(listener: (mention: EditorMention) => void) {
@@ -383,17 +352,15 @@ function readEditorLockFile(filePath: string): EditorLockFile | undefined {
}
}
export function editorSelectionKey(selection: EditorSelection | undefined) {
function editorSelectionKey(selection: EditorSelection | undefined) {
if (!selection) return ""
return [
selection.filePath,
...selection.ranges.flatMap((range) => [
range.selection.start.line,
range.selection.start.character,
range.selection.end.line,
range.selection.end.character,
range.text,
]),
selection.selection.start.line,
selection.selection.start.character,
selection.selection.end.line,
selection.selection.end.character,
selection.text,
].join("\0")
}
+4 -7
View File
@@ -71,12 +71,6 @@ async function input(value?: string) {
return piped + "\n" + value
}
export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) {
const root = Filesystem.resolve(envPWD ?? cwd)
if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project))
return Filesystem.resolve(cwd)
}
export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
@@ -130,7 +124,10 @@ export const TuiThreadCommand = cmd({
// Resolve relative --project paths from PWD, then use the real cwd after
// chdir so the thread and worker share the same directory key.
const next = resolveThreadDirectory(args.project)
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
const next = args.project
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
: Filesystem.resolve(process.cwd())
const file = await target()
try {
process.chdir(next)
@@ -42,7 +42,7 @@ export interface DialogSelectOption<T = any> {
categoryView?: JSX.Element
disabled?: boolean
bg?: RGBA
gutter?: () => JSX.Element
gutter?: JSX.Element
margin?: JSX.Element
onSelect?: (ctx: DialogContext) => void
}
@@ -407,7 +407,7 @@ function Option(props: {
active?: boolean
current?: boolean
footer?: JSX.Element | string
gutter?: () => JSX.Element
gutter?: JSX.Element
onMouseOver?: () => void
}) {
const { theme } = useTheme()
@@ -422,7 +422,7 @@ function Option(props: {
</Show>
<Show when={!props.current && props.gutter}>
<box flexShrink={0} marginRight={0}>
{props.gutter?.()}
{props.gutter}
</box>
</Show>
<text
+12 -20
View File
@@ -3,7 +3,7 @@ import path from "path"
import { pathToFileURL } from "url"
import os from "os"
import z from "zod"
import { mergeDeep } from "remeda"
import { mergeDeep, pipe } from "remeda"
import { Global } from "@opencode-ai/core/global"
import fsNode from "fs/promises"
import { NamedError } from "@opencode-ai/core/util/error"
@@ -47,13 +47,8 @@ import { Npm } from "@opencode-ai/core/npm"
const log = Log.create({ service: "config" })
// Custom merge function that concatenates array fields instead of replacing them
// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here.
function mergeConfig(target: Info, source: Info): Info {
return mergeDeep(target, source) as Info
}
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeConfig(target, source)
const merged = mergeDeep(target, source)
if (target.instructions && source.instructions) {
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
}
@@ -392,10 +387,12 @@ export const layer = Layer.effect(
})
const loadGlobal = Effect.fnUntraced(function* () {
let result: Info = {}
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
let result: Info = pipe(
{},
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
)
const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) {
@@ -405,7 +402,7 @@ export const layer = Layer.effect(
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeConfig(result, rest)
result = mergeDeep(result, rest)
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fsNode.unlink(legacy)
})
@@ -762,23 +759,18 @@ export const layer = Layer.effect(
const patch = writableGlobal(config)
let next: Info
let changed: boolean
if (!file.endsWith(".jsonc")) {
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
const merged = mergeDeep(writable(existing), patch)
const serialized = JSON.stringify(merged, null, 2)
changed = serialized !== before
if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, patch)
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
changed = updated !== before
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
// Only tear down running instances if the config actually changed.
if (changed) yield* invalidate()
yield* invalidate()
return next
})
@@ -1,26 +1,27 @@
import { lazy } from "@/util/lazy"
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types"
import { WorktreeAdaptor } from "./worktree"
const BUILTIN: Record<string, WorkspaceAdaptor> = {
worktree: WorktreeAdaptor,
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
}
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
export function getAdaptor(projectID: ProjectID, type: string): WorkspaceAdaptor {
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin
if (builtin) return builtin()
throw new Error(`Unknown workspace adaptor: ${type}`)
}
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, adaptor]) => {
Object.entries(BUILTIN).map(async ([type, init]) => {
const adaptor = await init()
return {
type,
name: adaptor.name,
@@ -1,4 +1,6 @@
import { Schema } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Worktree } from "@/worktree"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({
@@ -8,26 +10,19 @@ const WorktreeConfig = Schema.Struct({
})
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
async function loadWorktree() {
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
return { AppRuntime, Worktree }
}
export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
return {
...info,
name: next.name,
branch: next.branch,
directory: next.directory,
name: worktree.name,
branch: worktree.branch,
directory: worktree.directory,
}
},
async create(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(
Worktree.Service.use((svc) =>
@@ -40,7 +35,6 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
)
},
async remove(info) {
const { AppRuntime, Worktree } = await loadWorktree()
const config = decodeWorktreeConfig(info)
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
},
@@ -1,19 +0,0 @@
This is a plugin to simulate a remote environment locally. Add this to `.opencode/opencode.jsonc`:
```json
"plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"],
```
In a separate terminal, run a separate OpenCode server. This will act like a remote server and the local instance will proxy all requests to it:
```
./packages/opencode/script/run-workspace-server
```
With the plugin install, you can now run OpenCode and create a `debug` workspace type. This will create a "remote" workspace which talks to the second workspace server started above.
How this works:
- The workspace server needs to know the workspace id and port to run. It waits for this information to be written to a file and starts the server when the data is written.
- The debug plugin writes this information in the `create` call to the workspace. So create a `debug` workspace will always kick off a new external server.
- The server script watches for file changes, so whenver you create a new `debug` workspace it will restart with the new information. This means that there is only ever one working `debug` workspace at a time; when you create a new one all previous sessions will show that it can't connect because previous debug workspaces do not exist.
@@ -0,0 +1,66 @@
export async function parseSSE(
body: ReadableStream<Uint8Array>,
signal: AbortSignal,
onEvent: (event: unknown) => void,
) {
const reader = body.getReader()
const decoder = new TextDecoder()
let buf = ""
let last = ""
let retry = 1000
const abort = () => {
void reader.cancel().catch(() => undefined)
}
signal.addEventListener("abort", abort)
try {
while (!signal.aborted) {
const chunk = await reader.read().catch(() => ({ done: true, value: undefined as Uint8Array | undefined }))
if (chunk.done) break
buf += decoder.decode(chunk.value, { stream: true })
buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const chunks = buf.split("\n\n")
buf = chunks.pop() ?? ""
chunks.forEach((chunk) => {
const data: string[] = []
chunk.split("\n").forEach((line) => {
if (line.startsWith("data:")) {
data.push(line.replace(/^data:\s*/, ""))
return
}
if (line.startsWith("id:")) {
last = line.replace(/^id:\s*/, "")
return
}
if (line.startsWith("retry:")) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10)
if (!Number.isNaN(parsed)) retry = parsed
}
})
if (!data.length) return
const raw = data.join("\n")
try {
onEvent(JSON.parse(raw))
} catch {
onEvent({
type: "sse.message",
properties: {
data: raw,
id: last || undefined,
retry,
},
})
}
})
}
} finally {
signal.removeEventListener("abort", abort)
reader.releaseLock()
}
}
+6 -8
View File
@@ -1,23 +1,22 @@
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Effect } from "effect"
export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (event: GlobalEvent) => boolean }) {
if (input.signal?.aborted) return Effect.fail(input.signal.reason ?? new Error("Request aborted"))
if (input.signal?.aborted) return Promise.reject(input.signal.reason ?? new Error("Request aborted"))
return Effect.callback<void, unknown>((resume) => {
return new Promise<void>((resolve, reject) => {
const abort = () => {
cleanup()
resume(Effect.fail(input.signal?.reason ?? new Error("Request aborted")))
reject(input.signal?.reason ?? new Error("Request aborted"))
}
const handler = (event: GlobalEvent) => {
try {
if (!input.fn(event)) return
cleanup()
resume(Effect.void)
resolve()
} catch (error) {
cleanup()
resume(Effect.fail(error))
reject(error)
}
}
@@ -29,11 +28,10 @@ export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (e
const timeout = setTimeout(() => {
cleanup()
resume(Effect.fail(new Error("Timed out waiting for global event")))
reject(new Error("Timed out waiting for global event"))
}, input.timeout)
GlobalBus.on("event", handler)
input.signal?.addEventListener("abort", abort, { once: true })
return Effect.sync(cleanup)
})
}
+467 -722
View File
@@ -1,5 +1,6 @@
import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Schema } from "effect"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Database } from "@/storage/db"
import { asc } from "drizzle-orm"
import { eq } from "drizzle-orm"
@@ -19,10 +20,12 @@ import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors"
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse"
import { Session } from "@/session/session"
import { SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema"
import { errorData } from "@/util/error"
import { AppRuntime } from "@/effect/app-runtime"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
import { NonNegativeInt, withStatics } from "@/util/schema"
@@ -73,11 +76,6 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
}
}
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
const log = Log.create({ service: "workspace-sync" })
export const CreateInput = Schema.Struct({
id: Schema.optional(WorkspaceID),
type: Info.fields.type,
@@ -87,759 +85,286 @@ export const CreateInput = Schema.Struct({
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
export const create = fn(CreateInput.zod, async (input) => {
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.projectID, input.type)
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
const info: Info = {
id,
type: config.type,
branch: config.branch ?? null,
name: config.name ?? null,
directory: config.directory ?? null,
extra: config.extra ?? null,
projectID: input.projectID,
}
Database.use((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
})
.run()
})
const env = {
OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
OPENCODE_WORKSPACE_ID: config.id,
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
}
await adaptor.create(config, env)
startSync(info)
await waitEvent({
timeout: TIMEOUT,
fn(event) {
if (event.workspace === info.id && event.payload.type === Event.Status.type) {
const { status } = event.payload.properties
return status === "error" || status === "connected"
}
return false
},
})
return info
})
export const SessionRestoreInput = Schema.Struct({
workspaceID: WorkspaceID,
sessionID: SessionID,
}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) })))
export type SessionRestoreInput = Schema.Schema.Type<typeof SessionRestoreInput>
export class SyncHttpError extends Schema.TaggedErrorClass<SyncHttpError>()("WorkspaceSyncHttpError", {
message: Schema.String,
status: Schema.Number,
body: Schema.optional(Schema.String),
}) {}
export const sessionRestore = fn(SessionRestoreInput.zod, async (input) => {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
try {
const space = await get(input.workspaceID)
if (!space) throw new Error(`Workspace not found: ${input.workspaceID}`)
export class WorkspaceNotFoundError extends Schema.TaggedErrorClass<WorkspaceNotFoundError>()(
"WorkspaceNotFoundError",
{
message: Schema.String,
workspaceID: WorkspaceID,
},
) {}
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
export class SessionEventsNotFoundError extends Schema.TaggedErrorClass<SessionEventsNotFoundError>()(
"WorkspaceSessionEventsNotFoundError",
{
message: Schema.String,
sessionID: SessionID,
},
) {}
// Need to switch the workspace of the session
SyncEvent.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
})
export class SessionRestoreHttpError extends Schema.TaggedErrorClass<SessionRestoreHttpError>()(
"WorkspaceSessionRestoreHttpError",
{
message: Schema.String,
workspaceID: WorkspaceID,
sessionID: SessionID,
status: Schema.Number,
body: Schema.String,
},
) {}
const rows = Database.use((db) =>
db
.select({
id: EventTable.id,
aggregateID: EventTable.aggregate_id,
seq: EventTable.seq,
type: EventTable.type,
data: EventTable.data,
})
.from(EventTable)
.where(eq(EventTable.aggregate_id, input.sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
if (rows.length === 0) throw new Error(`No events found for session: ${input.sessionID}`)
export class SyncTimeoutError extends Schema.TaggedErrorClass<SyncTimeoutError>()("WorkspaceSyncTimeoutError", {
message: Schema.String,
state: Schema.Record(Schema.String, Schema.Number),
}) {}
const all = rows
export class SyncAbortedError extends Schema.TaggedErrorClass<SyncAbortedError>()("WorkspaceSyncAbortedError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
type CreateError = Auth.AuthError
type SessionRestoreError =
| WorkspaceNotFoundError
| SessionEventsNotFoundError
| SessionRestoreHttpError
| HttpClientError.HttpClientError
type WaitForSyncError = SyncTimeoutError | SyncAbortedError
type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, CreateError>
readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError>
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly status: () => Effect.Effect<ConnectionStatus[]>
readonly isSyncing: (workspaceID: WorkspaceID) => Effect.Effect<boolean>
readonly waitForSync: (
workspaceID: WorkspaceID,
state: Record<string, number>,
signal?: AbortSignal,
) => Effect.Effect<void, WaitForSyncError>
readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const auth = yield* Auth.Service
const session = yield* Session.Service
const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service
const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
const setStatus = (id: WorkspaceID, status: ConnectionStatus["status"]) => {
const prev = connections.get(id)
if (prev?.status === status) return
const next = { workspaceID: id, status }
connections.set(id, next)
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
const size = 10
const sets = Array.from({ length: Math.ceil(all.length / size) }, (_, i) => all.slice(i * size, (i + 1) * size))
const total = sets.length
log.info("session restore prepared", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
workspaceType: space.type,
directory: space.directory,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
events: all.length,
batches: total,
first: all[0]?.seq,
last: all.at(-1)?.seq,
})
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: 0,
},
})
}
const connectSSE = Effect.fn("Workspace.connectSSE")(function* (
url: URL | string,
headers: HeadersInit | undefined,
) {
const response = yield* http.execute(
HttpClientRequest.get(route(url, "/global/event"), {
headers: new Headers(headers),
accept: "text/event-stream",
}),
)
if (response.status < 200 || response.status >= 300) {
return yield* new SyncHttpError({
message: `Workspace sync HTTP failure: ${response.status}`,
status: response.status,
})
}
return response.stream
},
})
const parseSSE = Effect.fn("Workspace.parseSSE")(function* (
stream: Stream.Stream<Uint8Array, unknown>,
onEvent: (event: unknown) => Effect.Effect<void>,
) {
yield* stream.pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.mapAccum(
() => ({ data: [] as string[], id: undefined as string | undefined, retry: 1000 }),
(state, line) => {
if (line === "") {
if (!state.data.length) return [state, []]
return [{ ...state, data: [] }, [{ data: state.data.join("\n"), id: state.id, retry: state.retry }]]
}
const index = line.indexOf(":")
const field = index === -1 ? line : line.slice(0, index)
const value = index === -1 ? "" : line.slice(index + (line[index + 1] === " " ? 2 : 1))
if (field === "data") return [{ ...state, data: [...state.data, value] }, []]
if (field === "id") return [{ ...state, id: value }, []]
if (field === "retry") {
const retry = Number.parseInt(value, 10)
return [Number.isNaN(retry) ? state : { ...state, retry }, []]
}
return [state, []]
},
{
onHalt: (state) =>
state.data.length ? [{ data: state.data.join("\n"), id: state.id, retry: state.retry }] : [],
},
),
Stream.map((event) => {
try {
return JSON.parse(event.data) as unknown
} catch {
return {
type: "sse.message",
properties: {
data: event.data,
id: event.id || undefined,
retry: event.retry,
},
}
}
}),
Stream.runForEach(onEvent),
)
})
const syncHistory = Effect.fn("Workspace.syncHistory")(function* (
space: Info,
url: URL | string,
headers: HeadersInit | undefined,
) {
const sessionIDs = yield* db((db) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, space.id))
.all()
.map((row) => row.id),
)
const state = sessionIDs.length
? Object.fromEntries(
(yield* db((db) =>
db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(),
)).map((row) => [row.aggregate_id, row.seq]),
)
: {}
log.info("syncing workspace history", {
workspaceID: space.id,
sessions: sessionIDs.length,
known: Object.keys(state).length,
})
const response = yield* http.execute(
HttpClientRequest.post(route(url, "/sync/history"), {
headers: new Headers(headers),
body: HttpBody.jsonUnsafe(state),
}),
)
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text
return yield* new SyncHttpError({
message: `Workspace history HTTP failure: ${response.status} ${body}`,
status: response.status,
body,
})
}
const events = (yield* response.json) as HistoryEvent[]
log.info("workspace history synced", {
workspaceID: space.id,
for (const [i, events] of sets.entries()) {
log.info("session restore batch starting", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
})
yield* Effect.promise(async () => {
await WorkspaceContext.provide({
workspaceID: space.id,
async fn() {
await Effect.runPromise(
Effect.forEach(
events,
(event) =>
sync.replay(
{
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
},
{ publish: true },
),
{ discard: true },
),
)
},
})
})
})
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
if (target.type === "local") return
let attempt = 0
while (true) {
log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
const stream = yield* connectSSE(target.url, target.headers).pipe(
Effect.tap(() => syncHistory(space, target.url, target.headers)),
Effect.catch((err) =>
Effect.sync(() => {
setStatus(space.id, "error")
log.info("failed to connect to global sync", {
workspace: space.name,
err,
})
return null
}),
),
)
if (stream) {
attempt = 0
log.info("global sync connected", { workspace: space.name })
setStatus(space.id, "connected")
yield* parseSSE(stream, (evt) =>
Effect.gen(function* () {
if (!evt || typeof evt !== "object" || !("payload" in evt)) return
const payload = evt.payload as { type?: string; syncEvent?: SyncEvent.SerializedEvent }
if (payload.type === "server.heartbeat") return
if (payload.type === "sync" && payload.syncEvent) {
const failed = yield* sync.replay(payload.syncEvent).pipe(
Effect.as(false),
Effect.catchCause((error) =>
Effect.sync(() => {
log.info("failed to replay global event", {
workspaceID: space.id,
error,
})
return true
}),
),
)
if (failed) return
}
try {
const event = evt as { directory?: string; project?: string; payload: unknown }
GlobalBus.emit("event", {
directory: event.directory,
project: event.project,
workspace: space.id,
payload: event.payload,
})
} catch (error) {
log.info("failed to replay global event", {
workspaceID: space.id,
error,
})
}
}),
)
log.info("disconnected from global sync: " + space.id)
setStatus(space.id, "disconnected")
}
// Back off reconnect attempts up to 2 minutes while the workspace
// stays unavailable.
yield* Effect.sleep(`${Math.min(120_000, 1_000 * 2 ** attempt)} millis`)
attempt += 1
}
})
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
if (target.type === "local") {
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
return
}
const exists = yield* FiberMap.has(syncFibers, space.id)
if (exists && connections.get(space.id)?.status !== "error") return
setStatus(space.id, "disconnected")
yield* FiberMap.run(
syncFibers,
space.id,
// TODO: look into `tapError` to set the status but still
// allow the fiber to fail and automatically get removed
syncWorkspaceLoop(space).pipe(
Effect.catch((error) =>
Effect.sync(() => {
setStatus(space.id, "error")
log.warn("workspace listener failed", {
workspaceID: space.id,
error,
})
}),
),
),
)
})
const stopSync = Effect.fn("Workspace.stopSync")(function* (id: WorkspaceID) {
yield* FiberMap.remove(syncFibers, id)
connections.delete(id)
})
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = WorkspaceID.ascending(input.id)
const adaptor = getAdaptor(input.projectID, input.type)
const config = yield* Effect.promise(() =>
Promise.resolve(adaptor.configure({ ...input, id, name: Slug.create(), directory: null })),
)
const info: Info = {
id,
type: config.type,
branch: config.branch ?? null,
name: config.name ?? null,
directory: config.directory ?? null,
extra: config.extra ?? null,
projectID: input.projectID,
}
yield* db((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
})
.run()
})
const env = {
OPENCODE_AUTH_CONTENT: JSON.stringify(yield* auth.all()),
OPENCODE_WORKSPACE_ID: config.id,
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
}
yield* Effect.promise(() => adaptor.create(config, env))
yield* Effect.all(
[
waitEvent({
timeout: TIMEOUT,
fn(event) {
if (event.workspace === info.id && event.payload.type === Event.Status.type) {
const { status } = event.payload.properties
return status === "error" || status === "connected"
}
return false
},
}),
startSync(info),
],
{ concurrency: 2, discard: true },
)
return info
})
const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) {
return yield* Effect.gen(function* () {
log.info("session restore requested", {
SyncEvent.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
})
const space = yield* get(input.workspaceID)
if (!space)
return yield* new WorkspaceNotFoundError({
message: `Workspace not found: ${input.workspaceID}`,
workspaceID: input.workspaceID,
})
const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
})
const rows = yield* db((db) =>
db
.select({
id: EventTable.id,
aggregateID: EventTable.aggregate_id,
seq: EventTable.seq,
type: EventTable.type,
data: EventTable.data,
})
.from(EventTable)
.where(eq(EventTable.aggregate_id, input.sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
if (rows.length === 0)
return yield* new SessionEventsNotFoundError({
message: `No events found for session: ${input.sessionID}`,
sessionID: input.sessionID,
})
const size = 10
// TODO: look into using effect APIs to process this in chunks
const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) =>
rows.slice(i * size, (i + 1) * size),
)
const total = sets.length
log.info("session restore prepared", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
workspaceType: space.type,
directory: space.directory,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
events: rows.length,
batches: total,
first: rows[0]?.seq,
last: rows.at(-1)?.seq,
})
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: 0,
},
},
} else {
const url = route(target.url, "/sync/replay")
const headers = new Headers(target.headers)
headers.set("content-type", "application/json")
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
directory: space.directory ?? "",
events,
}),
)
for (const [i, events] of sets.entries()) {
log.info("session restore batch starting", {
})
if (!res.ok) {
const body = await res.text()
log.error("session restore batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory,
status: res.status,
body,
})
if (target.type === "local") {
yield* sync.replayAll(events)
log.info("session restore batch replayed locally", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
events: events.length,
})
} else {
const url = route(target.url, "/sync/replay")
const res = yield* http.execute(
HttpClientRequest.post(url, {
headers: new Headers(target.headers),
body: HttpBody.jsonUnsafe({
directory: space.directory ?? "",
events,
}),
}),
)
if (res.status < 200 || res.status >= 300) {
const body = yield* res.text
log.error("session restore batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
body,
})
return yield* new SessionRestoreHttpError({
message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: res.status,
body,
})
}
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: res.status,
})
}
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: i + 1,
},
},
}),
throw new Error(
`Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`,
)
}
log.info("session restore complete", {
log.info("session restore batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
step: i + 1,
total,
status: res.status,
})
return { total }
}).pipe(
Effect.tapError((err) =>
Effect.sync(() =>
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
}),
),
),
)
})
const list = Effect.fn("Workspace.list")(function* (project: Project.Info) {
return yield* db((db) =>
db
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.project_id, project.id))
.all()
.map(fromRow)
.sort((a, b) => a.id.localeCompare(b.id)),
)
})
const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) {
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
})
const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) {
const sessions = yield* db((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
)
yield* Effect.forEach(sessions, (sessionInfo) => session.remove(sessionInfo.id), { discard: true })
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
yield* stopSync(id)
const info = fromRow(row)
yield* Effect.catch(
Effect.gen(function* () {
const adaptor = getAdaptor(info.projectID, row.type)
yield* Effect.tryPromise(() => Promise.resolve(adaptor.remove(info)))
}),
() =>
Effect.sync(() => {
log.error("adaptor not available when removing workspace", { type: row.type })
}),
)
yield* db((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
})
const status = Effect.fn("Workspace.status")(function* () {
return [...connections.values()]
})
const isSyncing = Effect.fn("Workspace.isSyncing")(function* (workspaceID: WorkspaceID) {
const exists = yield* FiberMap.has(syncFibers, workspaceID)
return exists && connections.get(workspaceID)?.status !== "error"
})
const waitForSync = Effect.fn("Workspace.waitForSync")(function* (
workspaceID: WorkspaceID,
state: Record<string, number>,
signal?: AbortSignal,
) {
if (synced(state)) return
yield* Effect.catch(
waitEvent({
timeout: TIMEOUT,
signal,
fn(event) {
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
return false
}
return synced(state)
},
}),
(): Effect.Effect<never, WaitForSyncError> =>
signal?.aborted
? Effect.fail(
new SyncAbortedError({
message: signal.reason instanceof Error ? signal.reason.message : "Request aborted",
cause: signal.reason,
}),
)
: Effect.fail(
new SyncTimeoutError({
message: `Timed out waiting for sync fence: ${JSON.stringify(state)}`,
state,
}),
),
)
})
const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) {
// This session table join makes this query only return
// workspaces that have sessions
const rows = yield* db((db) =>
db
.selectDistinct({ workspace: WorkspaceTable })
.from(WorkspaceTable)
.innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
.where(eq(WorkspaceTable.project_id, projectID))
.all(),
)
for (const { workspace } of rows) {
yield* startSync(fromRow(workspace)).pipe(
Effect.catch((error) =>
Effect.sync(() => {
setStatus(workspace.id, "error")
log.warn("workspace sync failed to start", {
workspaceID: workspace.id,
error,
})
}),
),
Effect.forkDetach,
)
}
GlobalBus.emit("event", {
directory: "global",
workspace: input.workspaceID,
payload: {
type: Event.Restore.type,
properties: {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total,
step: i + 1,
},
},
})
}
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
return Service.of({
create,
sessionRestore,
list,
get,
remove,
status,
isSyncing,
waitForSync,
startWorkspaceSyncing,
return {
total,
}
} catch (err) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
}),
)
throw err
}
})
export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
export function list(project: Project.Info) {
const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
return spaces
}
export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
})
export const remove = fn(WorkspaceID.zod, async (id) => {
const sessions = Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, id)).all(),
)
for (const session of sessions) {
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(session.id)))
}
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) {
stopSync(id)
const info = fromRow(row)
try {
const adaptor = await getAdaptor(info.projectID, row.type)
await adaptor.remove(info)
} catch {
log.error("adaptor not available when removing workspace", { type: row.type })
}
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
}
})
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
const TIMEOUT = 5000
type HistoryEvent = {
id: string
aggregate_id: string
seq: number
type: string
data: Record<string, unknown>
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"]) {
const prev = connections.get(id)
if (prev?.status === status) return
const next = { workspaceID: id, status }
connections.set(id, next)
if (status === "error") {
aborts.delete(id)
}
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
},
})
}
export function status(): ConnectionStatus[] {
return [...connections.values()]
}
function synced(state: Record<string, number>) {
@@ -864,6 +389,32 @@ function synced(state: Record<string, number>) {
})
}
export async function isSyncing(workspaceID: WorkspaceID) {
return aborts.has(workspaceID)
}
export async function waitForSync(workspaceID: WorkspaceID, state: Record<string, number>, signal?: AbortSignal) {
if (synced(state)) return
try {
await waitEvent({
timeout: TIMEOUT,
signal,
fn(event) {
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
return false
}
return synced(state)
},
})
} catch {
if (signal?.aborted) throw signal.reason ?? new Error("Request aborted")
throw new Error(`Timed out waiting for sync fence: ${JSON.stringify(state)}`)
}
}
const log = Log.create({ service: "workspace-sync" })
function route(url: string | URL, path: string) {
const next = new URL(url)
next.pathname = `${next.pathname.replace(/\/$/, "")}${path}`
@@ -872,4 +423,198 @@ function route(url: string | URL, path: string) {
return next
}
async function connectSSE(url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) {
const res = await fetch(route(url, "/global/event"), {
method: "GET",
headers,
signal,
})
if (!res.ok) throw new Error(`Workspace sync HTTP failure: ${res.status}`)
if (!res.body) throw new Error("No response body from global sync")
return res.body
}
async function syncHistory(space: Info, url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) {
const sessionIDs = Database.use((db) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, space.id))
.all()
.map((row) => row.id),
)
const state = sessionIDs.length
? Object.fromEntries(
Database.use((db) =>
db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(),
).map((row) => [row.aggregate_id, row.seq]),
)
: {}
log.info("syncing workspace history", {
workspaceID: space.id,
sessions: sessionIDs.length,
known: Object.keys(state).length,
})
const requestHeaders = new Headers(headers)
requestHeaders.set("content-type", "application/json")
const res = await fetch(route(url, "/sync/history"), {
method: "POST",
headers: requestHeaders,
body: JSON.stringify(state),
signal,
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Workspace history HTTP failure: ${res.status} ${body}`)
}
const events = await res.json()
return WorkspaceContext.provide({
workspaceID: space.id,
fn: () => {
for (const event of events) {
SyncEvent.replay(
{
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
},
{ publish: true },
)
}
},
})
log.info("workspace history synced", {
workspaceID: space.id,
events: events.length,
})
}
async function syncWorkspaceLoop(space: Info, signal: AbortSignal) {
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") return null
let attempt = 0
while (!signal.aborted) {
log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
let stream
try {
stream = await connectSSE(target.url, target.headers, signal)
await syncHistory(space, target.url, target.headers, signal)
} catch (err) {
stream = null
setStatus(space.id, "error")
log.info("failed to connect to global sync", {
workspace: space.name,
err,
})
}
if (stream) {
attempt = 0
log.info("global sync connected", { workspace: space.name })
setStatus(space.id, "connected")
await parseSSE(stream, signal, (evt: any) => {
try {
if (!("payload" in evt)) return
if (evt.payload.type === "server.heartbeat") return
if (evt.payload.type === "sync") {
SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent)
}
GlobalBus.emit("event", {
directory: evt.directory,
project: evt.project,
workspace: space.id,
payload: evt.payload,
})
} catch (err) {
log.info("failed to replay global event", {
workspaceID: space.id,
error: err,
})
}
})
log.info("disconnected from global sync: " + space.id)
setStatus(space.id, "disconnected")
}
// Back off reconnect attempts up to 2 minutes while the workspace
// stays unavailable.
await sleep(Math.min(120_000, 1_000 * 2 ** attempt))
attempt += 1
}
}
async function startSync(space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") {
void Filesystem.exists(target.directory).then((exists) => {
setStatus(space.id, exists ? "connected" : "error")
})
return
}
if (aborts.has(space.id)) return true
setStatus(space.id, "disconnected")
const abort = new AbortController()
aborts.set(space.id, abort)
void syncWorkspaceLoop(space, abort.signal).catch((error) => {
aborts.delete(space.id)
setStatus(space.id, "error")
log.warn("workspace listener failed", {
workspaceID: space.id,
error,
})
})
}
function stopSync(id: WorkspaceID) {
aborts.get(id)?.abort()
aborts.delete(id)
connections.delete(id)
}
export function startWorkspaceSyncing(projectID: ProjectID) {
const spaces = Database.use((db) =>
db
.select({ workspace: WorkspaceTable })
.from(WorkspaceTable)
.innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
.where(eq(WorkspaceTable.project_id, projectID))
.all(),
)
for (const row of new Map(spaces.map((row) => [row.workspace.id, row.workspace])).values()) {
void startSync(fromRow(row))
}
}
export * as Workspace from "./workspace"
@@ -41,13 +41,11 @@ import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { SyncEvent } from "@/sync"
import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
@@ -92,13 +90,11 @@ export const AppLayer = Layer.mergeAll(
Format.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.defaultLayer,
Pty.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
SyncEvent.defaultLayer,
).pipe(Layer.provideMerge(Observability.layer))
const rt = ManagedRuntime.make(AppLayer, { memoMap })
+1 -10
View File
@@ -1,4 +1,4 @@
import { Effect, Exit, Fiber } from "effect"
import { Effect, Fiber } from "effect"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Instance, type InstanceContext } from "@/project/instance"
import type { WorkspaceID } from "@/control-plane/schema"
@@ -9,7 +9,6 @@ import { attachWith } from "./run-service"
export interface Shape {
readonly promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A>
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
readonly run: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E>
}
function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R {
@@ -44,14 +43,6 @@ export function make(): Effect.Effect<Shape> {
restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
restore(instance, workspace, () => Effect.runFork(wrap(effect))),
run: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.callback<A, E>((resume) => {
restore(instance, workspace, () =>
Effect.runPromiseExit(wrap(effect)).then((exit) =>
resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)),
),
)
}),
} satisfies Shape
})
}
@@ -1,67 +0,0 @@
import { Config, Context, Effect, Layer } from "effect"
type ConfigMap = Record<string, Config.Config<unknown>>
/**
* The service shape inferred from an object of Effect `Config` definitions.
*/
export type Shape<Fields extends ConfigMap> = {
readonly [Key in keyof Fields]: Config.Success<Fields[Key]>
}
/**
* A Context service class with generated layers for config-backed services.
*/
export type ServiceClass<Self, Id extends string, Service> = Context.ServiceClass<Self, Id, Service> & {
/** Provide already-parsed config, useful in tests. */
readonly layer: (input: Service) => Layer.Layer<Self>
/** Parse config once from the active Effect ConfigProvider and provide the service. */
readonly defaultLayer: Layer.Layer<Self, Config.ConfigError>
}
/**
* Create a Context service whose implementation is derived from Effect `Config`.
*
* This keeps Effect `Config` as the source of truth for env names, defaults, and
* validation while generating a typed service plus convenient production/test
* layers.
*
* ```ts
* class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
* "@opencode/ServerAuthConfig",
* {
* password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
* username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
* },
* ) {}
*
* const live = ServerAuthConfig.defaultLayer
* const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
* ```
*/
export const Service =
<Self>() =>
<const Id extends string, const Fields extends ConfigMap>(id: Id, fields: Fields) => {
class ConfigTag extends Context.Service<Self, Shape<Fields>>()(id) {
static layer(input: Shape<Fields>) {
return Layer.succeed(this, this.of(input))
}
static get defaultLayer() {
return Layer.effect(
this,
Config.all(fields)
.asEffect()
.pipe(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs.
Effect.map((config) => this.of(config as Shape<Fields>)),
),
)
}
}
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers.
return ConfigTag as ServiceClass<Self, Id, Shape<Fields>>
}
export * as ConfigService from "./config-service"
+14 -10
View File
@@ -10,6 +10,7 @@ import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Git } from "@/git"
import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy"
import { Config } from "@/config/config"
import { FileIgnore } from "./ignore"
@@ -75,27 +76,25 @@ export const layer = Layer.effect(
function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
const ctx = yield* InstanceState.context
log.info("init", { directory: ctx.directory })
log.info("init", { directory: Instance.directory })
const backend = getBackend()
if (!backend) {
log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform })
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
return
}
const w = watcher()
if (!w) return
log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
)
const cb: ParcelWatcher.SubscribeCallback = InstanceState.bind((err, evts) => {
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return
for (const evt of evts) {
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
@@ -123,14 +122,19 @@ export const layer = Layer.effect(
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)])
yield* subscribe(Instance.directory, [
...FileIgnore.PATTERNS,
...cfgIgnores,
...protecteds(Instance.directory),
])
}
if (ctx.project.vcs === "git") {
if (Instance.project.vcs === "git") {
const result = yield* git.run(["rev-parse", "--git-dir"], {
cwd: ctx.worktree,
cwd: Instance.project.worktree,
})
const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
(entry) => entry !== "HEAD",
-26
View File
@@ -1,26 +0,0 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
const prompts = []
if (!process.env.AZURE_RESOURCE_NAME) {
prompts.push({
type: "text" as const,
key: "resourceName",
message: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
})
}
return {
auth: {
provider: "azure",
methods: [
{
type: "api",
label: "API key",
prompts,
},
],
},
}
}
+39 -35
View File
@@ -14,7 +14,6 @@ const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"])
interface PkceCodes {
verifier: string
@@ -359,45 +358,50 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
return {
provider: {
id: "openai",
async models(provider, ctx) {
if (ctx.auth?.type !== "oauth") return provider.models
return Object.fromEntries(
Object.entries(provider.models)
.filter(([, model]) => {
if (ALLOWED_MODELS.has(model.api.id)) return true
const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
return match ? parseFloat(match[1]) > 5.4 : false
})
.map(([modelID, model]) => [
modelID,
{
...model,
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: model.id.includes("gpt-5.5")
? {
context: 400_000,
input: 272_000,
output: 128_000,
}
: model.limit,
},
]),
)
},
},
auth: {
provider: "openai",
async loader(getAuth) {
async loader(getAuth, provider) {
const auth = await getAuth()
if (auth.type !== "oauth") return {}
// Filter models to only allowed Codex models for OAuth
const allowedModels = new Set([
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.4-mini",
])
for (const [modelId, model] of Object.entries(provider.models)) {
if (modelId.includes("codex")) continue
if (allowedModels.has(model.api.id)) continue
const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
if (match && parseFloat(match[1]) > 5.4) continue
delete provider.models[modelId]
}
// Zero out costs for Codex (included with ChatGPT subscription)
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
}
// gpt-5.5 models temporarily have restricted context window size for codex plans
if (model.id.includes("gpt-5.5")) {
model.limit = {
context: 400_000,
//@ts-expect-error incorrect type for v1 sdk but works
input: 272_000,
output: 128_000,
}
}
}
return {
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
-2
View File
@@ -17,7 +17,6 @@ import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { AzureAuthPlugin } from "./azure"
import { Effect, Layer, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
@@ -62,7 +61,6 @@ const INTERNAL_PLUGINS: PluginInstance[] = [
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
]
function isServerPlugin(value: unknown): value is PluginInstance {
+3 -5
View File
@@ -7,7 +7,7 @@ import * as Project from "./project"
import * as Vcs from "./vcs"
import { Bus } from "../bus"
import { Command } from "../command"
import { InstanceState } from "@/effect/instance-state"
import { Instance } from "./instance"
import * as Log from "@opencode-ai/core/util/log"
import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next"
@@ -15,8 +15,7 @@ import * as Effect from "effect/Effect"
import { Config } from "@/config/config"
export const InstanceBootstrap = Effect.gen(function* () {
const ctx = yield* InstanceState.context
Log.Default.info("bootstrapping", { directory: ctx.directory })
Log.Default.info("bootstrapping", { directory: Instance.directory })
// everything depends on config so eager load it for nice traces
yield* Config.Service.use((svc) => svc.get())
// Plugin can mutate config so it has to be initialized before anything else.
@@ -33,11 +32,10 @@ export const InstanceBootstrap = Effect.gen(function* () {
].map((s) => Effect.forkDetach(s.use((i) => i.init()))),
).pipe(Effect.withSpan("InstanceBootstrap.init"))
const projectID = ctx.project.id
yield* Bus.Service.use((svc) =>
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
Project.setInitialized(projectID)
Project.setInitialized(Instance.project.id)
}
}),
)
+10 -10
View File
@@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { serviceUse } from "@/effect/service-use"
const log = Log.create({ service: "project" })
@@ -24,13 +24,13 @@ const log = Log.create({ service: "project" })
const ProjectVcs = Schema.Literal("git")
const ProjectIcon = Schema.Struct({
url: optionalOmitUndefined(Schema.String),
override: optionalOmitUndefined(Schema.String),
color: optionalOmitUndefined(Schema.String),
url: Schema.optional(Schema.String),
override: Schema.optional(Schema.String),
color: Schema.optional(Schema.String),
})
const ProjectCommands = Schema.Struct({
start: optionalOmitUndefined(
start: Schema.optional(
Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
),
})
@@ -38,16 +38,16 @@ const ProjectCommands = Schema.Struct({
const ProjectTime = Schema.Struct({
created: NonNegativeInt,
updated: NonNegativeInt,
initialized: optionalOmitUndefined(NonNegativeInt),
initialized: Schema.optional(NonNegativeInt),
})
export const Info = Schema.Struct({
id: ProjectID,
worktree: Schema.String,
vcs: optionalOmitUndefined(ProjectVcs),
name: optionalOmitUndefined(Schema.String),
icon: optionalOmitUndefined(ProjectIcon),
commands: optionalOmitUndefined(ProjectCommands),
vcs: Schema.optional(ProjectVcs),
name: Schema.optional(Schema.String),
icon: Schema.optional(ProjectIcon),
commands: Schema.optional(ProjectCommands),
time: ProjectTime,
sandboxes: Schema.Array(Schema.String),
})
+19 -21
View File
@@ -3,7 +3,7 @@ import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { zod } from "@/util/effect-zod"
import { namedSchemaError } from "@/util/named-schema-error"
import { optionalOmitUndefined, withStatics } from "@/util/schema"
import { withStatics } from "@/util/schema"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
@@ -18,14 +18,14 @@ const TextPrompt = Schema.Struct({
type: Schema.Literal("text"),
key: Schema.String,
message: Schema.String,
placeholder: optionalOmitUndefined(Schema.String),
when: optionalOmitUndefined(When),
placeholder: Schema.optional(Schema.String),
when: Schema.optional(When),
})
const SelectOption = Schema.Struct({
label: Schema.String,
value: Schema.String,
hint: optionalOmitUndefined(Schema.String),
hint: Schema.optional(Schema.String),
})
const SelectPrompt = Schema.Struct({
@@ -33,7 +33,7 @@ const SelectPrompt = Schema.Struct({
key: Schema.String,
message: Schema.String,
options: Schema.Array(SelectOption),
when: optionalOmitUndefined(When),
when: Schema.optional(When),
})
const Prompt = Schema.Union([TextPrompt, SelectPrompt])
@@ -41,7 +41,7 @@ const Prompt = Schema.Union([TextPrompt, SelectPrompt])
export class Method extends Schema.Class<Method>("ProviderAuthMethod")({
type: Schema.Literals(["oauth", "api"]),
label: Schema.String,
prompts: optionalOmitUndefined(Schema.Array(Prompt)),
prompts: Schema.optional(Schema.Array(Prompt)),
}) {
static readonly zod = zod(this)
}
@@ -135,25 +135,23 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
item.methods.map((method) => ({
type: method.type,
label: method.label,
...(method.prompts && {
prompts: method.prompts.map((prompt) => {
if (prompt.type === "select") {
return {
type: "select" as const,
key: prompt.key,
message: prompt.message,
options: prompt.options,
...(prompt.when && { when: prompt.when }),
}
}
prompts: method.prompts?.map((prompt) => {
if (prompt.type === "select") {
return {
type: "text" as const,
type: "select" as const,
key: prompt.key,
message: prompt.message,
...(prompt.placeholder && { placeholder: prompt.placeholder }),
...(prompt.when && { when: prompt.when }),
options: prompt.options,
when: prompt.when,
}
}),
}
return {
type: "text" as const,
key: prompt.key,
message: prompt.message,
placeholder: prompt.placeholder,
when: prompt.when,
}
}),
})),
),
+40 -59
View File
@@ -24,7 +24,7 @@ import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { isRecord } from "@/util/record"
import { optionalOmitUndefined, withStatics } from "@/util/schema"
import { withStatics } from "@/util/schema"
import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
@@ -199,26 +199,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
}),
azure: Effect.fnUntraced(function* (provider: Info) {
const env = yield* dep.env()
const auth = yield* dep.auth(provider.id)
const resource = iife(() => {
return [
provider.options?.resourceName,
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
env["AZURE_RESOURCE_NAME"],
].find((name) => typeof name === "string" && name.trim() !== "")
const name = provider.options?.resourceName
if (typeof name === "string" && name.trim() !== "") return name
return env["AZURE_RESOURCE_NAME"]
})
if (!resource && !provider.options?.baseURL) {
return {
autoload: false,
async getModel() {
throw new Error(
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
)
},
}
}
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
@@ -229,16 +215,11 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
return sdk.responses(modelID)
}
},
options: {
resourceName: resource,
},
vars(_options): Record<string, string> {
if (resource) {
return {
AZURE_RESOURCE_NAME: resource,
}
options: {},
vars(_options) {
return {
...(resource && { AZURE_RESOURCE_NAME: resource }),
}
return {}
},
}
}),
@@ -875,7 +856,7 @@ const ProviderCost = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache: ProviderCacheCost,
experimentalOver200K: optionalOmitUndefined(
experimentalOver200K: Schema.optional(
Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
@@ -886,7 +867,7 @@ const ProviderCost = Schema.Struct({
const ProviderLimit = Schema.Struct({
context: Schema.Finite,
input: optionalOmitUndefined(Schema.Finite),
input: Schema.optional(Schema.Finite),
output: Schema.Finite,
})
@@ -895,7 +876,7 @@ export const Model = Schema.Struct({
providerID: ProviderID,
api: ProviderApiInfo,
name: Schema.String,
family: optionalOmitUndefined(Schema.String),
family: Schema.optional(Schema.String),
capabilities: ProviderCapabilities,
cost: ProviderCost,
limit: ProviderLimit,
@@ -903,7 +884,7 @@ export const Model = Schema.Struct({
options: Schema.Record(Schema.String, Schema.Any),
headers: Schema.Record(Schema.String, Schema.String),
release_date: Schema.String,
variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
})
.annotate({ identifier: "Model" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -914,7 +895,7 @@ export const Info = Schema.Struct({
name: Schema.String,
source: Schema.Literals(["env", "config", "custom", "api"]),
env: Schema.Array(Schema.String),
key: optionalOmitUndefined(Schema.String),
key: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Any),
models: Schema.Record(Schema.String, Model),
})
@@ -1140,33 +1121,6 @@ const layer: Layer.Layer<
return true
}
for (const hook of plugins) {
const p = hook.provider
const models = p?.models
if (!p || !models) continue
const providerID = ProviderID.make(p.id)
if (disabled.has(providerID)) continue
const provider = database[providerID]
if (!provider) continue
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
provider.models = yield* Effect.promise(async () => {
const next = await models(provider, { auth: pluginAuth })
return Object.fromEntries(
Object.entries(next).map(([id, model]) => [
id,
{
...model,
id: ModelID.make(id),
providerID,
},
]),
)
})
}
// extend database from config
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
@@ -1353,6 +1307,33 @@ const layer: Layer.Layer<
})
}
for (const hook of plugins) {
const p = hook.provider
const models = p?.models
if (!p || !models) continue
const providerID = ProviderID.make(p.id)
if (disabled.has(providerID)) continue
const provider = providers[providerID]
if (!provider) continue
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
provider.models = yield* Effect.promise(async () => {
const next = await models(provider, { auth: pluginAuth })
return Object.fromEntries(
Object.entries(next).map(([id, model]) => [
id,
{
...model,
id: ModelID.make(id),
providerID,
},
]),
)
})
}
for (const [id, provider] of Object.entries(providers)) {
const providerID = ProviderID.make(id)
if (!isProviderAllowed(providerID)) {
+1 -10
View File
@@ -1058,16 +1058,7 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a
return result
}
// AI SDK packages that resolve providerOptionsName by splitting the
// provider name on "." (e.g. "wafer.ai" -> "wafer") need the same
// logic here so the key we write matches the key they read.
// Other SDKs (xai, mistral, groq, cohere, etc.) use hardcoded keys
// like "xai" or "cohere" - applying .split(".")[0] would break those.
const usesDotSplitOptions =
model.api.npm === "@ai-sdk/openai-compatible" ||
model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/anthropic"
const key = sdkKey(model.api.npm) ?? (usesDotSplitOptions ? model.providerID.split(".")[0] : model.providerID)
const key = sdkKey(model.api.npm) ?? model.providerID
// @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from
// providerOptions["openai"], but OpenAIResponsesLanguageModel checks
// "azure" first. Pass both so model options work on either code path.
+34 -29
View File
@@ -5,6 +5,7 @@ import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import { lazy } from "@opencode-ai/core/util/lazy"
import { Plugin } from "@/plugin"
import { Instance } from "@/project/instance"
import { Shell } from "@/shell/shell"
import type { Proc } from "#pty"
import * as Log from "@opencode-ai/core/util/log"
@@ -228,38 +229,42 @@ export const layer = Layer.effect(
subscribers: new Map(),
}
s.sessions.set(id, session)
proc.onData((chunk) => {
session.cursor += chunk.length
proc.onData(
Instance.bind((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(key)
continue
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(key)
continue
}
if (sock(ws) !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
}
if (sock(ws) !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
}
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
})
proc.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
bridge.fork(bus.publish(Event.Exited, { id, exitCode }))
bridge.fork(remove(id))
})
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
}),
)
proc.onExit(
Instance.bind(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
bridge.fork(bus.publish(Event.Exited, { id, exitCode }))
bridge.fork(remove(id))
}),
)
yield* bus.publish(Event.Created, { info })
return info
})
+2 -5
View File
@@ -1,12 +1,9 @@
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
export type CorsOptions = { readonly cors?: ReadonlyArray<string> }
export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) {
if (!input) return true
export function isAllowedCorsOrigin(input: string | undefined, opts?: { cors?: string[] }) {
if (!input) return false
if (input.startsWith("http://localhost:")) return true
if (input.startsWith("http://127.0.0.1:")) return true
if (input.startsWith("oc://renderer")) return true
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
return true
if (opencodeOrigin.test(input)) return true
+9 -17
View File
@@ -5,8 +5,6 @@ import { EventSequenceTable } from "@/sync/event.sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceID } from "@/control-plane/schema"
import * as Log from "@opencode-ai/core/util/log"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
const HEADER = "x-opencode-sync"
type State = Record<string, number>
@@ -56,22 +54,16 @@ export function parse(headers: Headers) {
) as State
}
export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
return Effect.gen(function* () {
log.info("waiting for state", {
workspaceID,
state,
})
yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
log.info("state fully synced", {
workspaceID,
state,
})
})
}
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
log.info("waiting for state", {
workspaceID,
state,
})
await Workspace.waitForSync(workspaceID, state, signal)
log.info("state fully synced", {
workspaceID,
state,
})
}
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
+2 -2
View File
@@ -11,7 +11,7 @@ import { basicAuth } from "hono/basic-auth"
import { cors } from "hono/cors"
import { compress } from "hono/compress"
import * as ServerBackend from "./backend"
import { isAllowedCorsOrigin, type CorsOptions } from "./cors"
import { isAllowedCorsOrigin } from "./cors"
const log = Log.create({ service: "server" })
@@ -67,7 +67,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M
}
}
export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler {
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
return cors({
maxAge: 86_400,
origin(input) {
+9 -9
View File
@@ -4,7 +4,6 @@ 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"
import { AppRuntime } from "@/effect/app-runtime"
import { ProxyUtil } from "./proxy-util"
import { Effect, Stream } from "effect"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
@@ -70,17 +69,18 @@ function statusText(response: unknown) {
}
export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
return Effect.gen(function* () {
const syncing = yield* Workspace.Service.use((workspace) => workspace.isSyncing(workspaceID))
if (!syncing) {
return new Response(`broken sync connection for workspace: ${workspaceID}`, {
if (!Workspace.isSyncing(workspaceID)) {
return Effect.succeed(
new Response(`broken sync connection for workspace: ${workspaceID}`, {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
},
})
}
}),
)
}
return Effect.gen(function* () {
const response = yield* HttpClient.execute(
HttpClientRequest.make(req.method as never)(url, {
headers: ProxyUtil.headers(req, extra),
@@ -100,7 +100,7 @@ export function httpEffect(url: string | URL, extra: HeadersInit | undefined, re
next.delete("content-encoding")
next.delete("content-length")
if (sync) yield* Fence.waitEffect(workspaceID, sync, req.signal)
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,
@@ -114,7 +114,7 @@ export function httpEffect(url: string | URL, extra: HeadersInit | undefined, re
}
export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) {
return AppRuntime.runPromise(httpEffect(url, extra, req, workspaceID))
return Effect.runPromise(httpEffect(url, extra, req, workspaceID))
}
export function websocket(
@@ -1,10 +1,8 @@
import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
import { Effect } from "effect"
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance"
@@ -64,14 +62,10 @@ export const WorkspaceRoutes = lazy(() =>
),
async (c) => {
const body = c.req.valid("json") as Omit<Workspace.CreateInput, "projectID">
const workspace = await AppRuntime.runPromise(
Workspace.Service.use((svc) =>
svc.create({
projectID: Instance.project.id,
...body,
}),
),
)
const workspace = await Workspace.create({
projectID: Instance.project.id,
...body,
})
return c.json(workspace)
},
)
@@ -93,7 +87,7 @@ export const WorkspaceRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project))))
return c.json(Workspace.list(Instance.project))
},
)
.get(
@@ -114,11 +108,8 @@ export const WorkspaceRoutes = lazy(() =>
},
}),
async (c) => {
const result = await AppRuntime.runPromise(
Workspace.Service.use((svc) => Effect.all([svc.list(Instance.project), svc.status()])),
)
const ids = new Set(result[0].map((item) => item.id))
return c.json(result[1].filter((item) => ids.has(item.workspaceID)))
const ids = new Set(Workspace.list(Instance.project).map((item) => item.id))
return c.json(Workspace.status().filter((item) => ids.has(item.workspaceID)))
},
)
.delete(
@@ -147,7 +138,7 @@ export const WorkspaceRoutes = lazy(() =>
),
async (c) => {
const { id } = c.req.valid("param")
return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.remove(id))))
return c.json(await Workspace.remove(id))
},
)
.post(
@@ -183,14 +174,10 @@ export const WorkspaceRoutes = lazy(() =>
directory: Instance.directory,
})
try {
const result = await AppRuntime.runPromise(
Workspace.Service.use((svc) =>
svc.sessionRestore({
workspaceID: id,
...body,
}),
),
)
const result = await Workspace.sessionRestore({
workspaceID: id,
...body,
})
log.info("session restore route complete", {
workspaceID: id,
sessionID: body.sessionID,
@@ -1,35 +0,0 @@
# HttpApi Route Patterns
Use `HttpApiBuilder.group(...)` for normal HTTP endpoints, including streaming HTTP responses such as server-sent events. Handlers should yield stable services once while building the handler layer, then close over those services in endpoint implementations.
```ts
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
return handlers.handle("list", () => session.list())
}),
)
```
For SSE endpoints, stay in `HttpApiBuilder.group(...)` and return `HttpServerResponse.stream(...)` from the handler. Annotate the endpoint success schema with `HttpApiSchema.asText({ contentType: "text/event-stream" })` so OpenAPI documents the stream content type.
Use raw `HttpRouter.use(...)` only for routes that do not fit the request/response HttpApi model, such as WebSocket upgrade routes or catch-all fallback routes. Yield stable services at route-layer construction and close over them in `router.add(...)` callbacks.
```ts
export const rawRoute = HttpRouter.use((router) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
yield* router.add("GET", PtyPaths.connect, (request) => connectPty(request, pty))
}),
)
```
Avoid `Effect.provide(SomeLayer)` inside request handlers or raw route callbacks. Stable layers should be provided once at the application/layer boundary, not rebuilt or scoped per request.
Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally request-level. Prefer `HttpRouter.use(...)` for stable app services.
Use `Effect.provideService(...)` in middleware only for request-derived context, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`. Do not use it to smuggle stable services through request effects when they can be yielded at layer construction.
When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled.
@@ -2,8 +2,8 @@ import { Bus } from "@/bus"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
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" })
@@ -16,7 +16,7 @@ export const EventApi = HttpApi.make("event").add(
HttpApiGroup.make("event")
.add(
HttpApiEndpoint.get("subscribe", EventPaths.event, {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
success: Schema.Unknown,
}).annotateMerge(
OpenApi.annotations({
identifier: "event.subscribe",
@@ -37,41 +37,34 @@ function eventData(data: unknown): Sse.Event {
}
}
function eventResponse(bus: Bus.Interface) {
const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type))
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ type: "server.heartbeat", properties: {} })),
)
log.info("event connected")
return HttpServerResponse.stream(
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"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
)
}
export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) =>
export const eventRoute = HttpRouter.add(
"GET",
EventPaths.event,
Effect.gen(function* () {
const bus = yield* Bus.Service
return handlers.handleRaw(
"subscribe",
Effect.fn("EventHttpApi.subscribe")(function* () {
return eventResponse(bus)
}),
const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type))
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ type: "server.heartbeat", properties: {} })),
)
}),
log.info("event connected")
return HttpServerResponse.stream(
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"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
)
}).pipe(Effect.provide(Bus.layer)),
)
@@ -1,3 +1,4 @@
import { EffectBridge } from "@/effect/bridge"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { handlePtyInput } from "@/pty/input"
@@ -22,11 +23,16 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
})
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
return yield* pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
})
const bridge = yield* EffectBridge.make()
return yield* Effect.promise(() =>
bridge.promise(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
),
)
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
@@ -62,60 +68,52 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
}),
)
export const ptyConnectRoute = HttpRouter.use((router) =>
export const ptyConnectRoute = HttpRouter.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const pty = yield* Pty.Service
yield* router.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const params = yield* HttpRouter.schemaPathParams(Params)
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const params = yield* HttpRouter.schemaPathParams(Params)
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
? parsedCursor
: undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
const services = yield* Effect.context()
const writeScoped = (effect: Effect.Effect<void, unknown>) => {
Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void)))
}
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
writeScoped(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data))
},
close: (code?: number, reason?: string) => {
if (closed) return
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
Effect.runFork(
write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)),
)
},
close: (code?: number, reason?: string) => {
if (closed) return
closed = true
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => handlePtyInput(handler, message))
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
closed = true
writeScoped(write(new Socket.CloseEvent(code, reason)))
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => handlePtyInput(handler, message))
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
closed = true
handler.onClose()
}),
),
Effect.orDie,
)
return HttpServerResponse.empty()
}),
)
}),
handler.onClose()
}),
),
Effect.orDie,
)
return HttpServerResponse.empty()
}).pipe(Effect.provide(Pty.defaultLayer)),
)
@@ -1,10 +1,11 @@
import * as InstanceState from "@/effect/instance-state"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { AppRuntime } from "@/effect/app-runtime"
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { Command } from "@/command"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Instance } from "@/project/instance"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
@@ -17,8 +18,9 @@ import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { NotFoundError } from "@/storage/storage"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { Cause, Effect, Schema, Scope } from "effect"
import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
@@ -38,6 +40,8 @@ import {
UpdatePayload,
} from "../groups/session"
const log = Log.create({ service: "server" })
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
@@ -49,29 +53,25 @@ const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const shareSvc = yield* SessionShare.Service
const promptSvc = yield* SessionPrompt.Service
const revertSvc = yield* SessionRevert.Service
const compactSvc = yield* SessionCompaction.Service
const runState = yield* SessionRunState.Service
const agentSvc = yield* Agent.Service
const permissionSvc = yield* Permission.Service
const statusSvc = yield* SessionStatus.Service
const todoSvc = yield* Todo.Service
const summary = yield* SessionSummary.Service
const bus = yield* Bus.Service
const scope = yield* Scope.Scope
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
return yield* session.list({
directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
scope: ctx.query.scope,
path: ctx.query.path,
roots: ctx.query.roots,
start: ctx.query.start,
search: ctx.query.search,
limit: ctx.query.limit,
})
const instance = yield* InstanceState.context
return Instance.restore(instance, () =>
Array.from(
Session.list({
directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
scope: ctx.query.scope,
path: ctx.query.path,
roots: ctx.query.roots,
start: ctx.query.start,
search: ctx.query.search,
limit: ctx.query.limit,
}),
),
)
})
const status = Effect.fn("SessionHttpApi.status")(function* () {
@@ -148,7 +148,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload?: Session.CreateInput }) {
return yield* shareSvc.create(ctx.payload)
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionShare.Service.use((svc) => svc.create(ctx.payload)).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
})
const createRaw = Effect.fn("SessionHttpApi.createRaw")(function* (ctx: {
@@ -168,7 +175,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* session.remove(ctx.params.sessionID)
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.remove(ctx.params.sessionID)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
return true
})
@@ -176,31 +190,60 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof UpdatePayload.Type
}) {
const current = yield* session.get(ctx.params.sessionID)
if (ctx.payload.title !== undefined) {
yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
}
if (ctx.payload.permission !== undefined) {
yield* session.setPermission({
sessionID: ctx.params.sessionID,
permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
})
}
if (ctx.payload.time?.archived !== undefined) {
yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
}
return yield* session.get(ctx.params.sessionID)
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) =>
Effect.gen(function* () {
const current = yield* svc.get(ctx.params.sessionID)
if (ctx.payload.title !== undefined) {
yield* svc.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
}
if (ctx.payload.permission !== undefined) {
yield* svc.setPermission({
sessionID: ctx.params.sessionID,
permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
})
}
if (ctx.payload.time?.archived !== undefined) {
yield* svc.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
}
return yield* svc.get(ctx.params.sessionID)
}),
).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
})
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ForkPayload.Type
}) {
return yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID })
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }),
).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
})
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* promptSvc.cancel(ctx.params.sessionID)
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) => svc.cancel(ctx.params.sessionID)).pipe(
Effect.provide(SessionPrompt.defaultLayer),
),
),
),
)
return true
})
@@ -208,45 +251,98 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof InitPayload.Type
}) {
yield* promptSvc.command({
sessionID: ctx.params.sessionID,
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
})
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({
sessionID: ctx.params.sessionID,
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
}),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
return true
})
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* session.get(ctx.params.sessionID)
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.share(ctx.params.sessionID)
return yield* session.get(ctx.params.sessionID)
}).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
})
const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* session.get(ctx.params.sessionID)
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.unshare(ctx.params.sessionID)
return yield* session.get(ctx.params.sessionID)
}).pipe(Effect.provide(SessionShare.defaultLayer)),
),
),
)
})
const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof SummarizePayload.Type
}) {
yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agentSvc.defaultAgent()
const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const compact = yield* SessionCompaction.Service
const prompt = yield* SessionPrompt.Service
const agent = yield* Agent.Service
yield* compactSvc.create({
sessionID: ctx.params.sessionID,
agent: currentAgent,
model: {
providerID: ctx.payload.providerID,
modelID: ctx.payload.modelID,
},
auto: ctx.payload.auto ?? false,
})
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
yield* revert.cleanup(yield* session.get(ctx.params.sessionID))
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
const defaultAgent = yield* agent.defaultAgent()
const currentAgent =
messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
yield* compact.create({
sessionID: ctx.params.sessionID,
agent: currentAgent,
model: {
providerID: ctx.payload.providerID,
modelID: ctx.payload.modelID,
},
auto: ctx.payload.auto ?? false,
})
yield* prompt.loop({ sessionID: ctx.params.sessionID })
}).pipe(
Effect.provide(SessionRevert.defaultLayer),
Effect.provide(SessionCompaction.defaultLayer),
Effect.provide(SessionPrompt.defaultLayer),
Effect.provide(Agent.defaultLayer),
Effect.provide(Session.defaultLayer),
),
),
),
)
return true
})
@@ -255,15 +351,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
payload: typeof PromptPayload.Type
}) {
const instance = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
return HttpServerResponse.stream(
Stream.fromEffect(
promptSvc
.prompt({
...ctx.payload,
sessionID: ctx.params.sessionID,
})
.pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)),
Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.prompt({
...ctx.payload,
sessionID: ctx.params.sessionID,
} as unknown as SessionPrompt.PromptInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
),
).pipe(
Stream.map((message) => JSON.stringify(message)),
Stream.encodeText,
@@ -276,18 +377,24 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof PromptPayload.Type
}) {
yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause })
yield* bus.publish(Session.Event.Error, {
const instance = yield* InstanceState.context
yield* Effect.sync(() => {
Instance.restore(instance, () => {
void AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
).catch((error) => {
log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error })
void Bus.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
error: new NamedError.Unknown({
message: error instanceof Error ? error.message : String(error),
}).toObject(),
})
}),
),
Effect.forkIn(scope, { startImmediately: true }),
)
})
})
})
return HttpApiSchema.NoContent.make()
})
@@ -295,47 +402,111 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof CommandPayload.Type
}) {
return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
})
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ShellPayload.Type
}) {
return yield* promptSvc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID })
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput),
).pipe(Effect.provide(SessionPrompt.defaultLayer)),
),
),
)
})
const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof RevertPayload.Type
}) {
return yield* revertSvc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })
const instance = yield* InstanceState.context
log.info("revert", ctx.payload)
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionRevert.Service.use((svc) => svc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload })).pipe(
Effect.provide(SessionRevert.defaultLayer),
),
),
),
)
})
const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* revertSvc.unrevert({ sessionID: ctx.params.sessionID })
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
SessionRevert.Service.use((svc) => svc.unrevert({ sessionID: ctx.params.sessionID })).pipe(
Effect.provide(SessionRevert.defaultLayer),
),
),
),
)
})
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
params: { permissionID: PermissionID }
payload: typeof PermissionResponsePayload.Type
}) {
yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response })
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }),
).pipe(Effect.provide(Permission.defaultLayer)),
),
),
)
return true
})
const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
}) {
yield* runState.assertNotBusy(ctx.params.sessionID)
yield* session.removeMessage(ctx.params)
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(ctx.params.sessionID)
yield* session.removeMessage(ctx.params)
}).pipe(Effect.provide(SessionRunState.defaultLayer), Effect.provide(Session.defaultLayer)),
),
),
)
return true
})
const deletePart = Effect.fn("SessionHttpApi.deletePart")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
}) {
yield* session.removePart(ctx.params)
const instance = yield* InstanceState.context
yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.removePart(ctx.params)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
return true
})
@@ -353,7 +524,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
`Part mismatch: body.id='${payload.id}' vs partID='${ctx.params.partID}', body.messageID='${payload.messageID}' vs messageID='${ctx.params.messageID}', body.sessionID='${payload.sessionID}' vs sessionID='${ctx.params.sessionID}'`,
)
}
return yield* session.updatePart(payload)
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
AppRuntime.runPromise(
Session.Service.use((svc) => svc.updatePart(payload)).pipe(Effect.provide(Session.defaultLayer)),
),
),
)
})
return handlers
@@ -1,4 +1,4 @@
import { Workspace } from "@/control-plane/workspace"
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Database } from "@/storage/db"
import { SyncEvent } from "@/sync"
@@ -9,24 +9,15 @@ import { eq } from "drizzle-orm"
import { lte } from "drizzle-orm"
import { not } from "drizzle-orm"
import { or } from "drizzle-orm"
import { Effect, Scope } from "effect"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { HistoryPayload, ReplayPayload } from "../groups/sync"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "server.sync" })
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const scope = yield* Scope.Scope
const sync = yield* SyncEvent.Service
const start = Effect.fn("SyncHttpApi.start")(function* () {
yield* workspace
.startWorkspaceSyncing((yield* InstanceState.context).project.id)
.pipe(Effect.ignore, Effect.forkIn(scope))
startWorkspaceSyncing((yield* InstanceState.context).project.id)
return true
})
@@ -38,22 +29,8 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
type: event.type,
data: { ...event.data },
}))
const source = events[0].aggregateID
log.info("sync replay requested", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
directory: ctx.payload.directory,
})
yield* sync.replayAll(events)
log.info("sync replay complete", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
})
return { sessionID: source }
SyncEvent.replayAll(events)
return { sessionID: events[0].aggregateID }
})
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
@@ -1,53 +1,58 @@
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Instance } from "@/project/instance"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => listAdaptors(instance.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
return yield* workspace.list((yield* InstanceState.context).project)
return Workspace.list((yield* InstanceState.context).project)
})
const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) {
const instance = yield* InstanceState.context
return yield* workspace
.create({
...ctx.payload,
projectID: instance.project.id,
})
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.create({
...ctx.payload,
projectID: instance.project.id,
}),
),
)
})
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
const ids = new Set((yield* workspace.list((yield* InstanceState.context).project)).map((item) => item.id))
return (yield* workspace.status()).filter((item) => ids.has(item.workspaceID))
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
return Workspace.status().filter((item) => ids.has(item.workspaceID))
})
const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) {
return yield* workspace.remove(ctx.params.id)
const instance = yield* InstanceState.context
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
})
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
params: { id: Workspace.Info["id"] }
payload: typeof SessionRestorePayload.Type
}) {
return yield* workspace
.sessionRestore({
workspaceID: ctx.params.id,
sessionID: ctx.payload.sessionID,
})
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.sessionRestore({
workspaceID: ctx.params.id,
sessionID: ctx.payload.sessionID,
}),
),
)
})
return handlers
@@ -1,63 +1,47 @@
import { ConfigService } from "@/effect/config-service"
import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
import { Effect, Encoding, Layer, Redacted, Schema } from "effect"
import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
import { Flag } from "@opencode-ai/core/flag/flag"
const AUTH_TOKEN_QUERY = "auth_token"
const UNAUTHORIZED = 401
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
"@opencode/ExperimentalHttpApiAuthorization",
{
error: HttpApiError.UnauthorizedNoContent,
error: Unauthorized,
security: {
basic: HttpApiSecurity.basic,
authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }),
authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }),
},
},
) {}
export class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
"@opencode/ExperimentalHttpApiServerAuthConfig",
{
password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
},
) {}
const emptyCredential = {
username: "",
password: Redacted.make(""),
}
function validateCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: { readonly username: string; readonly password: Redacted.Redacted },
config: Context.Service.Shape<typeof ServerAuthConfig>,
credential: { readonly username: string; readonly password: typeof emptyCredential.password },
) {
return Effect.gen(function* () {
if (!isAuthRequired(config)) return yield* effect
if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
if (credential.username !== (Flag.OPENCODE_SERVER_USERNAME ?? "opencode")) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
return yield* effect
})
}
function isAuthRequired(config: Context.Service.Shape<typeof ServerAuthConfig>) {
return Option.isSome(config.password) && config.password.value !== ""
}
function isCredentialAuthorized(
credential: { readonly username: string; readonly password: Redacted.Redacted },
config: Context.Service.Shape<typeof ServerAuthConfig>,
) {
return (
Option.isSome(config.password) &&
credential.username === config.username &&
Redacted.value(credential.password) === config.password.value
)
}
function decodeCredential(input: string) {
const emptyCredential = {
username: "",
password: Redacted.make(""),
}
return Encoding.decodeBase64String(input)
.asEffect()
.pipe(
@@ -75,54 +59,13 @@ function decodeCredential(input: string) {
)
}
function validateRawCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: { readonly username: string; readonly password: Redacted.Redacted },
config: Context.Service.Shape<typeof ServerAuthConfig>,
) {
if (!isAuthRequired(config)) return effect
if (!isCredentialAuthorized(credential, config))
return Effect.succeed(HttpServerResponse.empty({ status: UNAUTHORIZED }))
return effect
}
export const authorizationRouterMiddleware = HttpRouter.middleware()(
Effect.gen(function* () {
const config = yield* ServerAuthConfig
if (!isAuthRequired(config)) return (effect) => effect
return (effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
if (match) {
return yield* decodeCredential(match[1]).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
}
const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
if (token) {
return yield* decodeCredential(token).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
}
return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config)
})
}),
)
export const authorizationLayer = Layer.effect(
export const authorizationLayer = Layer.succeed(
Authorization,
Effect.gen(function* () {
const config = yield* ServerAuthConfig
return Authorization.of({
basic: (effect, { credential }) => validateCredential(effect, credential, config),
authToken: (effect, { credential }) =>
decodeCredential(Redacted.value(credential)).pipe(
Effect.flatMap((decoded) => validateCredential(effect, decoded, config)),
),
})
Authorization.of({
basic: (effect, { credential }) => validateCredential(effect, credential),
authToken: (effect, { credential }) =>
Effect.gen(function* () {
return yield* validateCredential(effect, yield* decodeCredential(Redacted.value(credential)))
}),
}),
)
@@ -1,6 +1,13 @@
import { ProxyUtil } from "@/server/proxy-util"
import { Effect, Stream } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import {
FetchHttpClient,
HttpBody,
HttpClient,
HttpClientRequest,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined {
@@ -59,13 +66,12 @@ function statusText(response: unknown) {
}
export function http(
client: HttpClient.HttpClient,
url: string | URL,
extra: HeadersInit | undefined,
request: HttpServerRequest.HttpServerRequest,
): Effect.Effect<HttpServerResponse.HttpServerResponse> {
return Effect.gen(function* () {
const response = yield* client.execute(
const response = yield* HttpClient.execute(
HttpClientRequest.make(request.method as never)(url, {
headers: ProxyUtil.headers(request.headers as HeadersInit, extra),
body: requestBody(request),
@@ -80,7 +86,10 @@ export function http(
statusText: statusText(response),
headers,
})
}).pipe(Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 500 }))))
}).pipe(
Effect.provide(FetchHttpClient.layer),
Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 500 }))),
)
}
export * as HttpApiProxy from "./proxy"
@@ -9,7 +9,7 @@ import * as Fence from "@/server/fence"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Data, Effect, Layer } from "effect"
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
@@ -75,9 +75,9 @@ function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest,
function resolveWorkspace(
id: WorkspaceID | undefined,
envWorkspaceID: WorkspaceID | undefined,
): Effect.Effect<Workspace.Info | void, never, Workspace.Service> {
): Effect.Effect<Workspace.Info | void> {
if (!id || envWorkspaceID) return Effect.void
return Workspace.Service.use((workspace) => workspace.get(id))
return Effect.promise(() => Workspace.get(id))
}
function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServerResponse {
@@ -89,20 +89,19 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe
function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target> {
return Effect.gen(function* () {
const adaptor = yield* Effect.sync(() => getAdaptor(workspace.projectID, workspace.type))
const adaptor = yield* Effect.promise(() => getAdaptor(workspace.projectID, workspace.type))
return yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace)))
})
}
function proxyRemote(
client: HttpClient.HttpClient,
request: HttpServerRequest.HttpServerRequest,
workspace: Workspace.Info,
target: RemoteTarget,
url: URL,
): Effect.Effect<HttpServerResponse.HttpServerResponse, never, Socket.WebSocketConstructor | Workspace.Service> {
): Effect.Effect<HttpServerResponse.HttpServerResponse, never, Socket.WebSocketConstructor> {
return Effect.gen(function* () {
const syncing = yield* Workspace.Service.use((svc) => svc.isSyncing(workspace.id))
const syncing = yield* Effect.promise(() => Workspace.isSyncing(workspace.id))
if (!syncing) {
return HttpServerResponse.text(`broken sync connection for workspace: ${workspace.id}`, {
status: 503,
@@ -112,19 +111,12 @@ function proxyRemote(
const proxyURL = workspaceProxyURL(target.url, url)
const headers = request.headers as Record<string, string>
if (headers["upgrade"]?.toLowerCase() === "websocket") return yield* HttpApiProxy.websocket(request, proxyURL)
const response = yield* HttpApiProxy.http(client, proxyURL, target.headers, request)
const response = yield* HttpApiProxy.http(proxyURL, target.headers, request)
const sync = Fence.parse(new Headers(response.headers))
if (sync) {
const syncFailure = yield* Fence.waitEffect(
workspace.id,
sync,
request.source instanceof Request ? request.source.signal : undefined,
).pipe(
Effect.as(undefined),
Effect.catch((error) => Effect.succeed(HttpServerResponse.text(error.message, { status: 503 }))),
if (sync)
yield* Effect.promise(() =>
Fence.wait(workspace.id, sync, request.source instanceof Request ? request.source.signal : undefined),
)
if (syncFailure) return syncFailure
}
return response
})
}
@@ -133,7 +125,7 @@ function planWorkspaceRequest(
request: HttpServerRequest.HttpServerRequest,
url: URL,
workspace: Workspace.Info,
): Effect.Effect<RequestPlan, never, Workspace.Service> {
): Effect.Effect<RequestPlan> {
return Effect.gen(function* () {
const target = yield* resolveTarget(workspace)
if (target.type === "remote") return RequestPlan.Remote({ request, workspace, target, url })
@@ -144,7 +136,7 @@ function planWorkspaceRequest(
function planRequest(
request: HttpServerRequest.HttpServerRequest,
sessionWorkspaceID?: WorkspaceID,
): Effect.Effect<RequestPlan, never, Workspace.Service> {
): Effect.Effect<RequestPlan> {
return Effect.gen(function* () {
const url = requestURL(request)
const envWorkspaceID = configuredWorkspaceID()
@@ -164,25 +156,31 @@ function planRequest(
}
function routeWorkspace<E>(
client: HttpClient.HttpClient,
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
plan: RequestPlan,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor | Workspace.Service> {
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor> {
return RequestPlan.$match(plan, {
MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)),
Remote: ({ request, workspace, target, url }) => proxyRemote(client, request, workspace, target, url),
Remote: ({ request, workspace, target, url }) => proxyRemote(request, workspace, target, url),
Local: ({ directory, workspaceID }) =>
effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID }))),
})
}
function routeWorkspaceRequest<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
request: HttpServerRequest.HttpServerRequest,
sessionWorkspaceID?: WorkspaceID,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor> {
return Effect.flatMap(planRequest(request, sessionWorkspaceID), (plan) => routeWorkspace(effect, plan))
}
function routeHttpApiWorkspace<E>(
client: HttpClient.HttpClient,
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext>,
): Effect.Effect<
HttpServerResponse.HttpServerResponse,
E,
Session.Service | Workspace.Service | HttpServerRequest.HttpServerRequest | Socket.WebSocketConstructor
Session.Service | HttpServerRequest.HttpServerRequest | Socket.WebSocketConstructor
> {
return Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
@@ -190,8 +188,7 @@ function routeHttpApiWorkspace<E>(
const session = sessionID
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void))
: undefined
const plan = yield* planRequest(request, session?.workspaceID)
return yield* routeWorkspace(client, effect, plan)
return yield* routeWorkspaceRequest(effect, request, session?.workspaceID)
})
}
@@ -199,13 +196,8 @@ export const workspaceRoutingLayer = Layer.effect(
WorkspaceRoutingMiddleware,
Effect.gen(function* () {
const makeWebSocket = yield* Socket.WebSocketConstructor
const workspace = yield* Workspace.Service
const client = yield* HttpClient.HttpClient
return WorkspaceRoutingMiddleware.of((effect) =>
routeHttpApiWorkspace(client, effect).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
Effect.provideService(Workspace.Service, workspace),
),
routeHttpApiWorkspace(effect).pipe(Effect.provideService(Socket.WebSocketConstructor, makeWebSocket)),
)
}),
)
@@ -213,16 +205,12 @@ export const workspaceRoutingLayer = Layer.effect(
export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()(
Effect.gen(function* () {
const makeWebSocket = yield* Socket.WebSocketConstructor
const workspace = yield* Workspace.Service
const client = yield* HttpClient.HttpClient
return (effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const plan = yield* planRequest(request)
return yield* routeWorkspace(client, effect, plan)
}).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
Effect.provideService(Workspace.Service, workspace),
)
return yield* routeWorkspaceRequest(effect, request).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
)
})
}),
)
@@ -1,8 +1,7 @@
import { Context, Effect, Layer } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { FetchHttpClient, HttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
@@ -23,26 +22,19 @@ import { Provider } from "@/provider/provider"
import { Pty } from "@/pty"
import { Question } from "@/question"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
import { SessionPrompt } from "@/session/prompt"
import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { Skill } from "@/skill"
import { SyncEvent } from "@/sync"
import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace"
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { serveUIEffect } from "@/server/routes/ui"
import { isAllowedCorsOrigin } from "@/server/cors"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
import { EventApi, eventHandlers } from "./event"
import { authorizationLayer } from "./middleware/authorization"
import { eventRoute } from "./event"
import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control"
import { experimentalHandlers } from "./handlers/experimental"
@@ -65,7 +57,7 @@ import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import * as ServerBackend from "@/server/backend"
export const context = Context.makeUnsafe<unknown>(new Map())
export const context = Context.empty() as Context.Context<unknown>
const runtime = HttpRouter.middleware()(
Effect.succeed((effect) =>
@@ -77,24 +69,12 @@ const runtime = HttpRouter.middleware()(
),
).layer
const cors = (corsOptions?: CorsOptions) =>
HttpRouter.middleware(
HttpMiddleware.cors({
allowedOrigins: (origin) => isAllowedCorsOrigin(origin, corsOptions),
maxAge: 86_400,
}),
{ global: true },
)
const cors = HttpRouter.middleware(HttpMiddleware.cors({
allowedOrigins: isAllowedCorsOrigin,
maxAge: 86_400,
}), { global: true })
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
const instanceRouterLayer = authorizationRouterMiddleware
.combine(instanceRouterMiddleware)
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer))
const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe(
Layer.provide(eventHandlers),
Layer.provide(instanceRouterLayer),
)
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
Layer.provide([
configHandlers,
@@ -114,85 +94,62 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
]),
)
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(
Layer.provide(
instanceRouterMiddleware
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
),
)
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
Layer.provide([
authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
authorizationLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
instanceContextLayer,
]),
)
const uiRoute = HttpRouter.use((router) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const client = yield* HttpClient.HttpClient
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
}),
).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))))
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
Layer.provide([
cors,
runtime,
Account.defaultLayer,
Agent.defaultLayer,
Auth.defaultLayer,
Command.defaultLayer,
Config.defaultLayer,
File.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
Installation.defaultLayer,
MCP.defaultLayer,
Permission.defaultLayer,
Project.defaultLayer,
ProviderAuth.defaultLayer,
Provider.defaultLayer,
Pty.defaultLayer,
Question.defaultLayer,
Ripgrep.defaultLayer,
Session.defaultLayer,
SessionRunState.defaultLayer,
SessionStatus.defaultLayer,
SessionSummary.defaultLayer,
Skill.defaultLayer,
Todo.defaultLayer,
ToolRegistry.defaultLayer,
Vcs.defaultLayer,
Worktree.defaultLayer,
Bus.layer,
HttpServer.layerServices,
]),
Layer.provideMerge(Observability.layer),
)
export function createRoutes(corsOptions?: CorsOptions) {
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
Layer.provide([
cors(corsOptions),
runtime,
Account.defaultLayer,
Agent.defaultLayer,
Auth.defaultLayer,
Command.defaultLayer,
Config.defaultLayer,
File.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
Installation.defaultLayer,
MCP.defaultLayer,
Permission.defaultLayer,
Project.defaultLayer,
ProviderAuth.defaultLayer,
Provider.defaultLayer,
Pty.defaultLayer,
Question.defaultLayer,
Ripgrep.defaultLayer,
Session.defaultLayer,
SessionCompaction.defaultLayer,
SessionPrompt.defaultLayer,
SessionRevert.defaultLayer,
SessionShare.defaultLayer,
SessionRunState.defaultLayer,
SessionStatus.defaultLayer,
SessionSummary.defaultLayer,
SyncEvent.defaultLayer,
Skill.defaultLayer,
Todo.defaultLayer,
ToolRegistry.defaultLayer,
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.defaultLayer,
Bus.layer,
AppFileSystem.defaultLayer,
FetchHttpClient.layer,
HttpServer.layerServices,
]),
Layer.provideMerge(Observability.layer),
)
}
export const routes = createRoutes()
const defaultWebHandler = lazy(() =>
export const webHandler = lazy(() =>
HttpRouter.toWebHandler(routes, {
memoMap,
middleware: disposeMiddleware,
}),
)
export function webHandler(corsOptions?: CorsOptions) {
if (!corsOptions?.cors?.length) return defaultWebHandler()
return HttpRouter.toWebHandler(createRoutes(corsOptions), {
// Server-level CORS options are dynamic; don't reuse the default route layer memoized without them.
memoMap: Layer.makeMemoMapUnsafe(),
middleware: disposeMiddleware,
})
}
export * as ExperimentalHttpApiServer from "./server"
@@ -78,22 +78,18 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const query = c.req.valid("query")
return c.json(
await runRequest(
"SessionRoutes.list",
c,
Session.Service.use((svc) =>
svc.list({
directory: query.scope === "project" ? undefined : query.directory,
path: query.path,
roots: queryBoolean(query.roots),
start: query.start,
search: query.search,
limit: query.limit,
}),
),
),
)
const sessions: Session.Info[] = []
for await (const session of Session.list({
directory: query.scope === "project" ? undefined : query.directory,
path: query.path,
roots: queryBoolean(query.roots),
start: query.start,
search: query.search,
limit: query.limit,
})) {
sessions.push(session)
}
return c.json(sessions)
},
)
.get(
@@ -12,8 +12,7 @@ import { eq } from "drizzle-orm"
import { EventTable } from "@/sync/event.sql"
import { lazy } from "@/util/lazy"
import * as Log from "@opencode-ai/core/util/log"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
@@ -47,9 +46,7 @@ export const SyncRoutes = lazy(() =>
},
}),
async (c) => {
void AppRuntime.runPromise(
Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(Instance.project.id)),
)
startWorkspaceSyncing(Instance.project.id)
return c.json(true)
},
)
@@ -94,7 +91,7 @@ export const SyncRoutes = lazy(() =>
last: events.at(-1)?.seq,
directory: body.directory,
})
await AppRuntime.runPromise(SyncEvent.use.replayAll(events))
SyncEvent.replayAll(events)
log.info("sync replay complete", {
sessionID: source,
+29 -101
View File
@@ -1,13 +1,9 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Stream } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { Hono } from "hono"
import { proxy } from "hono/proxy"
import { getMimeType } from "hono/utils/mime"
import { createHash } from "node:crypto"
import fs from "node:fs/promises"
import { ProxyUtil } from "../proxy-util"
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
? Promise.resolve(null)
@@ -16,112 +12,44 @@ const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
const DEFAULT_CSP =
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
const UI_UPSTREAM = new URL("https://app.opencode.ai")
const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
function themePreloadHash(body: string) {
return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
}
function requestBody(request: HttpServerRequest.HttpServerRequest) {
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
const len = request.headers["content-length"]
return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len))
}
function proxyResponseHeaders(headers: Record<string, string>) {
const result = new Headers(headers)
// FetchHttpClient exposes decoded response bodies, so forwarding upstream
// transfer metadata makes browsers decode already-decoded assets again.
result.delete("content-encoding")
result.delete("content-length")
return result
}
function upstreamURL(path: string) {
return new URL(path, UI_UPSTREAM).toString()
}
function embeddedUI() {
if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
return embeddedUIPromise
}
export async function serveUI(request: Request) {
const embeddedWebUI = await embeddedUI()
const path = new URL(request.url).pathname
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return Response.json({ error: "Not Found" }, { status: 404 })
if (await fs.exists(match)) {
const mime = getMimeType(match) ?? "text/plain"
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
return new Response(new Uint8Array(await fs.readFile(match)), { headers })
}
return Response.json({ error: "Not Found" }, { status: 404 })
}
const response = await proxy(upstreamURL(path), {
raw: request,
headers: ProxyUtil.headers(request, { host: UI_UPSTREAM.host }),
})
const match = response.headers.get("content-type")?.includes("text/html")
? themePreloadHash(await response.clone().text())
: undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash))
return response
}
export function serveUIEffect(
request: HttpServerRequest.HttpServerRequest,
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
) {
return Effect.gen(function* () {
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
const path = new URL(request.url, "http://localhost").pathname
export const UIRoutes = (): Hono =>
new Hono().all("/*", async (c) => {
const embeddedWebUI = await embeddedUIPromise
const path = c.req.path
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
if (!match) return c.json({ error: "Not Found" }, 404)
if (yield* services.fs.existsSafe(match)) {
if (await fs.exists(match)) {
const mime = getMimeType(match) ?? "text/plain"
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers })
c.header("Content-Type", mime)
if (mime.startsWith("text/html")) {
c.header("Content-Security-Policy", DEFAULT_CSP)
}
return c.body(new Uint8Array(await fs.readFile(match)))
} else {
return c.json({ error: "Not Found" }, 404)
}
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
} else {
const response = await proxy(`https://app.opencode.ai${path}`, {
raw: c.req.raw,
headers: {
...Object.fromEntries(c.req.raw.headers.entries()),
host: "app.opencode.ai",
},
})
const match = response.headers.get("content-type")?.includes("text/html")
? (await response.clone().text()).match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
: undefined
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
response.headers.set("Content-Security-Policy", csp(hash))
return response
}
const response = yield* services.client.execute(
HttpClientRequest.make(request.method)(upstreamURL(path), {
headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
body: requestBody(request),
}),
)
const headers = proxyResponseHeaders(response.headers)
if (response.headers["content-type"]?.includes("text/html")) {
const body = yield* response.text
const match = themePreloadHash(body)
headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : ""))
return HttpServerResponse.text(body, { status: response.status, headers })
}
headers.set("Content-Security-Policy", csp())
return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
status: response.status,
headers,
})
})
}
export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw))
+16 -15
View File
@@ -18,7 +18,6 @@ import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace"
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
import * as ServerBackend from "./backend"
import type { CorsOptions } from "./cors"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -39,13 +38,6 @@ type ServerApp = {
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
}
type ListenOptions = CorsOptions & {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
}
const DefaultHono = lazy(() =>
withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })),
)
@@ -62,14 +54,14 @@ export const Default = () => {
return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono()
}
function create(opts: ListenOptions) {
function create(opts: { cors?: string[] }) {
const selected = select()
return selected.backend === "effect-httpapi"
? withBackend(selected, createHttpApi(opts))
? withBackend(selected, createHttpApi())
: withBackend(selected, createHono(opts, selected))
}
export function Legacy(opts: CorsOptions = {}) {
export function Legacy(opts: { cors?: string[] } = {}) {
return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" }))
}
@@ -82,8 +74,8 @@ function withBackend<T extends { app: ServerApp; runtime: unknown }>(selection:
return built
}
function createHttpApi(corsOptions?: CorsOptions) {
const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler
function createHttpApi() {
const handler = ExperimentalHttpApiServer.webHandler().handler
const app: ServerApp = {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input, init) {
@@ -96,7 +88,10 @@ function createHttpApi(corsOptions?: CorsOptions) {
}
}
function createHono(opts: CorsOptions, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) {
function createHono(
opts: { cors?: string[] },
selection: ServerBackend.Selection = ServerBackend.force(select(), "hono"),
) {
const backendAttributes = ServerBackend.attributes(selection)
const app = new Hono()
.onError(ErrorMiddleware)
@@ -156,7 +151,13 @@ export async function openapi() {
export let url: URL
export async function listen(opts: ListenOptions): Promise<Listener> {
export async function listen(opts: {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
}): Promise<Listener> {
const built = create(opts)
const server = await built.runtime.listen(opts)
+2 -4
View File
@@ -72,9 +72,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return next()
}
const workspace = await AppRuntime.runPromise(
Workspace.Service.use((svc) => svc.get(WorkspaceID.make(workspaceID))),
)
const workspace = await Workspace.get(WorkspaceID.make(workspaceID))
if (!workspace) {
return new Response(`Workspace not found: ${workspaceID}`, {
@@ -91,7 +89,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return next()
}
const adaptor = getAdaptor(workspace.projectID, workspace.type)
const adaptor = await getAdaptor(workspace.projectID, workspace.type)
const target = await adaptor.target(workspace)
if (target.type === "local") {
+161 -149
View File
@@ -1,3 +1,4 @@
import os from "os"
import path from "path"
import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
@@ -7,15 +8,30 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"
const log = Log.create({ service: "instruction" })
const FILES = [
"AGENTS.md",
...(Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [] : ["CLAUDE.md"]),
"CONTEXT.md", // deprecated
]
function globalFiles() {
const files = []
if (Flag.OPENCODE_CONFIG_DIR) {
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
}
files.push(path.join(Global.Path.config, "AGENTS.md"))
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}
return files
}
function extract(messages: MessageV2.WithParts[]) {
const paths = new Set<string>()
for (const msg of messages) {
@@ -47,180 +63,176 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient
> = Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const fs = yield* AppFileSystem.Service
const global = yield* Global.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const globalFiles = [
path.join(global.config, "AGENTS.md"),
...(!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
]
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const fs = yield* AppFileSystem.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() =>
Effect.succeed({
// Track which instruction files have already been attached for a given assistant message.
claims: new Map<MessageID, Set<string>>(),
}),
),
)
const relative = Effect.fnUntraced(function* (instruction: string) {
const ctx = yield* InstanceState.context
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return yield* fs
.globUp(instruction, ctx.directory, ctx.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
}
return yield* fs
.globUp(instruction, global.config, global.config)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
})
const read = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
})
const fetch = Effect.fnUntraced(function* (url: string) {
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
Effect.timeout(5000),
Effect.catch(() => Effect.succeed(null)),
const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() =>
Effect.succeed({
// Track which instruction files have already been attached for a given assistant message.
claims: new Map<MessageID, Set<string>>(),
}),
),
)
if (!res) return ""
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
return new TextDecoder().decode(body)
})
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
const s = yield* InstanceState.get(state)
s.claims.delete(messageID)
})
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
const config = yield* cfg.get()
const ctx = yield* InstanceState.context
const paths = new Set<string>()
for (const file of globalFiles) {
if (yield* fs.existsSafe(file)) {
paths.add(path.resolve(file))
break
const relative = Effect.fnUntraced(function* (instruction: string) {
const ctx = yield* InstanceState.context
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return yield* fs
.globUp(instruction, ctx.directory, ctx.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
}
}
if (!Flag.OPENCODE_CONFIG_DIR) {
log.warn(
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
)
return []
}
return yield* fs
.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
})
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree)
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
const read = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
})
const fetch = Effect.fnUntraced(function* (url: string) {
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
Effect.timeout(5000),
Effect.catch(() => Effect.succeed(null)),
)
if (!res) return ""
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
return new TextDecoder().decode(body)
})
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
const s = yield* InstanceState.get(state)
s.claims.delete(messageID)
})
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
const config = yield* cfg.get()
const ctx = yield* InstanceState.context
const paths = new Set<string>()
for (const file of globalFiles()) {
if (yield* fs.existsSafe(file)) {
paths.add(path.resolve(file))
break
}
}
}
if (config.instructions) {
for (const raw of config.instructions) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw
const matches = yield* (
path.isAbsolute(instruction)
? fs.glob(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
})
: relative(instruction)
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
matches.forEach((item) => paths.add(path.resolve(item)))
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = yield* fs.findUp(file, ctx.directory, ctx.worktree)
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break
}
}
}
}
return paths
})
if (config.instructions) {
for (const raw of config.instructions) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw
const matches = yield* (
path.isAbsolute(instruction)
? fs.glob(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
})
: relative(instruction)
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
matches.forEach((item) => paths.add(path.resolve(item)))
}
}
const system = Effect.fn("Instruction.system")(function* () {
const config = yield* cfg.get()
const paths = yield* systemPaths()
const urls = (config.instructions ?? []).filter(
(item) => item.startsWith("https://") || item.startsWith("http://"),
)
return paths
})
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
const system = Effect.fn("Instruction.system")(function* () {
const config = yield* cfg.get()
const paths = yield* systemPaths()
const urls = (config.instructions ?? []).filter(
(item) => item.startsWith("https://") || item.startsWith("http://"),
)
return [
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
]
})
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
const find = Effect.fn("Instruction.find")(function* (dir: string) {
for (const file of FILES) {
const filepath = path.resolve(path.join(dir, file))
if (yield* fs.existsSafe(filepath)) return filepath
}
return undefined
})
return [
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
]
})
const resolve = Effect.fn("Instruction.resolve")(function* (
messages: MessageV2.WithParts[],
filepath: string,
messageID: MessageID,
) {
const sys = yield* systemPaths()
const already = extract(messages)
const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)
const root = path.resolve(yield* InstanceState.directory)
const find = Effect.fn("Instruction.find")(function* (dir: string) {
for (const file of FILES) {
const filepath = path.resolve(path.join(dir, file))
if (yield* fs.existsSafe(filepath)) return filepath
}
})
const target = path.resolve(filepath)
let current = path.dirname(target)
const resolve = Effect.fn("Instruction.resolve")(function* (
messages: MessageV2.WithParts[],
filepath: string,
messageID: MessageID,
) {
const sys = yield* systemPaths()
const already = extract(messages)
const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)
const root = path.resolve(yield* InstanceState.directory)
const target = path.resolve(filepath)
let current = path.dirname(target)
// Walk upward from the file being read and attach nearby instruction files once per message.
while (current.startsWith(root) && current !== root) {
const found = yield* find(current)
if (!found || found === target || sys.has(found) || already.has(found)) {
current = path.dirname(current)
continue
}
let set = s.claims.get(messageID)
if (!set) {
set = new Set()
s.claims.set(messageID, set)
}
if (set.has(found)) {
current = path.dirname(current)
continue
}
set.add(found)
const content = yield* read(found)
if (content) {
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
}
// Walk upward from the file being read and attach nearby instruction files once per message.
while (current.startsWith(root) && current !== root) {
const found = yield* find(current)
if (!found || found === target || sys.has(found) || already.has(found)) {
current = path.dirname(current)
continue
}
let set = s.claims.get(messageID)
if (!set) {
set = new Set()
s.claims.set(messageID, set)
}
if (set.has(found)) {
current = path.dirname(current)
continue
}
return results
})
set.add(found)
const content = yield* read(found)
if (content) {
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
}
current = path.dirname(current)
}
return results
})
return Service.of({ clear, systemPaths, system, find, resolve })
}),
)
return Service.of({ clear, systemPaths, system, find, resolve })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Global.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
+10 -13
View File
@@ -3,11 +3,11 @@ import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer, Record } from "effect"
import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
import { mergeDeep } from "remeda"
import { mergeDeep, pipe } from "remeda"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
import { ProviderTransform } from "@/provider/transform"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { Instance } from "@/project/instance"
import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
@@ -29,10 +29,6 @@ const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
type Result = Awaited<ReturnType<typeof streamText>>
// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
mergeDeep(target, source ?? {}) as Record<string, any>
export type StreamInput = {
user: MessageV2.User
sessionID: string
@@ -138,7 +134,12 @@ const live: Layer.Layer<
sessionID: input.sessionID,
providerOptions: item.options,
})
const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)
const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isOpenaiOauth) {
options.instructions = system.join("\n")
}
@@ -267,7 +268,7 @@ const live: Layer.Layer<
const bridge = yield* EffectBridge.make()
const approvedToolsForSession = new Set<string>()
workflowModel.approvalHandler = InstanceState.bind(async (approvalTools) => {
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
// Auto-approve tools that were already approved in this session
// (prevents infinite approval loops for server-side MCP tools)
@@ -329,10 +330,6 @@ const live: Layer.Layer<
})
: undefined
const opencodeProjectID = input.model.providerID.startsWith("opencode")
? (yield* InstanceState.context).project.id
: undefined
return streamText({
onError(error) {
l.error("stream error", {
@@ -372,7 +369,7 @@ const live: Layer.Layer<
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": opencodeProjectID,
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
+24 -15
View File
@@ -45,7 +45,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Truncate } from "@/tool/truncate"
import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process"
import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect"
import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
@@ -127,7 +127,7 @@ export const layer = Layer.effect(
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
const ctx = yield* InstanceState.context
const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
const parts: PromptInput["parts"] = [{ type: "text", text: template }]
const files = ConfigMarkdown.files(template)
const seen = new Set<string>()
yield* Effect.forEach(
@@ -256,8 +256,7 @@ export const layer = Layer.effect(
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
const plan = Session.plan(input.session)
if (!(yield* fsys.existsSafe(plan))) return input.messages
const part = yield* sessions.updatePart({
id: PartID.ascending(),
@@ -273,8 +272,7 @@ export const layer = Layer.effect(
if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages
const ctx = yield* InstanceState.context
const plan = Session.plan(input.session, ctx)
const plan = Session.plan(input.session)
const exists = yield* fsys.existsSafe(plan)
if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
const part = yield* sessions.updatePart({
@@ -1014,7 +1012,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
case "file:": {
log.info("file", { mime: part.mime })
const filepath = fileURLToPath(part.url)
const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime
if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory"
const { read } = yield* registry.named()
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {
@@ -1033,7 +1031,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
.pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))
}
if (mime === "text/plain") {
if (part.mime === "text/plain") {
let offset: number | undefined
let limit: number | undefined
const range = { start: url.searchParams.get("start"), end: url.searchParams.get("end") }
@@ -1091,7 +1089,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})),
)
} else {
pieces.push({ ...part, mime, messageID: info.id, sessionID: input.sessionID })
pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
}
} else {
const error = Cause.squash(exit.cause)
@@ -1112,7 +1110,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return pieces
}
if (mime === "application/x-directory") {
if (part.mime === "application/x-directory") {
const args = { filePath: filepath }
const exit = yield* execRead(args).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
@@ -1148,7 +1146,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
synthetic: true,
text: exit.value.output,
},
{ ...part, mime, messageID: info.id, sessionID: input.sessionID },
{ ...part, messageID: info.id, sessionID: input.sessionID },
]
}
@@ -1166,9 +1164,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID: input.sessionID,
type: "file",
url:
`data:${mime};base64,` +
`data:${part.mime};base64,` +
Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"),
mime,
mime: part.mime,
filename: part.filename!,
source: part.source,
},
@@ -1443,7 +1441,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
sys.skills(agent),
sys.environment(model),
Effect.sync(() => sys.environment(model)),
instruction.system().pipe(Effect.orDie),
MessageV2.toModelMessagesEffect(msgs, model),
])
@@ -1702,7 +1700,18 @@ export const PromptInput = Schema.Struct({
]).annotate({ discriminator: "type" }),
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type PromptInput = Schema.Schema.Type<typeof PromptInput>
// `z.discriminatedUnion` erases the discriminated members' shapes back to
// `{}` when walked from the generic `z.ZodType` input. Restore the precise
// `parts` type from the exported Schema input types so callers see a proper
// tagged union.
type PartInputUnion =
| MessageV2.TextPartInput
| MessageV2.FilePartInput
| MessageV2.AgentPartInput
| MessageV2.SubtaskPartInput
export type PromptInput = Omit<Schema.Schema.Type<typeof PromptInput>, "parts"> & {
parts: PartInputUnion[]
}
export class LoopInput extends Schema.Class<LoopInput>("SessionPrompt.LoopInput")({
sessionID: SessionID,

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